feat: Global 서브탭 추가 (Standard/Dedicated/Premium/High Memory)

- 도쿄/오사카/싱가폴을 단일 탭으로 통합 (동일 가격)
- Linode 인스턴스 타입별 서브탭 필터링 추가
- 서브탭 설명 개선 (공유 CPU, 전용 CPU, AMD EPYC, 대용량 RAM)
- GPU Japan 렌더링 이슈 수정 (template key 중복 문제)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-23 01:57:34 +09:00
parent 4c64346787
commit 570ae8f49b
2 changed files with 957 additions and 76 deletions

789
app.js
View File

@@ -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');
}
};
}