/** * Pricing Table Component * API 연동 가격표 (아시아 전용, 캐싱 적용) */ /** * 가격표 컴포넌트 */ export 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', tooltip: '공유 CPU · 1-32 vCPU · 1-192GB RAM\n버스트 가능한 CPU로 비용 효율적\n웹서버, 개발환경에 적합' }, { id: 'dedicated', name: 'Dedicated', tooltip: '전용 CPU · 4-64 vCPU · 8-512GB RAM\n100% CPU 자원 보장\nCI/CD, 게임서버, 고부하 작업에 적합' }, { id: 'premium', name: 'Premium', tooltip: 'AMD EPYC 9004 · 1-64 vCPU · 2-512GB RAM\nDDR5 메모리 · NVMe 스토리지\n최신 세대 고성능 컴퓨팅' }, { id: 'highmem', name: 'High Memory', tooltip: '고밀도 메모리 · 2-16 vCPU · 24-300GB RAM\nvCPU당 RAM 비율 최대화\n데이터베이스, 캐싱, 분석 워크로드' }, ], // 서울 서브탭 (인스턴스 타입별) seoulTypes: [ { id: 'vc2', name: 'Cloud Compute', tooltip: '일반 SSD · 1-24 vCPU · 1-96GB RAM\n가성비 좋은 범용 인스턴스\n웹호스팅, 소규모 앱에 적합' }, { id: 'vhf', name: 'High Frequency', tooltip: 'NVMe SSD · 3GHz+ CPU · 1-12 vCPU\n고클럭 프로세서로 단일 스레드 성능 극대화\n게임서버, 실시간 처리에 적합' }, { id: 'vhp', name: 'High Performance', tooltip: 'AMD EPYC · 1-16 vCPU · 1-64GB RAM\n전용 CPU 코어 · NVMe 스토리지\n고성능 컴퓨팅, ML 추론에 적합' }, { id: 'voc', name: 'Optimized', tooltip: '특화 인스턴스 · 다양한 구성\nCPU/메모리/스토리지 최적화 버전\n특정 워크로드에 맞춤 선택' }, { id: 'vx1', name: 'Extreme', tooltip: '초고밀도 · 4-24 vCPU · 96-384GB RAM\n고메모리 비율 인스턴스\n대규모 DB, 인메모리 캐시에 적합' }, ], // 데이터 상태 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]; // 도시 필터 (항상 적용) 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'); if (!isSeoul || !instId.startsWith(this.selectedSeoulType + '-') || hasGpu) return false; // vhp: AMD/Intel 중복 방지 → AMD만 표시 (Intel은 레거시) if (this.selectedSeoulType === 'vhp' && instId.endsWith('-intel')) return false; return true; 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); 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': return regionName.includes('tokyo 2') && !hasGpu; case 'seoul': return regionName.includes('seoul') && !hasGpu; case 'gpu-japan': return regionName.includes('tokyo 2') && hasGpu; case 'gpu-korea': 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'); }, // 인스턴스 선택 상태 selectedInstance: null, // 모달 상태 showInstanceModal: false, selectedInstanceDetail: null, // 클립보드 복사 상태 copiedToClipboard: false, // 인스턴스 선택 핸들러 selectInstance(inst) { // 같은 인스턴스 다시 클릭하면 선택 해제 및 모달 닫기 if (this.selectedInstance?.id === inst.id && this.selectedInstance?.region?.region_code === inst.region?.region_code) { this.selectedInstance = null; this.showInstanceModal = false; this.selectedInstanceDetail = null; return; } this.selectedInstance = inst; this.selectedInstanceDetail = inst; this.showInstanceModal = true; }, // 모달 닫기 closeInstanceModal() { this.showInstanceModal = false; }, // 인스턴스 스펙 복사 copyInstanceSpec() { const inst = this.selectedInstanceDetail; if (!inst) return; const spec = `vCPU: ${inst.vcpu}, RAM: ${this.formatMemory(inst.memory_mb)}, Storage: ${this.formatStorage(inst.storage_gb)}, 월 ${this.formatKrw(inst.pricing?.monthly_price_krw)}`; navigator.clipboard.writeText(spec).then(() => { console.log('[PricingTable] Copied to clipboard:', spec); this.copiedToClipboard = true; setTimeout(() => { this.copiedToClipboard = false; }, 2000); }).catch(err => { console.error('[PricingTable] Failed to copy:', err); }); }, // 텔레그램 링크 가져오기 getInstanceTelegramLink() { if (!this.selectedInstanceDetail) return '#'; return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}`; } }; }