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:
789
app.js
789
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
244
index.html
244
index.html
@@ -29,7 +29,7 @@
|
||||
<meta name="twitter:image" content="https://hosting.anvil.it.com/og-image.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%230ea5e9' rx='20' width='100' height='100'/><text x='50' y='70' font-size='60' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>A</text></svg>">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
|
||||
<!-- Telegram Web App SDK -->
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
@@ -584,7 +584,7 @@
|
||||
<div class="absolute bottom-1/4 -left-32 w-96 h-96 bg-brand-500/10 rounded-full blur-3xl"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-6 relative z-10">
|
||||
<div class="text-center mb-16 animate-on-scroll">
|
||||
<div class="text-center mb-8 animate-on-scroll">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-panel text-sm text-slate-300 mb-6">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
|
||||
투명한 가격 정책
|
||||
@@ -594,77 +594,191 @@
|
||||
<p class="text-slate-500 text-sm mt-2">(월간 기준, VAT 포함)</p>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Tabs -->
|
||||
<div x-data="pricingTable()" class="glass-card-static p-8 rounded-3xl mb-12 animate-on-scroll">
|
||||
<!-- Tab Buttons -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="glass-panel p-1.5 rounded-2xl inline-flex gap-1">
|
||||
<button @click="region = 'global'" :class="region === 'global' ? 'bg-gradient-to-r from-brand-600 to-brand-500 text-white shadow-lg shadow-brand-500/30 border-b-2 border-b-white/60' : 'text-slate-400 hover:text-white hover:bg-white/5'" class="px-6 py-3 rounded-xl text-sm font-bold transition-all flex items-center gap-2">
|
||||
<span>🌏 Global</span>
|
||||
<span class="text-[10px] font-normal opacity-80">(Tokyo/SG/HK)</span>
|
||||
</button>
|
||||
<button @click="region = 'seoul'" :class="region === 'seoul' ? 'bg-gradient-to-r from-brand-600 to-brand-500 text-white shadow-lg shadow-brand-500/30 border-b-2 border-b-white/60' : 'text-slate-400 hover:text-white hover:bg-white/5'" class="px-6 py-3 rounded-xl text-sm font-bold transition-all flex items-center gap-2">
|
||||
<span>🇰🇷 Seoul</span>
|
||||
<span class="text-[10px] font-normal opacity-80">(Premium)</span>
|
||||
<!-- Real-time Cloud Pricing -->
|
||||
<div x-data="pricingTable()" x-init="init()" class="glass-card-static p-8 rounded-3xl mb-12 animate-on-scroll">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg font-bold text-white">🔥 Forge Instances</span>
|
||||
<span x-show="fromCache" class="text-xs text-slate-500 glass-panel px-2 py-1 rounded">캐시됨</span>
|
||||
</div>
|
||||
<button @click="forceRefresh()" :disabled="loading" class="p-2 glass-panel rounded-lg hover:bg-slate-700 transition disabled:opacity-50" title="새로고침">
|
||||
<svg class="w-5 h-5 text-slate-400" :class="loading && 'animate-spin'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- City Tabs -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<template x-for="city in cities" :key="city.id">
|
||||
<button
|
||||
@click="selectedCity = city.id; onFilterChange()"
|
||||
:class="selectedCity === city.id
|
||||
? 'bg-brand-500 text-white border-brand-500'
|
||||
: 'bg-transparent text-slate-400 border-slate-600 hover:border-slate-500 hover:text-white'"
|
||||
class="px-4 py-2 rounded-lg border text-sm font-medium transition-all flex items-center gap-2"
|
||||
:title="city.desc || ''">
|
||||
<span x-text="city.flag"></span>
|
||||
<span x-text="city.name"></span>
|
||||
<span x-show="city.desc" class="text-[10px] opacity-60 hidden md:inline" x-text="city.desc"></span>
|
||||
<span class="text-xs opacity-70" x-text="'(' + getInstanceCountByCity(city.id) + ')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Seoul Subtabs (서울 선택 시 인스턴스 타입 필터) -->
|
||||
<div x-show="selectedCity === 'seoul'" x-transition class="mb-4">
|
||||
<div class="glass-panel rounded-xl p-1 inline-flex gap-1">
|
||||
<template x-for="type in seoulTypes" :key="type.id">
|
||||
<button
|
||||
@click="selectedSeoulType = type.id; onFilterChange()"
|
||||
:class="selectedSeoulType === type.id
|
||||
? 'bg-brand-500/20 text-brand-400 shadow-lg shadow-brand-500/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5"
|
||||
:title="type.desc">
|
||||
<span x-text="type.name"></span>
|
||||
<span class="text-[10px] opacity-60" x-text="'(' + getSeoulTypeCount(type.id) + ')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Pricing Table -->
|
||||
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left font-sans"
|
||||
aria-label="서버 요금 비교표 - 리전 및 플랜별 가격 정보">
|
||||
<caption class="sr-only">
|
||||
서버 요금은 월간 기준입니다. 글로벌 리전(도쿄, 싱가포르, 홍콩)과 서울 프리미엄 리전 중에서 선택할 수 있습니다.
|
||||
</caption>
|
||||
<thead>
|
||||
<tr class="text-slate-500 text-sm border-b border-slate-700">
|
||||
<th class="pb-4 font-medium pl-4">Plan</th>
|
||||
<th class="pb-4 font-medium text-center">vCPU</th>
|
||||
<th class="pb-4 font-medium text-center">RAM</th>
|
||||
<th class="pb-4 font-medium text-center" :class="isSeoul() ? 'text-green-400' : ''">
|
||||
<span x-text="isSeoul() ? 'SSD (High)' : 'SSD'"></span>
|
||||
</th>
|
||||
<th class="pb-4 font-medium text-center">Transfer</th>
|
||||
<th class="pb-4 font-medium text-right pr-4">Price / Month</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300 text-sm">
|
||||
<template x-for="(plan, index) in plans" :key="plan.plan">
|
||||
<tr :class="plan.featured
|
||||
? 'border-b border-slate-700/50 bg-brand-500/5 hover:bg-brand-500/10 transition border-l-4 border-l-brand-500'
|
||||
: (index < plans.length - 1 ? 'border-b border-slate-700/50 hover:bg-slate-800/50 transition' : 'hover:bg-slate-800/50 transition')">
|
||||
<td :class="plan.featured ? 'py-4 pl-3 font-bold text-brand-400' : 'py-4 pl-4 font-bold text-white'">
|
||||
<span x-text="plan.plan"></span>
|
||||
<span x-show="plan.featured"> ⭐</span>
|
||||
</td>
|
||||
<td :class="plan.featured ? 'text-center text-brand-400 font-bold' : 'text-center text-brand-400'" x-text="plan.vcpu"></td>
|
||||
<td :class="plan.featured ? 'text-center font-bold' : 'text-center'" x-text="plan.ram"></td>
|
||||
<td :class="isSeoul() ? 'text-center text-green-400 font-bold' : 'text-center'" x-text="plan.ssd"></td>
|
||||
<td class="text-center" x-text="plan.transfer"></td>
|
||||
<td :class="plan.featured ? 'text-right pr-4 font-bold text-lg text-brand-400' : 'text-right pr-4 font-bold text-lg'" x-text="formatPrice(plan.price)"></td>
|
||||
<!-- Global Subtabs (도쿄/오사카/싱가폴 선택 시 인스턴스 타입 필터) -->
|
||||
<div x-show="selectedCity === 'global'" x-transition class="mb-4">
|
||||
<div class="glass-panel rounded-xl p-1 inline-flex gap-1">
|
||||
<template x-for="type in globalTypes" :key="type.id">
|
||||
<button
|
||||
@click="selectedGlobalType = type.id; onFilterChange()"
|
||||
:class="selectedGlobalType === type.id
|
||||
? 'bg-brand-500/20 text-brand-400 shadow-lg shadow-brand-500/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5"
|
||||
:title="type.desc">
|
||||
<span x-text="type.name"></span>
|
||||
<span class="text-[10px] opacity-60" x-text="'(' + getGlobalTypeCount(type.id) + ')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GPU 탭에서 간격 맞추기 -->
|
||||
<div x-show="selectedCity.startsWith('gpu-')" class="mb-4"></div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="flex justify-between items-center mb-4 text-xs text-slate-500">
|
||||
<span x-show="!loading && !error">
|
||||
<span x-text="filteredInstances.length"></span>개 인스턴스
|
||||
<span x-show="selectedCity !== 'all'"> · <span x-text="cities.find(c => c.id === selectedCity)?.name"></span></span>
|
||||
<span x-show="selectedCity === 'seoul'"> · <span x-text="seoulTypes.find(t => t.id === selectedSeoulType)?.name"></span></span>
|
||||
<span x-show="selectedCity === 'global'"> · <span x-text="globalTypes.find(t => t.id === selectedGlobalType)?.name"></span></span>
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
불러오는 중...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error" class="mb-4 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<span x-text="error"></span>
|
||||
<button @click="refresh()" class="ml-2 underline hover:no-underline">다시 시도</button>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Table -->
|
||||
<div x-show="!error" x-transition class="overflow-x-auto">
|
||||
<table class="w-full text-left font-sans" aria-label="클라우드 인스턴스 가격 비교표">
|
||||
<caption class="sr-only">실시간 클라우드 인스턴스 가격입니다.</caption>
|
||||
<thead>
|
||||
<tr class="text-slate-500 text-sm border-b border-slate-700">
|
||||
<!-- GPU 컬럼 (GPU 탭에서만 표시) -->
|
||||
<th x-show="selectedCity.startsWith('gpu-')" class="pb-4 font-medium pl-4 text-left">GPU 모델</th>
|
||||
<th class="pb-4 font-medium text-center cursor-pointer hover:text-white transition" :class="!selectedCity.startsWith('gpu-') && 'pl-4'" @click="toggleSort('vcpu')">
|
||||
vCPU <span x-show="sortBy === 'vcpu'" class="text-brand-400" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="pb-4 font-medium text-center cursor-pointer hover:text-white transition" @click="toggleSort('memory')">
|
||||
RAM <span x-show="sortBy === 'memory'" class="text-brand-400" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="pb-4 font-medium text-center">Storage</th>
|
||||
<th class="pb-4 font-medium text-center">Transfer</th>
|
||||
<th class="pb-4 font-medium text-right">시간요금</th>
|
||||
<th class="pb-4 font-medium text-right pr-4 cursor-pointer hover:text-white transition" @click="toggleSort('price')">
|
||||
월요금 <span x-show="sortBy === 'price'" class="text-brand-400" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300 text-sm">
|
||||
<!-- Loading Skeleton -->
|
||||
<template x-if="loading && filteredInstances.length === 0">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr class="border-b border-slate-700/50 animate-pulse">
|
||||
<td class="py-4 pl-4 text-center"><div class="h-4 bg-slate-700 rounded w-12 mx-auto"></div></td>
|
||||
<td class="py-4 text-center"><div class="h-4 bg-slate-700 rounded w-12 mx-auto"></div></td>
|
||||
<td class="py-4 text-center"><div class="h-4 bg-slate-700 rounded w-12 mx-auto"></div></td>
|
||||
<td class="py-4 text-center"><div class="h-4 bg-slate-700 rounded w-10 mx-auto"></div></td>
|
||||
<td class="py-4 text-right"><div class="h-4 bg-slate-700 rounded w-16 ml-auto"></div></td>
|
||||
<td class="py-4 pr-4 text-right"><div class="h-4 bg-slate-700 rounded w-20 ml-auto"></div></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Global Note -->
|
||||
<p x-show="!isSeoul()" class="mt-4 text-center text-xs text-slate-500">
|
||||
* 🇯🇵 도쿄, 🇸🇬 싱가포르 기준 요금입니다. 🇭🇰 홍콩 리전은 약 10%의 추가 요금이 발생할 수 있습니다.
|
||||
</p>
|
||||
<!-- Instance Rows -->
|
||||
<template x-for="(inst, idx) in filteredInstances" :key="inst.id + '-' + (inst.region?.region_code || idx)">
|
||||
<tr class="border-b border-slate-700/50 hover:bg-slate-800/50 transition" :class="idx === 0 && 'bg-brand-500/5 border-l-4 border-l-brand-500'">
|
||||
<!-- GPU (GPU 탭에서만 표시) -->
|
||||
<td x-show="selectedCity.startsWith('gpu-')" class="py-4 pl-4 text-left">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-green-400 font-bold text-sm" x-text="inst.instance_name || inst.gpu_type"></span>
|
||||
<span class="text-[10px] text-slate-500" x-text="inst.gpu_count > 1 ? inst.gpu_count + '× GPU' : ''"></span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- vCPU -->
|
||||
<td class="py-4 text-center" :class="!selectedCity.startsWith('gpu-') && 'pl-4'">
|
||||
<span class="text-brand-400 font-bold" x-text="inst.vcpu + ' Core' + (inst.vcpu > 1 ? 's' : '')"></span>
|
||||
<span x-show="inst.id && inst.id.endsWith('-v6')" class="ml-1 text-[10px] px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded font-medium">IPv6 only</span>
|
||||
</td>
|
||||
<!-- RAM -->
|
||||
<td class="py-4 text-center" x-text="formatMemory(inst.memory_mb)"></td>
|
||||
<!-- Storage -->
|
||||
<td class="py-4 text-center">
|
||||
<span x-text="formatStorage(inst.storage_gb)"></span>
|
||||
<span x-show="inst.storage_gb && inst.storage_gb <= 1" class="ml-1 text-[10px] px-1.5 py-0.5 bg-amber-500/20 text-amber-400 rounded font-medium">블록 스토리지 전용</span>
|
||||
</td>
|
||||
<!-- Transfer -->
|
||||
<td class="py-4 text-center" x-text="formatTransfer(inst.transfer_tb)"></td>
|
||||
<!-- 시간당 요금 (DB에서 가져온 한화 금액) -->
|
||||
<td class="py-4 text-right">
|
||||
<div class="text-sm text-slate-400" x-text="formatKrwHourly(inst.pricing?.hourly_price_krw)"></div>
|
||||
</td>
|
||||
<!-- 월 요금 (DB에서 가져온 한화 금액) -->
|
||||
<td class="py-4 pr-4 text-right">
|
||||
<div class="font-bold text-lg" :class="idx === 0 ? 'text-brand-400' : 'text-white'" x-text="formatKrw(inst.pricing?.monthly_price_krw)"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Seoul Enterprise Note -->
|
||||
<div x-show="isSeoul()" class="mt-6 p-4 bg-slate-800/50 rounded-lg border border-slate-700 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="text-xs text-slate-400">
|
||||
<span class="text-white font-bold block mb-1">더 강력한 성능이 필요하신가요?</span>
|
||||
AWS EC2 기반의 고성능(4 vCPU+) 인스턴스는 별도 문의 바랍니다. (VPC Peering 지원)
|
||||
</div>
|
||||
<a href="https://t.me/AnvilForgeBot" target="_blank" rel="noopener noreferrer" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs font-bold rounded transition">
|
||||
Enterprise 문의 →
|
||||
</a>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<template x-if="!loading && filteredInstances.length === 0 && !error">
|
||||
<tr>
|
||||
<td :colspan="selectedCity.startsWith('gpu-') ? 7 : 6" class="py-12 text-center text-slate-500">
|
||||
선택한 조건에 맞는 인스턴스가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer Note -->
|
||||
<div class="mt-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-slate-500">
|
||||
<p>* 실시간 API 데이터 기준. 실제 요금은 변동될 수 있습니다.</p>
|
||||
<a href="https://t.me/AnvilForgeBot" target="_blank" rel="noopener noreferrer" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.62-.2-1.12-.31-1.15-.63.03-.37.59-.75 1.5-.95 6.07-2.64 10.12-4.38 12.15-5.21 2.91-1.2 3.51-1.4 3.91-1.41.09 0 .28.02.41.09.11.06.23.14.3.24.08.12.12.33.09.57z"/></svg>
|
||||
봇으로 서버 생성하기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user