diff --git a/app.js b/app.js index c934294..e18878f 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,116 @@ * 대화형 서버 런처 및 가격 데이터 관리 */ +// 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'; @@ -141,6 +251,14 @@ function anvilApp() { initData: null // 검증용 데이터 }, + // 웹 로그인 사용자 (텔레그램 로그인 위젯 사용) + webUser: null, + + // 현재 로그인된 사용자 (텔레그램 또는 웹) + get currentUser() { + return this.telegram.user || this.webUser; + }, + // 서버 구성 config: { region: null, @@ -221,8 +339,12 @@ function anvilApp() { // 미니앱 전용 초기화 (/app 페이지용) initMiniApp() { - if (window.Telegram?.WebApp) { - const tg = window.Telegram.WebApp; + const tg = window.Telegram?.WebApp; + + // 실제 텔레그램 환경인지 확인 (initData가 있어야 진짜 텔레그램) + const isRealTelegram = tg && tg.initData && tg.initData.length > 0; + + if (isRealTelegram) { tg.ready(); tg.expand(); @@ -230,7 +352,7 @@ function anvilApp() { this.telegram.user = tg.initDataUnsafe.user || null; this.telegram.initData = tg.initData; - console.log('[MiniApp] Initialized', { + console.log('[MiniApp] Telegram environment detected', { user: this.telegram.user, platform: tg.platform }); @@ -240,9 +362,101 @@ function anvilApp() { } 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...'); @@ -554,27 +768,580 @@ function anvilApp() { 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 { - region: 'global', + // 필터 상태 + 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' (글로벌 서브탭) - get plans() { - return PRICING_DATA[this.region] || []; + // 도시 목록: 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(); }, - formatPrice(price) { - return formatPrice(price); + // 캐시에서 로드 또는 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(); }, - isSeoul() { - return this.region === 'seoul'; + // 캐시 조회 + 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'); } }; } diff --git a/index.html b/index.html index 1e7a5d4..0bef204 100644 --- a/index.html +++ b/index.html @@ -29,7 +29,7 @@ - + @@ -584,7 +584,7 @@
(월간 기준, VAT 포함)