/** * Anvil Hosting - Main Application JavaScript * 대화형 서버 런처 및 가격 데이터 관리 */ // API 설정 (프록시 사용 - /api/* 경로) const API_CONFIG = { baseUrl: '/api', // Cloudflare Pages Functions 프록시 사용 apiKey: null, // 프록시에서 처리 timeout: 10000 }; /** * API 서비스 - cloud-instances-api 워커 호출 */ const ApiService = { /** * API 요청 헬퍼 */ async request(endpoint, options = {}) { const url = `${API_CONFIG.baseUrl}${endpoint}`; const headers = { 'Content-Type': 'application/json', ...(API_CONFIG.apiKey && { 'X-API-Key': API_CONFIG.apiKey }), ...options.headers }; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout); const response = await fetch(url, { ...options, headers, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || `HTTP ${response.status}`); } return await response.json(); } catch (error) { if (error.name === 'AbortError') { throw new Error('요청 시간이 초과되었습니다.'); } throw error; } }, /** * Health Check - 서버 상태 확인 */ async health() { console.log('[API] Health check...'); return this.request('/health'); }, /** * Instances 조회 - 클라우드 인스턴스 목록 */ async getInstances(params = {}) { console.log('[API] Fetching instances...', params); const query = new URLSearchParams(); // 파라미터 설정 if (params.provider) query.set('provider', params.provider); if (params.region) query.set('region', params.region); if (params.min_vcpu) query.set('min_vcpu', params.min_vcpu); if (params.max_vcpu) query.set('max_vcpu', params.max_vcpu); if (params.min_memory_gb) query.set('min_memory_gb', params.min_memory_gb); if (params.max_memory_gb) query.set('max_memory_gb', params.max_memory_gb); if (params.max_price) query.set('max_price', params.max_price); if (params.instance_family) query.set('instance_family', params.instance_family); if (params.has_gpu !== undefined) query.set('has_gpu', params.has_gpu); if (params.sort_by) query.set('sort_by', params.sort_by); if (params.order) query.set('order', params.order); if (params.limit) query.set('limit', params.limit); if (params.offset) query.set('offset', params.offset); const queryString = query.toString(); return this.request(`/instances${queryString ? '?' + queryString : ''}`); }, /** * Recommend - 기술 스택 기반 인스턴스 추천 */ async recommend(stack, scale = 'medium', budgetMax = null) { console.log('[API] Getting recommendations...', { stack, scale, budgetMax }); const body = { stack, scale }; if (budgetMax) body.budget_max = budgetMax; return this.request('/recommend', { method: 'POST', body: JSON.stringify(body) }); }, /** * Sync 트리거 - 프로바이더 데이터 동기화 (관리자용) */ async sync(provider = null) { console.log('[API] Triggering sync...', { provider }); const body = provider ? { provider } : {}; return this.request('/sync', { method: 'POST', body: JSON.stringify(body) }); } }; // 텔레그램 봇 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 // 검증용 데이터 }, // 웹 로그인 사용자 (텔레그램 로그인 위젯 사용) webUser: null, // 현재 로그인된 사용자 (텔레그램 또는 웹) get currentUser() { return this.telegram.user || this.webUser; }, // 서버 구성 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'); } }, // 미니앱 전용 초기화 (/app 페이지용) initMiniApp() { const tg = window.Telegram?.WebApp; // 실제 텔레그램 환경인지 확인 (initData가 있어야 진짜 텔레그램) const isRealTelegram = tg && tg.initData && tg.initData.length > 0; if (isRealTelegram) { tg.ready(); tg.expand(); this.telegram.isAvailable = true; this.telegram.user = tg.initDataUnsafe.user || null; this.telegram.initData = tg.initData; console.log('[MiniApp] Telegram environment detected', { user: this.telegram.user, platform: tg.platform }); // 미니앱은 무조건 대시보드 로드 this.loadDashboard(); } else { console.log('[MiniApp] Not in Telegram environment'); this.telegram.isAvailable = false; // 웹 브라우저: localStorage에서 webUser 복원 const savedUser = localStorage.getItem('anvil_web_user'); if (savedUser) { try { this.webUser = JSON.parse(savedUser); console.log('[MiniApp] Web user restored from localStorage:', this.webUser); this.loadDashboard(); } catch (e) { console.error('[MiniApp] Failed to parse saved user:', e); localStorage.removeItem('anvil_web_user'); // 복원 실패시 로그인 위젯 표시 this.loadTelegramLoginWidget(); } } else { // 로그인 필요 - 텔레그램 로그인 위젯 로드 this.loadTelegramLoginWidget(); } } }, // 텔레그램 로그인 위젯 동적 로드 loadTelegramLoginWidget() { // Alpine이 DOM을 렌더링할 시간을 주기 위해 약간 지연 setTimeout(() => { const container = document.getElementById('telegram-login-container'); if (!container) { console.log('[MiniApp] Login container not found, retrying...'); setTimeout(() => this.loadTelegramLoginWidget(), 100); return; } // 이미 위젯이 있으면 스킵 if (container.querySelector('iframe')) { console.log('[MiniApp] Login widget already loaded'); return; } console.log('[MiniApp] Loading Telegram Login Widget...'); // 텔레그램 위젯 스크립트 동적 생성 const script = document.createElement('script'); script.src = 'https://telegram.org/js/telegram-widget.js?22'; script.setAttribute('data-telegram-login', 'AnvilForgeBot'); script.setAttribute('data-size', 'large'); script.setAttribute('data-radius', '12'); script.setAttribute('data-onauth', 'onTelegramAuth(user)'); script.setAttribute('data-request-access', 'write'); script.async = true; container.appendChild(script); console.log('[MiniApp] Telegram Login Widget script added'); }, 50); }, // 웹 텔레그램 로그인 핸들러 handleWebLogin(user) { console.log('[MiniApp] Web login received:', user); // 사용자 정보 저장 this.webUser = { id: user.id, first_name: user.first_name, last_name: user.last_name || '', username: user.username || '', photo_url: user.photo_url || '', auth_date: user.auth_date }; // localStorage에 저장 (세션 유지) localStorage.setItem('anvil_web_user', JSON.stringify(this.webUser)); console.log('[MiniApp] Web user logged in:', this.webUser); // 대시보드 로드 this.loadDashboard(); }, // 로그아웃 logout() { console.log('[MiniApp] Logging out...'); // webUser 초기화 this.webUser = null; localStorage.removeItem('anvil_web_user'); // 서버/통계/알림 초기화 this.servers = []; this.stats = { totalCost: 0, totalServers: 0, runningServers: 0, costBreakdown: [] }; this.notifications = []; this.unreadCount = 0; console.log('[MiniApp] Logged out successfully'); }, // 대시보드 초기 로드 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(); } }, // ============================================================ // API 연동 메서드 // ============================================================ // API 테스트 결과 apiTestResult: null, apiTestLoading: false, // API 인스턴스 데이터 cloudInstances: [], cloudInstancesLoading: false, cloudInstancesError: null, cloudInstancesFilter: { provider: '', min_vcpu: '', max_price: '', sort_by: 'monthly_price', order: 'asc', limit: 20 }, // API Health Check 테스트 async testApiHealth() { console.log('[App] Testing API health...'); this.apiTestLoading = true; this.apiTestResult = null; try { const result = await ApiService.health(); this.apiTestResult = { success: true, data: result, message: `API 정상 (${result.status})` }; console.log('[App] API health check success:', result); } catch (error) { this.apiTestResult = { success: false, error: error.message, message: `API 오류: ${error.message}` }; console.error('[App] API health check failed:', error); } finally { this.apiTestLoading = false; } }, // 클라우드 인스턴스 목록 조회 async fetchCloudInstances() { console.log('[App] Fetching cloud instances...'); this.cloudInstancesLoading = true; this.cloudInstancesError = null; try { const params = {}; if (this.cloudInstancesFilter.provider) params.provider = this.cloudInstancesFilter.provider; if (this.cloudInstancesFilter.min_vcpu) params.min_vcpu = parseInt(this.cloudInstancesFilter.min_vcpu); if (this.cloudInstancesFilter.max_price) params.max_price = parseFloat(this.cloudInstancesFilter.max_price); if (this.cloudInstancesFilter.sort_by) params.sort_by = this.cloudInstancesFilter.sort_by; if (this.cloudInstancesFilter.order) params.order = this.cloudInstancesFilter.order; params.limit = this.cloudInstancesFilter.limit || 20; const result = await ApiService.getInstances(params); if (result.success && result.data) { this.cloudInstances = result.data.instances || []; console.log('[App] Cloud instances loaded:', this.cloudInstances.length); } else { throw new Error(result.error?.message || 'Unknown error'); } } catch (error) { this.cloudInstancesError = error.message; console.error('[App] Failed to fetch cloud instances:', error); } finally { this.cloudInstancesLoading = false; } }, // 추천 인스턴스 조회 recommendResult: null, recommendLoading: false, recommendStack: ['nginx', 'nodejs'], recommendScale: 'medium', async getRecommendation() { console.log('[App] Getting recommendation...'); this.recommendLoading = true; this.recommendResult = null; try { const result = await ApiService.recommend(this.recommendStack, this.recommendScale); if (result.success !== false) { this.recommendResult = { success: true, data: result }; console.log('[App] Recommendation received:', result); } else { throw new Error(result.error?.message || 'Recommendation failed'); } } catch (error) { this.recommendResult = { success: false, error: error.message }; console.error('[App] Recommendation failed:', error); } finally { this.recommendLoading = false; } }, // 메모리 포맷팅 (MB to GB) formatMemory(mb) { if (mb >= 1024) { return (mb / 1024).toFixed(1) + ' GB'; } return mb + ' MB'; }, // 가격 포맷팅 (USD) formatUsd(price) { return '$' + price.toFixed(2); } }; } /** * 가격표 컴포넌트 - API 연동 버전 (아시아 전용, 캐싱 적용) */ function pricingTable() { // 캐시 키 및 유효 시간 (1시간) const CACHE_KEY = 'anvil_pricing_cache_v3'; // v3: Global 탭 통합 (도쿄/오사카/싱가폴) const CACHE_TTL = 60 * 60 * 1000; // 1시간 // Fallback 데이터 (API 실패 시 사용) - Linode: Tokyo R1/R2/Osaka/Singapore, Vultr: Seoul const FALLBACK_DATA = [ { id: 'f1', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Tokyo 2, JP' }, pricing: { monthly_price: 5 } }, { id: 'f2', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Tokyo 2, JP' }, pricing: { monthly_price: 24 } }, { id: 'f3', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Tokyo 3, JP' }, pricing: { monthly_price: 5 } }, { id: 'f4', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Tokyo 3, JP' }, pricing: { monthly_price: 24 } }, { id: 'f5', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Osaka, JP', region_code: 'jp-osa' }, pricing: { monthly_price: 5 } }, { id: 'f6', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Osaka, JP', region_code: 'jp-osa' }, pricing: { monthly_price: 24 } }, { id: 'f7', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Singapore, SG' }, pricing: { monthly_price: 5 } }, { id: 'f8', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Singapore, SG' }, pricing: { monthly_price: 24 } }, { id: 'f9', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'vultr' }, region: { region_name: 'Seoul, KR' }, pricing: { monthly_price: 6 } }, { id: 'f10', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'vultr' }, region: { region_name: 'Seoul, KR' }, pricing: { monthly_price: 24 } }, ]; return { // 필터 상태 sortBy: 'vcpu', // 'vcpu' | 'memory' | 'price' sortOrder: 'asc', // 'asc' | 'desc' selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea' selectedSeoulType: 'vc2', // 'vc2' | 'vhf' (서울 서브탭) selectedGlobalType: 'standard', // 'standard' | 'dedicated' | 'premium' (글로벌 서브탭) // 도시 목록: Linode(도쿄/오사카/싱가폴 동일가격), Seoul(Vultr), GPU cities: [ { id: 'global', name: '도쿄/오사카/싱가폴', flag: '🌏', provider: 'linode' }, { id: 'seoul', name: 'Seoul', flag: '🇰🇷', provider: 'vultr' }, { id: 'gpu-japan', name: 'GPU Japan', flag: '🇯🇵', provider: 'linode', isGpu: true }, { id: 'gpu-korea', name: 'GPU Korea', flag: '🇰🇷', provider: 'vultr', isGpu: true }, ], // 글로벌(Linode) 서브탭 (인스턴스 타입별) globalTypes: [ { id: 'standard', name: 'Standard', desc: '공유 CPU, 저렴' }, { id: 'dedicated', name: 'Dedicated', desc: '전용 CPU, 안정' }, { id: 'premium', name: 'Premium', desc: 'AMD EPYC, 고성능' }, { id: 'highmem', name: 'High Memory', desc: '대용량 RAM' }, ], // 서울 서브탭 (인스턴스 타입별) seoulTypes: [ { id: 'vc2', name: '가성비', desc: '일반 SSD' }, { id: 'vhf', name: '고주파', desc: 'NVMe·3GHz+' }, { id: 'vhp', name: '고성능', desc: 'AMD/Intel' }, { id: 'voc', name: '최적화', desc: 'CPU/메모리/스토리지' }, { id: 'vx1', name: '차세대', desc: '고밀도 메모리' }, ], // 데이터 상태 instances: [], filteredInstances: [], loading: false, error: null, lastUpdate: null, fromCache: false, // 지원 리전 패턴 (Tokyo, Osaka, Singapore = Linode / Seoul = Vultr) supportedRegions: ['Tokyo', 'Osaka', 'Singapore', 'Seoul', 'ap-northeast', 'ap-southeast', 'jp-osa'], // 초기화 async init() { await this.loadInstances(); }, // 캐시에서 로드 또는 API 호출 async loadInstances() { // 캐시 확인 const cached = this.getCache(); if (cached) { console.log('[PricingTable] Using cached data'); this.instances = this.filterAndSort(cached.instances); this.filteredInstances = this.applyFilters(this.instances); this.lastUpdate = new Date(cached.timestamp); this.fromCache = true; return; } // 캐시 없으면 API 호출 await this.fetchFromApi(); }, // 캐시 조회 getCache() { try { const data = localStorage.getItem(CACHE_KEY); if (!data) return null; const parsed = JSON.parse(data); const age = Date.now() - parsed.timestamp; if (age > CACHE_TTL) { console.log('[PricingTable] Cache expired'); localStorage.removeItem(CACHE_KEY); return null; } return parsed; } catch (e) { console.error('[PricingTable] Cache read error:', e); return null; } }, // 캐시 저장 setCache(instances) { try { const data = { instances, timestamp: Date.now() }; localStorage.setItem(CACHE_KEY, JSON.stringify(data)); console.log('[PricingTable] Cache saved'); } catch (e) { console.error('[PricingTable] Cache write error:', e); } }, // API에서 인스턴스 가져오기 (D1 직접 조회 - rate limit 없음) async fetchFromApi() { this.loading = true; this.error = null; this.fromCache = false; try { // D1 직접 조회 엔드포인트 사용 (rate limit 없음) const response = await fetch('/api/pricing'); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || `HTTP ${response.status}`); } const instances = data.instances || []; console.log('[PricingTable] Fetched', instances.length, 'instances from D1'); console.log('[PricingTable] Region counts:', data.region_counts); if (instances.length > 0) { // 캐시에 저장 this.setCache(instances); this.instances = this.filterAndSort(instances); this.filteredInstances = this.applyFilters(this.instances); this.lastUpdate = new Date(); console.log('[PricingTable] Loaded', this.instances.length, 'instances (Linode: JP/SG, Vultr: KR)'); } else { throw new Error('인스턴스 데이터가 없습니다.'); } } catch (err) { console.error('[PricingTable] Error:', err); // 에러 시 만료된 캐시 사용 const expiredCache = localStorage.getItem(CACHE_KEY); if (expiredCache) { try { const parsed = JSON.parse(expiredCache); this.instances = this.filterAndSort(parsed.instances); this.filteredInstances = this.applyFilters(this.instances); this.lastUpdate = new Date(parsed.timestamp); this.fromCache = true; this.error = null; console.log('[PricingTable] Using expired cache as fallback'); return; } catch (e) { // 캐시 파싱 실패, fallback 사용 } } // 캐시도 없으면 fallback 데이터 사용 console.log('[PricingTable] Using fallback data'); this.instances = this.filterAndSort(FALLBACK_DATA); this.filteredInstances = this.applyFilters(this.instances); this.fromCache = true; this.error = null; } finally { this.loading = false; } }, // 필터 및 정렬 적용 (모든 인스턴스 표시) filterAndSort(instances) { let filtered = [...instances]; // 정렬 적용 filtered.sort((a, b) => { switch (this.sortBy) { case 'price': return (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0); case 'vcpu': return (b.vcpu || 0) - (a.vcpu || 0); case 'memory': return (b.memory_mb || 0) - (a.memory_mb || 0); default: return (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0); } }); return filtered; }, // 리전 이름 정규화 (Tokyo 2, JP -> Tokyo) normalizeRegion(regionName) { const name = regionName.toLowerCase(); if (name.includes('osaka') || name.includes('jp-osa')) return 'Osaka'; if (name.includes('tokyo')) return 'Tokyo'; if (name.includes('singapore')) return 'Singapore'; if (name.includes('seoul')) return 'Seoul'; return regionName.split(',')[0].trim(); }, // 필터 변경 핸들러 (도시 필터 + 정렬 적용) onFilterChange() { this.filteredInstances = this.applyFilters(this.instances); }, // 정렬 토글 (같은 컬럼 클릭 시 오름/내림 전환) toggleSort(column) { if (this.sortBy === column) { // 같은 컬럼: 방향 토글 this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; } else { // 다른 컬럼: 기본 방향 설정 this.sortBy = column; // vcpu, memory는 내림차순이 기본, price는 오름차순이 기본 this.sortOrder = column === 'price' ? 'asc' : 'desc'; } this.onFilterChange(); }, // 도시 필터 + 정렬 적용 applyFilters(instances) { let filtered = [...instances]; // 도시 필터 (항상 적용) // Seoul은 서브탭으로 인스턴스 타입 선택 filtered = filtered.filter(inst => { const regionName = (inst.region?.region_name || '').toLowerCase(); const regionCode = (inst.region?.region_code || '').toLowerCase(); const instId = (inst.id || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.gpu_count > 0; switch (this.selectedCity) { case 'global': // Global: Tokyo 2 + 서브탭(Standard/Dedicated/Premium/HighMem) 필터링, GPU 제외 if (!regionName.includes('tokyo 2') || hasGpu) return false; switch (this.selectedGlobalType) { case 'standard': return instId.startsWith('g6-nanode') || instId.startsWith('g6-standard'); case 'dedicated': return instId.startsWith('g6-dedicated'); case 'premium': return instId.startsWith('g7-premium'); case 'highmem': return instId.startsWith('g7-highmem'); default: return false; } case 'seoul': // 서울은 서브탭(selectedSeoulType)으로 필터링, GPU 제외 const isSeoul = regionName.includes('seoul'); return isSeoul && instId.startsWith(this.selectedSeoulType + '-') && !hasGpu; case 'gpu-japan': // 일본 GPU (도쿄만, 중복 방지) return regionName.includes('tokyo 2') && hasGpu; case 'gpu-korea': // 한국 GPU return regionName.includes('seoul') && hasGpu; default: return false; } }); // 정렬 (sortOrder 반영, 보조 정렬: 가격) const order = this.sortOrder === 'asc' ? 1 : -1; filtered.sort((a, b) => { let diff = 0; switch (this.sortBy) { case 'price': diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0); break; case 'vcpu': diff = (a.vcpu || 0) - (b.vcpu || 0); // 동일 vcpu시 가격순 정렬 (보조 키) if (diff === 0) { diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0); } break; case 'memory': diff = (a.memory_mb || 0) - (b.memory_mb || 0); // 동일 메모리시 가격순 정렬 (보조 키) if (diff === 0) { diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0); } break; default: diff = (a.vcpu || 0) - (b.vcpu || 0); } return diff * order; }); return filtered; }, // 도시별 인스턴스 수 조회 getInstanceCountByCity(cityId) { return this.instances.filter(inst => { const regionName = (inst.region?.region_name || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.gpu_count > 0; switch (cityId) { case 'global': // Global: Tokyo 2 전체 (GPU 제외) - 서브탭 카운트는 별도 return regionName.includes('tokyo 2') && !hasGpu; case 'seoul': // 전체 서울 인스턴스 (GPU 제외) return regionName.includes('seoul') && !hasGpu; case 'gpu-japan': // 일본 GPU (Tokyo 2 대표) return regionName.includes('tokyo 2') && hasGpu; case 'gpu-korea': // 한국 GPU return regionName.includes('seoul') && hasGpu; default: return false; } }).length; }, // 서울 서브탭별 인스턴스 수 조회 getSeoulTypeCount(typeId) { return this.instances.filter(inst => { const regionName = (inst.region?.region_name || '').toLowerCase(); const instId = (inst.id || '').toLowerCase(); return regionName.includes('seoul') && instId.startsWith(typeId + '-'); }).length; }, // 글로벌 서브탭별 인스턴스 수 조회 getGlobalTypeCount(typeId) { return this.instances.filter(inst => { const regionName = (inst.region?.region_name || '').toLowerCase(); const instId = (inst.id || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.gpu_count > 0; if (!regionName.includes('tokyo 2') || hasGpu) return false; switch (typeId) { case 'standard': return instId.startsWith('g6-nanode') || instId.startsWith('g6-standard'); case 'dedicated': return instId.startsWith('g6-dedicated'); case 'premium': return instId.startsWith('g7-premium'); case 'highmem': return instId.startsWith('g7-highmem'); default: return false; } }).length; }, // 강제 새로고침 (캐시 삭제 후 API 호출) async forceRefresh() { localStorage.removeItem(CACHE_KEY); await this.fetchFromApi(); }, // 가격 포맷 (USD) formatUsd(price) { if (price == null) return '-'; return '$' + price.toFixed(2); }, // 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액 formatKrw(krwPrice) { if (krwPrice == null) return '-'; return '₩' + Math.round(krwPrice).toLocaleString('ko-KR'); }, // 시간당 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액 formatKrwHourly(krwPrice) { if (krwPrice == null) return '-'; return '₩' + Math.round(krwPrice).toLocaleString('ko-KR'); }, // 메모리 포맷 formatMemory(mb) { if (mb >= 1024) { return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB'; } return mb + ' MB'; }, // 스토리지 포맷 formatStorage(gb) { if (gb == null) return '-'; if (gb >= 1000) { return (gb / 1000).toFixed(1) + ' TB'; } return gb + ' GB'; }, // 트래픽 포맷 formatTransfer(tb) { if (tb == null) return '-'; if (tb >= 1) { return tb.toFixed(tb % 1 === 0 ? 0 : 1) + ' TB'; } return Math.round(tb * 1024) + ' GB'; }, // 프로바이더 색상 getProviderColor(provider) { const colors = { 'linode': 'bg-green-500/20 text-green-400', 'vultr': 'bg-blue-500/20 text-blue-400', 'aws': 'bg-orange-500/20 text-orange-400', }; return colors[provider?.toLowerCase()] || 'bg-slate-500/20 text-slate-400'; }, // 리전 플래그 이모지 getRegionFlag(regionName) { const flags = { 'tokyo': '🇯🇵', 'osaka': '🇯🇵', 'japan': '🇯🇵', 'singapore': '🇸🇬', 'hong kong': '🇭🇰', 'seoul': '🇰🇷', 'korea': '🇰🇷', 'mumbai': '🇮🇳', 'bangalore': '🇮🇳', 'india': '🇮🇳', 'sydney': '🇦🇺', 'australia': '🇦🇺', 'amsterdam': '🇳🇱', 'frankfurt': '🇩🇪', 'germany': '🇩🇪', 'london': '🇬🇧', 'uk': '🇬🇧', 'paris': '🇫🇷', 'france': '🇫🇷', 'us': '🇺🇸', 'america': '🇺🇸', 'atlanta': '🇺🇸', 'dallas': '🇺🇸', 'chicago': '🇺🇸', 'miami': '🇺🇸', 'new york': '🇺🇸', 'seattle': '🇺🇸', 'los angeles': '🇺🇸', 'silicon valley': '🇺🇸' }; const lower = (regionName || '').toLowerCase(); for (const [key, flag] of Object.entries(flags)) { if (lower.includes(key)) return flag; } return '🌐'; }, // 마지막 업데이트 시간 표시 getLastUpdateText() { if (!this.lastUpdate) return ''; const now = new Date(); const diff = Math.floor((now - this.lastUpdate) / 1000); if (diff < 60) return '방금 전'; if (diff < 3600) return `${Math.floor(diff / 60)}분 전`; return this.lastUpdate.toLocaleTimeString('ko-KR'); } }; } /** * 탭 전환 (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'); } } }); }); } });