- 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>
451 lines
15 KiB
JavaScript
451 lines
15 KiB
JavaScript
/**
|
|
* Pricing Table Component
|
|
* Anvil 가격표 (D1 직접 조회)
|
|
*/
|
|
|
|
/**
|
|
* 가격표 컴포넌트
|
|
*/
|
|
export function pricingTable() {
|
|
// 캐시 키 및 유효 시간 (1시간)
|
|
const CACHE_KEY = 'anvil_pricing_cache_v6'; // v6: D1 API 형식 호환
|
|
const CACHE_TTL = 60 * 60 * 1000; // 1시간
|
|
|
|
// Fallback 데이터 (API 실패 시 사용)
|
|
const FALLBACK_DATA = [
|
|
{ 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: 'price', // 'vcpu' | 'memory' | 'price'
|
|
sortOrder: 'asc', // 'asc' | 'desc'
|
|
selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea'
|
|
selectedSeoulType: 'all', // 'all' (서브탭 단순화)
|
|
selectedGlobalType: 'all', // 'all' (서브탭 단순화)
|
|
|
|
// 도시 목록
|
|
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 },
|
|
],
|
|
|
|
// 글로벌 서브탭 (단순화)
|
|
globalTypes: [
|
|
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n도쿄, 오사카, 싱가폴 리전' },
|
|
],
|
|
|
|
// 서울 서브탭 (단순화)
|
|
seoulTypes: [
|
|
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n서울 리전' },
|
|
],
|
|
|
|
// 데이터 상태
|
|
instances: [],
|
|
filteredInstances: [],
|
|
loading: false,
|
|
error: null,
|
|
lastUpdate: null,
|
|
fromCache: false,
|
|
|
|
// 초기화
|
|
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에서 인스턴스 가져오기
|
|
async fetchFromApi() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
this.fromCache = false;
|
|
|
|
try {
|
|
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');
|
|
} 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 데이터 사용
|
|
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;
|
|
},
|
|
|
|
// 리전 이름에서 도시 추출
|
|
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;
|
|
this.sortOrder = column === 'price' ? 'asc' : 'desc';
|
|
}
|
|
this.onFilterChange();
|
|
},
|
|
|
|
// 도시 필터 + 정렬 적용
|
|
applyFilters(instances) {
|
|
let filtered = [...instances];
|
|
|
|
// 도시 필터
|
|
filtered = filtered.filter(inst => {
|
|
const displayName = (inst.region?.display_name || '').toLowerCase();
|
|
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
|
|
|
switch (this.selectedCity) {
|
|
case 'global':
|
|
// Global: 도쿄/오사카/싱가폴 (GPU 제외)
|
|
const isGlobal = displayName.includes('tokyo') ||
|
|
displayName.includes('osaka') ||
|
|
displayName.includes('singapore');
|
|
return isGlobal && !hasGpu;
|
|
case 'seoul':
|
|
// 서울 (GPU 제외)
|
|
return displayName.includes('seoul') && !hasGpu;
|
|
case 'gpu-japan':
|
|
// 일본 GPU
|
|
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
|
|
case 'gpu-korea':
|
|
// 한국 GPU
|
|
return displayName.includes('seoul') && hasGpu;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// 정렬
|
|
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 displayName = (inst.region?.display_name || '').toLowerCase();
|
|
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
|
|
|
switch (cityId) {
|
|
case 'global':
|
|
const isGlobal = displayName.includes('tokyo') ||
|
|
displayName.includes('osaka') ||
|
|
displayName.includes('singapore');
|
|
return isGlobal && !hasGpu;
|
|
case 'seoul':
|
|
return displayName.includes('seoul') && !hasGpu;
|
|
case 'gpu-japan':
|
|
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
|
|
case 'gpu-korea':
|
|
return displayName.includes('seoul') && hasGpu;
|
|
default:
|
|
return false;
|
|
}
|
|
}).length;
|
|
},
|
|
|
|
// 서울 서브탭별 인스턴스 수 조회
|
|
getSeoulTypeCount(typeId) {
|
|
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) {
|
|
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;
|
|
},
|
|
|
|
// 강제 새로고침
|
|
async forceRefresh() {
|
|
localStorage.removeItem(CACHE_KEY);
|
|
await this.fetchFromApi();
|
|
},
|
|
|
|
// 가격 포맷 (USD)
|
|
formatUsd(price) {
|
|
if (price == null) return '-';
|
|
return '$' + price.toFixed(2);
|
|
},
|
|
|
|
// 가격 포맷 (KRW)
|
|
formatKrw(krwPrice) {
|
|
if (krwPrice == null) return '-';
|
|
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
|
|
},
|
|
|
|
// 시간당 가격 포맷 (KRW)
|
|
formatKrwHourly(krwPrice) {
|
|
if (krwPrice == null) return '-';
|
|
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
|
|
},
|
|
|
|
// 메모리 포맷
|
|
formatMemory(mb) {
|
|
if (mb == null) return '-';
|
|
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';
|
|
},
|
|
|
|
// 리전 플래그 이모지
|
|
getRegionFlag(regionName) {
|
|
const flags = {
|
|
'tokyo': '🇯🇵', 'osaka': '🇯🇵', 'japan': '🇯🇵',
|
|
'singapore': '🇸🇬',
|
|
'seoul': '🇰🇷', 'korea': '🇰🇷',
|
|
};
|
|
|
|
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?.display_name === inst.region?.display_name) {
|
|
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 '#';
|
|
const region = this.selectedInstanceDetail.region?.display_name || '';
|
|
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}_${encodeURIComponent(region.toLowerCase().replace(/\s+/g, '-'))}`;
|
|
}
|
|
};
|
|
}
|