feat: add terminal-style pricing table with region filtering

- Add dynamic pricing section with kubectl-style table UI
- Implement region tabs (Tokyo 1-3, Osaka 1-2, Seoul 1, Singapore 1)
- Integrate with existing /api/pricing endpoint
- Add localStorage caching with 1-hour TTL
- Apply terminal theme (Fira Code, GitHub Dark colors)
- Sort by price, filter by region

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-30 07:38:27 +09:00
parent a4e9dc2dd8
commit f3dd6c8d5a
4 changed files with 570 additions and 338 deletions

View File

@@ -1,6 +1,6 @@
/**
* Pricing Table Component
* API 연동 가격표 (아시아 전용, 캐싱 적용)
* Anvil 가격표 (D1 직접 조회)
*/
/**
@@ -8,32 +8,27 @@
*/
export function pricingTable() {
// 캐시 키 및 유효 시간 (1시간)
const CACHE_KEY = 'anvil_pricing_cache_v3'; // v3: Global 탭 통합 (도쿄/오사카/싱가폴)
const CACHE_KEY = 'anvil_pricing_cache_v6'; // v6: D1 API 형식 호환
const CACHE_TTL = 60 * 60 * 1000; // 1시간
// Fallback 데이터 (API 실패 시 사용) - Linode: Tokyo R1/R2/Osaka/Singapore, Vultr: Seoul
// Fallback 데이터 (API 실패 시 사용)
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 } },
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
{ id: 'anvil-4g-2c', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 24, monthly_price_krw: 34000 } },
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, category: 'vm', provider: 'vultr', region: { display_name: 'Seoul 1', country_code: 'KR' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, category: 'vm', provider: 'vultr', region: { display_name: 'Seoul 1', country_code: 'KR' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
];
return {
// 필터 상태
sortBy: 'vcpu', // 'vcpu' | 'memory' | 'price'
sortBy: 'price', // 'vcpu' | 'memory' | 'price'
sortOrder: 'asc', // 'asc' | 'desc'
selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea'
selectedSeoulType: 'vc2', // 'vc2' | 'vhf' (서울 서브탭)
selectedGlobalType: 'standard', // 'standard' | 'dedicated' | 'premium' (글로벌 서브탭)
selectedSeoulType: 'all', // 'all' (서브탭 단순화)
selectedGlobalType: 'all', // 'all' (서브탭 단순화)
// 도시 목록: Linode(도쿄/오사카/싱가폴 동일가격), Seoul(Vultr), GPU
// 도시 목록
cities: [
{ id: 'global', name: '도쿄/오사카/싱가폴', flag: '🌏', provider: 'linode' },
{ id: 'seoul', name: 'Seoul', flag: '🇰🇷', provider: 'vultr' },
@@ -41,21 +36,14 @@ export function pricingTable() {
{ 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데이터베이스, 캐싱, 분석 워크로드' },
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\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, 인메모리 캐시에 적합' },
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n서울 리전' },
],
// 데이터 상태
@@ -66,9 +54,6 @@ export function pricingTable() {
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();
@@ -127,14 +112,13 @@ export function pricingTable() {
}
},
// API에서 인스턴스 가져오기 (D1 직접 조회 - rate limit 없음)
// API에서 인스턴스 가져오기
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();
@@ -147,14 +131,11 @@ export function pricingTable() {
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)');
console.log('[PricingTable] Loaded', this.instances.length, 'instances');
} else {
throw new Error('인스턴스 데이터가 없습니다.');
}
@@ -174,7 +155,7 @@ export function pricingTable() {
console.log('[PricingTable] Using expired cache as fallback');
return;
} catch (e) {
// 캐시 파싱 실패, fallback 사용
// 캐시 파싱 실패
}
}
@@ -189,11 +170,10 @@ export function pricingTable() {
}
},
// 필터 및 정렬 적용 (모든 인스턴스 표시)
// 필터 및 정렬 적용
filterAndSort(instances) {
let filtered = [...instances];
// 정렬 적용
filtered.sort((a, b) => {
switch (this.sortBy) {
case 'price':
@@ -210,30 +190,27 @@ export function pricingTable() {
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();
// 리전 이름에서 도시 추출
getRegionCity(displayName) {
const name = (displayName || '').toLowerCase();
if (name.includes('tokyo')) return 'tokyo';
if (name.includes('osaka')) return 'osaka';
if (name.includes('singapore')) return 'singapore';
if (name.includes('seoul')) return 'seoul';
return name;
},
// 필터 변경 핸들러 (도시 필터 + 정렬 적용)
// 필터 변경 핸들러
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();
@@ -243,41 +220,33 @@ export function pricingTable() {
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;
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
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;
}
// Global: 도쿄/오사카/싱가폴 (GPU 제외)
const isGlobal = displayName.includes('tokyo') ||
displayName.includes('osaka') ||
displayName.includes('singapore');
return isGlobal && !hasGpu;
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;
// 서울 (GPU 제외)
return displayName.includes('seoul') && !hasGpu;
case 'gpu-japan':
// 일본 GPU (도쿄만, 중복 방지)
return regionName.includes('tokyo 2') && hasGpu;
// 일본 GPU
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
case 'gpu-korea':
// 한국 GPU
return regionName.includes('seoul') && hasGpu;
default: return false;
return displayName.includes('seoul') && hasGpu;
default:
return false;
}
});
// 정렬 (sortOrder 반영, 보조 정렬: 가격)
// 정렬
const order = this.sortOrder === 'asc' ? 1 : -1;
filtered.sort((a, b) => {
let diff = 0;
@@ -287,15 +256,11 @@ export function pricingTable() {
break;
case 'vcpu':
diff = (a.vcpu || 0) - (b.vcpu || 0);
if (diff === 0) {
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 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);
}
if (diff === 0) diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
break;
default:
diff = (a.vcpu || 0) - (b.vcpu || 0);
@@ -309,49 +274,55 @@ export function pricingTable() {
// 도시별 인스턴스 수 조회
getInstanceCountByCity(cityId) {
return this.instances.filter(inst => {
const regionName = (inst.region?.region_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.gpu_count > 0;
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
switch (cityId) {
case 'global':
return regionName.includes('tokyo 2') && !hasGpu;
const isGlobal = displayName.includes('tokyo') ||
displayName.includes('osaka') ||
displayName.includes('singapore');
return isGlobal && !hasGpu;
case 'seoul':
return regionName.includes('seoul') && !hasGpu;
return displayName.includes('seoul') && !hasGpu;
case 'gpu-japan':
return regionName.includes('tokyo 2') && hasGpu;
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
case 'gpu-korea':
return regionName.includes('seoul') && hasGpu;
default: return false;
return displayName.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;
if (typeId === 'all') {
return this.instances.filter(inst => {
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
return displayName.includes('seoul') && !hasGpu;
}).length;
}
return 0;
},
// 글로벌 서브탭별 인스턴스 수 조회
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;
if (typeId === 'all') {
return this.instances.filter(inst => {
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
const isGlobal = displayName.includes('tokyo') ||
displayName.includes('osaka') ||
displayName.includes('singapore');
return isGlobal && !hasGpu;
}).length;
}
return 0;
},
// 강제 새로고침 (캐시 삭제 후 API 호출)
// 강제 새로고침
async forceRefresh() {
localStorage.removeItem(CACHE_KEY);
await this.fetchFromApi();
@@ -363,13 +334,13 @@ export function pricingTable() {
return '$' + price.toFixed(2);
},
// 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
// 가격 포맷 (KRW)
formatKrw(krwPrice) {
if (krwPrice == null) return '-';
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
},
// 시간당 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
// 시간당 가격 포맷 (KRW)
formatKrwHourly(krwPrice) {
if (krwPrice == null) return '-';
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
@@ -377,6 +348,7 @@ export function pricingTable() {
// 메모리 포맷
formatMemory(mb) {
if (mb == null) return '-';
if (mb >= 1024) {
return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
}
@@ -401,32 +373,12 @@ export function pricingTable() {
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();
@@ -448,19 +400,14 @@ export function pricingTable() {
// 인스턴스 선택 상태
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?.region?.display_name === inst.region?.display_name) {
this.selectedInstance = null;
this.showInstanceModal = false;
this.selectedInstanceDetail = null;
@@ -493,10 +440,11 @@ export function pricingTable() {
});
},
// 텔레그램 링크 가져오기
// 텔레그램 링크
getInstanceTelegramLink() {
if (!this.selectedInstanceDetail) return '#';
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}`;
const region = this.selectedInstanceDetail.region?.display_name || '';
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}_${encodeURIComponent(region.toLowerCase().replace(/\s+/g, '-'))}`;
}
};
}