refactor: app.js를 ES6 모듈로 분리
## 변경사항 - app.js (1370줄) → 7개 모듈 (1427줄) - ES6 import/export 문법 사용 - Alpine.js 호환성 유지 (window 전역 노출) ## 모듈 구조 - js/config.js: 상수/설정 (WIZARD_CONFIG, PRICING_DATA, MOCK_*) - js/api.js: ApiService - js/utils.js: formatPrice, switchTab, ping 시뮬레이션 - js/wizard.js: 서버 추천 마법사 로직 - js/pricing.js: 가격표 컴포넌트 - js/dashboard.js: 대시보드 및 텔레그램 연동 - js/app.js: 메인 통합 (모든 모듈 import) ## HTML 변경 - <script type="module" src="js/app.js">로 변경 - 기존 기능 모두 정상 작동 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
56
js/api.js
Normal file
56
js/api.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* API Service
|
||||
* cloud-instances-api 워커 호출
|
||||
*/
|
||||
|
||||
// API 설정 (프록시 사용 - /api/* 경로)
|
||||
export const API_CONFIG = {
|
||||
baseUrl: '/api', // Cloudflare Pages Functions 프록시 사용
|
||||
apiKey: null, // 프록시에서 처리
|
||||
timeout: 10000
|
||||
};
|
||||
|
||||
/**
|
||||
* API 서비스 객체
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed: health(), getInstances(), recommend(), sync()
|
||||
// These methods referenced deleted API functions (health.ts, instances.ts, recommend.ts)
|
||||
};
|
||||
52
js/app.js
Normal file
52
js/app.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Anvil Hosting - Main Application JavaScript
|
||||
* 메인 통합 모듈 (ES6 모듈 방식)
|
||||
*/
|
||||
|
||||
// 모듈 임포트
|
||||
import { TELEGRAM_BOT_URL, PRICING_DATA, MOCK_SERVERS, MOCK_STATS, MOCK_NOTIFICATIONS, WIZARD_CONFIG } from './config.js';
|
||||
import { API_CONFIG, ApiService } from './api.js';
|
||||
import { formatPrice, switchTab, updatePing, startPingUpdates, stopPingUpdates } from './utils.js';
|
||||
import { calculateRecommendation, createWizardMethods } from './wizard.js';
|
||||
import { createDashboardMethods } from './dashboard.js';
|
||||
import { pricingTable } from './pricing.js';
|
||||
|
||||
/**
|
||||
* Alpine.js 메인 앱 데이터 - 대화형 위자드 + 대시보드
|
||||
*/
|
||||
function anvilApp() {
|
||||
// 마법사 메서드와 대시보드 메서드 병합
|
||||
const wizardMethods = createWizardMethods();
|
||||
const dashboardMethods = createDashboardMethods();
|
||||
|
||||
return {
|
||||
...wizardMethods,
|
||||
...dashboardMethods
|
||||
};
|
||||
}
|
||||
|
||||
// 전역 함수로 노출 (Alpine.js x-data에서 사용)
|
||||
window.anvilApp = anvilApp;
|
||||
window.pricingTable = pricingTable;
|
||||
|
||||
// 전역 텔레그램 로그인 콜백 (웹 로그인 위젯용)
|
||||
window.onTelegramAuth = function(user) {
|
||||
// Alpine 인스턴스 찾기
|
||||
const appElement = document.querySelector('[x-data="anvilApp()"]');
|
||||
if (appElement && appElement._x_dataStack) {
|
||||
const appData = appElement._x_dataStack[0];
|
||||
if (appData.handleWebLogin) {
|
||||
appData.handleWebLogin(user);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 개발 도구 (콘솔에서 사용 가능)
|
||||
window.AnvilDevTools = {
|
||||
config: { TELEGRAM_BOT_URL, PRICING_DATA, WIZARD_CONFIG },
|
||||
api: ApiService,
|
||||
utils: { formatPrice, switchTab, updatePing }
|
||||
};
|
||||
|
||||
console.log('[Anvil] Application modules loaded');
|
||||
console.log('[Anvil] DevTools available at window.AnvilDevTools');
|
||||
136
js/config.js
Normal file
136
js/config.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Configuration and Constants
|
||||
* 상수, 설정, 목 데이터 정의
|
||||
*/
|
||||
|
||||
// 텔레그램 봇 URL
|
||||
export const TELEGRAM_BOT_URL = 'https://t.me/AnvilForgeBot';
|
||||
|
||||
// 단일 가격 데이터 소스 (VAT 포함, 월간 기준)
|
||||
export const PRICING_DATA = {
|
||||
global: [
|
||||
{ plan: 'Micro', vcpu: '1 Core', ram: '1 GB', ssd: '25 GB', transfer: '1 TB', price: 8500 },
|
||||
{ plan: 'Starter', vcpu: '1 Core', ram: '2 GB', ssd: '50 GB', transfer: '2 TB', price: 20400 },
|
||||
{ plan: 'Pro', vcpu: '2 Cores', ram: '4 GB', ssd: '80 GB', transfer: '4 TB', price: 40700, featured: true },
|
||||
{ plan: 'Business', vcpu: '4 Cores', ram: '8 GB', ssd: '160 GB', transfer: '5 TB', price: 67800 }
|
||||
],
|
||||
seoul: [
|
||||
{ plan: 'Nano', vcpu: '1 Core', ram: '512 MB', ssd: '20 GB', transfer: '1 TB', price: 6000 },
|
||||
{ plan: 'Micro', vcpu: '1 Core', ram: '1 GB', ssd: '40 GB', transfer: '2 TB', price: 8500 },
|
||||
{ plan: 'Starter', vcpu: '1 Core', ram: '2 GB', ssd: '60 GB', transfer: '3 TB', price: 17000 },
|
||||
{ plan: 'Pro', vcpu: '2 Cores', ram: '4 GB', ssd: '80 GB', transfer: '4 TB', price: 33900, featured: true },
|
||||
{ plan: 'Business', vcpu: '2 Cores', ram: '8 GB', ssd: '160 GB', transfer: '5 TB', price: 67800 }
|
||||
]
|
||||
};
|
||||
|
||||
// Mock 데이터 (API 없이 UI 테스트용)
|
||||
export const MOCK_SERVERS = [
|
||||
{
|
||||
id: 'srv-001',
|
||||
name: 'production-web',
|
||||
region: '🇯🇵 Tokyo',
|
||||
ip: '45.12.89.101',
|
||||
os: 'Debian 12',
|
||||
plan: 'Pro',
|
||||
status: 'running',
|
||||
created_at: '2026-01-15',
|
||||
vcpu: '2 Cores',
|
||||
ram: '4 GB',
|
||||
cost: 40700
|
||||
},
|
||||
{
|
||||
id: 'srv-002',
|
||||
name: 'dev-api',
|
||||
region: '🇰🇷 Seoul',
|
||||
ip: '45.12.89.102',
|
||||
os: 'Ubuntu 24.04',
|
||||
plan: 'Starter',
|
||||
status: 'stopped',
|
||||
created_at: '2026-01-20',
|
||||
vcpu: '1 Core',
|
||||
ram: '2 GB',
|
||||
cost: 17000
|
||||
}
|
||||
];
|
||||
|
||||
export const MOCK_STATS = {
|
||||
totalCost: 57700,
|
||||
totalServers: 2,
|
||||
runningServers: 1,
|
||||
costBreakdown: [
|
||||
{ plan: 'Pro', count: 1, cost: 40700 },
|
||||
{ plan: 'Starter', count: 1, cost: 17000 }
|
||||
]
|
||||
};
|
||||
|
||||
export const MOCK_NOTIFICATIONS = [
|
||||
{ id: 'n-001', type: 'info', title: '서버 생성 완료', message: 'production-web 서버가 Tokyo 리전에 생성되었습니다.', time: '10분 전', read: false },
|
||||
{ id: 'n-002', type: 'warning', title: '결제 예정', message: '이번 달 결제가 3일 후 예정되어 있습니다. (₩57,700)', time: '1시간 전', read: false },
|
||||
{ id: 'n-003', type: 'success', title: '시스템 업데이트 완료', message: 'dev-api 서버의 Ubuntu 패키지가 업데이트되었습니다.', time: '2일 전', read: true }
|
||||
];
|
||||
|
||||
// 서버 추천 마법사 설정
|
||||
export const WIZARD_CONFIG = {
|
||||
// 용도 목록 (객체 형태)
|
||||
purposes: {
|
||||
web: { icon: '🌐', name: '웹 서비스', desc: '웹사이트, API, SaaS' },
|
||||
game: { icon: '🎮', name: '게임 서버', desc: '마인크래프트, 발하임 등' },
|
||||
ai: { icon: '🤖', name: 'AI / ML', desc: '머신러닝, LLM, 데이터 분석' },
|
||||
dev: { icon: '💻', name: '개발 환경', desc: 'CI/CD, 테스트, 스테이징' },
|
||||
db: { icon: '🗄️', name: '데이터베이스', desc: 'MySQL, PostgreSQL, Redis' },
|
||||
other: { icon: '📦', name: '기타', desc: 'VPN, 프록시, 기타 용도' }
|
||||
},
|
||||
|
||||
// 기술 스택 (용도별 필터링, category 추가)
|
||||
stacks: {
|
||||
web: [
|
||||
{ id: 'wordpress', icon: '📝', name: 'WordPress', ram: 1024, cpu: 1, category: 'CMS' },
|
||||
{ id: 'nginx', icon: '🟢', name: 'Nginx / Static', ram: 512, cpu: 1, category: 'Web Server' },
|
||||
{ id: 'nodejs', icon: '🟩', name: 'Node.js', ram: 1024, cpu: 1, category: 'Runtime' },
|
||||
{ id: 'python', icon: '🐍', name: 'Python / Django', ram: 1024, cpu: 1, category: 'Framework' },
|
||||
{ id: 'php', icon: '🐘', name: 'PHP / Laravel', ram: 1024, cpu: 1, category: 'Framework' },
|
||||
{ id: 'nextjs', icon: '▲', name: 'Next.js / React', ram: 2048, cpu: 2, category: 'Framework' },
|
||||
{ id: 'docker', icon: '🐳', name: 'Docker 컨테이너', ram: 2048, cpu: 2, category: 'Container' }
|
||||
],
|
||||
game: [
|
||||
{ id: 'minecraft', icon: '⛏️', name: 'Minecraft', ram: 4096, cpu: 2, category: 'Sandbox' },
|
||||
{ id: 'valheim', icon: '⚔️', name: 'Valheim', ram: 4096, cpu: 2, category: 'Survival' },
|
||||
{ id: 'ark', icon: '🦖', name: 'ARK', ram: 8192, cpu: 4, category: 'Survival' },
|
||||
{ id: 'palworld', icon: '🐾', name: 'Palworld', ram: 16384, cpu: 4, category: 'Survival' },
|
||||
{ id: 'rust', icon: '🔫', name: 'Rust', ram: 8192, cpu: 4, category: 'Survival' },
|
||||
{ id: 'terraria', icon: '🌲', name: 'Terraria', ram: 1024, cpu: 1, category: 'Sandbox' }
|
||||
],
|
||||
ai: [
|
||||
{ id: 'jupyter', icon: '📓', name: 'Jupyter Notebook', ram: 4096, cpu: 2, category: 'IDE' },
|
||||
{ id: 'ollama', icon: '🦙', name: 'Ollama / LLM', ram: 16384, cpu: 4, category: 'LLM' },
|
||||
{ id: 'stable', icon: '🎨', name: 'Stable Diffusion', ram: 16384, cpu: 4, gpu: true, category: 'Image AI' },
|
||||
{ id: 'pytorch', icon: '🔥', name: 'PyTorch / TensorFlow', ram: 8192, cpu: 4, category: 'ML Framework' }
|
||||
],
|
||||
dev: [
|
||||
{ id: 'gitlab', icon: '🦊', name: 'GitLab', ram: 4096, cpu: 2, category: 'DevOps' },
|
||||
{ id: 'jenkins', icon: '🔧', name: 'Jenkins', ram: 2048, cpu: 2, category: 'CI/CD' },
|
||||
{ id: 'n8n', icon: '🔀', name: 'n8n 자동화', ram: 1024, cpu: 1, category: 'Automation' },
|
||||
{ id: 'vscode', icon: '💠', name: 'VS Code Server', ram: 2048, cpu: 2, category: 'IDE' }
|
||||
],
|
||||
db: [
|
||||
{ id: 'mysql', icon: '🐬', name: 'MySQL / MariaDB', ram: 2048, cpu: 2, category: 'RDBMS' },
|
||||
{ id: 'postgresql', icon: '🐘', name: 'PostgreSQL', ram: 2048, cpu: 2, category: 'RDBMS' },
|
||||
{ id: 'mongodb', icon: '🍃', name: 'MongoDB', ram: 2048, cpu: 2, category: 'NoSQL' },
|
||||
{ id: 'redis', icon: '🔴', name: 'Redis', ram: 1024, cpu: 1, category: 'Cache' }
|
||||
],
|
||||
other: [
|
||||
{ id: 'vpn', icon: '🔒', name: 'VPN / WireGuard', ram: 512, cpu: 1, category: 'Network' },
|
||||
{ id: 'proxy', icon: '🌐', name: '프록시 서버', ram: 512, cpu: 1, category: 'Network' },
|
||||
{ id: 'storage', icon: '💾', name: '파일 스토리지', ram: 1024, cpu: 1, category: 'Storage' },
|
||||
{ id: 'custom', icon: '⚙️', name: '커스텀 설정', ram: 1024, cpu: 1, category: 'Custom' }
|
||||
]
|
||||
},
|
||||
|
||||
// 규모 선택 (객체 형태)
|
||||
scales: {
|
||||
personal: { name: '개인', desc: '1-10명', users: '1-10명', multiplier: 1, icon: '👤' },
|
||||
small: { name: '소규모', desc: '10-100명', users: '10-100명', multiplier: 1.5, icon: '👥' },
|
||||
medium: { name: '중규모', desc: '100-1000명', users: '100-1,000명', multiplier: 2.5, icon: '🏢' },
|
||||
large: { name: '대규모', desc: '1000명+', users: '1,000명+', multiplier: 4, icon: '🏙️' }
|
||||
}
|
||||
};
|
||||
350
js/dashboard.js
Normal file
350
js/dashboard.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Dashboard Component
|
||||
* 텔레그램 대시보드 관련 상태 및 메서드
|
||||
*/
|
||||
|
||||
import { MOCK_SERVERS, MOCK_STATS, MOCK_NOTIFICATIONS } from './config.js';
|
||||
|
||||
/**
|
||||
* 대시보드 메서드 생성
|
||||
*/
|
||||
export function createDashboardMethods() {
|
||||
return {
|
||||
// 대시보드 상태
|
||||
dashboardMode: false, // true면 대시보드, false면 랜딩
|
||||
currentView: 'servers', // 'servers' | 'stats' | 'notifications'
|
||||
|
||||
// 텔레그램 연동
|
||||
telegram: {
|
||||
isAvailable: false, // 텔레그램 환경인지
|
||||
user: null, // 사용자 정보
|
||||
initData: null // 검증용 데이터
|
||||
},
|
||||
|
||||
// 웹 로그인 사용자 (텔레그램 로그인 위젯 사용)
|
||||
webUser: null,
|
||||
|
||||
// 현재 로그인된 사용자 (텔레그램 또는 웹)
|
||||
get currentUser() {
|
||||
return this.telegram.user || this.webUser;
|
||||
},
|
||||
|
||||
// 서버 목록
|
||||
servers: [],
|
||||
loadingServers: false,
|
||||
|
||||
// 통계
|
||||
stats: {
|
||||
totalCost: 0,
|
||||
totalServers: 0,
|
||||
runningServers: 0,
|
||||
costBreakdown: []
|
||||
},
|
||||
|
||||
// 알림
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
|
||||
// API 상태
|
||||
apiLoading: false,
|
||||
apiError: null,
|
||||
|
||||
// 초기화 (텔레그램 연동 + 대시보드)
|
||||
init() {
|
||||
if (window.Telegram?.WebApp) {
|
||||
const tg = window.Telegram.WebApp;
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
|
||||
this.telegram.isAvailable = true;
|
||||
this.telegram.user = tg.initDataUnsafe.user || null;
|
||||
this.telegram.initData = tg.initData;
|
||||
|
||||
console.log('[Telegram] Mini App initialized', {
|
||||
user: this.telegram.user,
|
||||
platform: tg.platform
|
||||
});
|
||||
|
||||
// 텔레그램 환경이면 대시보드 모드 활성화
|
||||
this.dashboardMode = true;
|
||||
this.loadDashboard();
|
||||
|
||||
console.log('[Telegram] Dashboard mode activated', {
|
||||
isAvailable: this.telegram.isAvailable,
|
||||
hasUser: !!this.telegram.user
|
||||
});
|
||||
} else {
|
||||
console.log('[Telegram] Running in web browser mode');
|
||||
}
|
||||
},
|
||||
|
||||
// 미니앱 전용 초기화 (/app 페이지용)
|
||||
initMiniApp() {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
|
||||
// 실제 텔레그램 환경인지 확인 (initData가 있어야 진짜 텔레그램)
|
||||
const isRealTelegram = tg && tg.initData && tg.initData.length > 0;
|
||||
|
||||
if (isRealTelegram) {
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
|
||||
this.telegram.isAvailable = true;
|
||||
this.telegram.user = tg.initDataUnsafe.user || null;
|
||||
this.telegram.initData = tg.initData;
|
||||
|
||||
console.log('[MiniApp] Telegram environment detected', {
|
||||
user: this.telegram.user,
|
||||
platform: tg.platform
|
||||
});
|
||||
|
||||
// 미니앱은 무조건 대시보드 로드
|
||||
this.loadDashboard();
|
||||
} 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...');
|
||||
await Promise.all([
|
||||
this.fetchServers(),
|
||||
this.fetchStats(),
|
||||
this.fetchNotifications()
|
||||
]);
|
||||
},
|
||||
|
||||
// 서버 목록 조회
|
||||
async fetchServers() {
|
||||
this.loadingServers = true;
|
||||
this.apiError = null;
|
||||
|
||||
try {
|
||||
// TODO: 실제 API 호출로 교체
|
||||
// const response = await fetch('/api/servers', {
|
||||
// headers: { 'X-Telegram-Init-Data': this.telegram.initData }
|
||||
// });
|
||||
// this.servers = await response.json();
|
||||
|
||||
// Mock 데이터 사용
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
this.servers = [...MOCK_SERVERS];
|
||||
console.log('[Dashboard] Servers loaded:', this.servers.length);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to fetch servers:', error);
|
||||
this.apiError = '서버 목록을 불러오는데 실패했습니다.';
|
||||
} finally {
|
||||
this.loadingServers = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 조회
|
||||
async fetchStats() {
|
||||
try {
|
||||
// TODO: 실제 API 호출로 교체
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
this.stats = { ...MOCK_STATS };
|
||||
console.log('[Dashboard] Stats loaded:', this.stats);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to fetch stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 조회
|
||||
async fetchNotifications() {
|
||||
try {
|
||||
// TODO: 실제 API 호출로 교체
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
this.notifications = [...MOCK_NOTIFICATIONS];
|
||||
this.unreadCount = this.notifications.filter(n => !n.read).length;
|
||||
console.log('[Dashboard] Notifications loaded:', this.notifications.length);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to fetch notifications:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 서버 시작
|
||||
async startServer(serverId) {
|
||||
const server = this.servers.find(s => s.id === serverId);
|
||||
if (!server) return;
|
||||
|
||||
console.log('[Dashboard] Starting server:', serverId);
|
||||
this.apiLoading = true;
|
||||
|
||||
try {
|
||||
// TODO: 실제 API 호출로 교체
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
server.status = 'running';
|
||||
this.stats.runningServers++;
|
||||
console.log('[Dashboard] Server started:', serverId);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to start server:', error);
|
||||
alert('서버를 시작하는데 실패했습니다.');
|
||||
} finally {
|
||||
this.apiLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 서버 중지
|
||||
async stopServer(serverId) {
|
||||
const server = this.servers.find(s => s.id === serverId);
|
||||
if (!server) return;
|
||||
|
||||
console.log('[Dashboard] Stopping server:', serverId);
|
||||
this.apiLoading = true;
|
||||
|
||||
try {
|
||||
// TODO: 실제 API 호출로 교체
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
server.status = 'stopped';
|
||||
this.stats.runningServers--;
|
||||
console.log('[Dashboard] Server stopped:', serverId);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to stop server:', error);
|
||||
alert('서버를 중지하는데 실패했습니다.');
|
||||
} finally {
|
||||
this.apiLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 서버 삭제
|
||||
async deleteServer(serverId) {
|
||||
if (!confirm('정말로 이 서버를 삭제하시겠습니까?')) return;
|
||||
|
||||
console.log('[Dashboard] Deleting server:', serverId);
|
||||
this.apiLoading = true;
|
||||
|
||||
try {
|
||||
// TODO: 실제 API 호출로 교체
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const serverIndex = this.servers.findIndex(s => s.id === serverId);
|
||||
if (serverIndex !== -1) {
|
||||
const deletedServer = this.servers[serverIndex];
|
||||
this.servers.splice(serverIndex, 1);
|
||||
|
||||
// 통계 업데이트
|
||||
this.stats.totalServers--;
|
||||
this.stats.totalCost -= deletedServer.cost;
|
||||
if (deletedServer.status === 'running') {
|
||||
this.stats.runningServers--;
|
||||
}
|
||||
|
||||
console.log('[Dashboard] Server deleted:', serverId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to delete server:', error);
|
||||
alert('서버를 삭제하는데 실패했습니다.');
|
||||
} finally {
|
||||
this.apiLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 모든 알림 읽음 처리
|
||||
markAllRead() {
|
||||
this.notifications.forEach(n => n.read = true);
|
||||
this.unreadCount = 0;
|
||||
console.log('[Dashboard] All notifications marked as read');
|
||||
},
|
||||
|
||||
// 뷰 전환
|
||||
switchView(view) {
|
||||
this.currentView = view;
|
||||
console.log('[Dashboard] View switched to:', view);
|
||||
}
|
||||
};
|
||||
}
|
||||
502
js/pricing.js
Normal file
502
js/pricing.js
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Pricing Table Component
|
||||
* API 연동 가격표 (아시아 전용, 캐싱 적용)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 가격표 컴포넌트
|
||||
*/
|
||||
export function pricingTable() {
|
||||
// 캐시 키 및 유효 시간 (1시간)
|
||||
const CACHE_KEY = 'anvil_pricing_cache_v3'; // v3: Global 탭 통합 (도쿄/오사카/싱가폴)
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1시간
|
||||
|
||||
// Fallback 데이터 (API 실패 시 사용) - Linode: Tokyo R1/R2/Osaka/Singapore, Vultr: Seoul
|
||||
const FALLBACK_DATA = [
|
||||
{ id: 'f1', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Tokyo 2, JP' }, pricing: { monthly_price: 5 } },
|
||||
{ id: 'f2', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Tokyo 2, JP' }, pricing: { monthly_price: 24 } },
|
||||
{ id: 'f3', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Tokyo 3, JP' }, pricing: { monthly_price: 5 } },
|
||||
{ id: 'f4', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Tokyo 3, JP' }, pricing: { monthly_price: 24 } },
|
||||
{ id: 'f5', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Osaka, JP', region_code: 'jp-osa' }, pricing: { monthly_price: 5 } },
|
||||
{ id: 'f6', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Osaka, JP', region_code: 'jp-osa' }, pricing: { monthly_price: 24 } },
|
||||
{ id: 'f7', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Singapore, SG' }, pricing: { monthly_price: 5 } },
|
||||
{ id: 'f8', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Singapore, SG' }, pricing: { monthly_price: 24 } },
|
||||
{ id: 'f9', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'vultr' }, region: { region_name: 'Seoul, KR' }, pricing: { monthly_price: 6 } },
|
||||
{ id: 'f10', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'vultr' }, region: { region_name: 'Seoul, KR' }, pricing: { monthly_price: 24 } },
|
||||
];
|
||||
|
||||
return {
|
||||
// 필터 상태
|
||||
sortBy: 'vcpu', // 'vcpu' | 'memory' | 'price'
|
||||
sortOrder: 'asc', // 'asc' | 'desc'
|
||||
selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea'
|
||||
selectedSeoulType: 'vc2', // 'vc2' | 'vhf' (서울 서브탭)
|
||||
selectedGlobalType: 'standard', // 'standard' | 'dedicated' | 'premium' (글로벌 서브탭)
|
||||
|
||||
// 도시 목록: Linode(도쿄/오사카/싱가폴 동일가격), Seoul(Vultr), GPU
|
||||
cities: [
|
||||
{ id: 'global', name: '도쿄/오사카/싱가폴', flag: '🌏', provider: 'linode' },
|
||||
{ id: 'seoul', name: 'Seoul', flag: '🇰🇷', provider: 'vultr' },
|
||||
{ id: 'gpu-japan', name: 'GPU Japan', flag: '🇯🇵', provider: 'linode', isGpu: true },
|
||||
{ id: 'gpu-korea', name: 'GPU Korea', flag: '🇰🇷', provider: 'vultr', isGpu: true },
|
||||
],
|
||||
|
||||
// 글로벌(Linode) 서브탭 (인스턴스 타입별)
|
||||
globalTypes: [
|
||||
{ id: 'standard', name: 'Standard', tooltip: '공유 CPU · 1-32 vCPU · 1-192GB RAM\n버스트 가능한 CPU로 비용 효율적\n웹서버, 개발환경에 적합' },
|
||||
{ id: 'dedicated', name: 'Dedicated', tooltip: '전용 CPU · 4-64 vCPU · 8-512GB RAM\n100% CPU 자원 보장\nCI/CD, 게임서버, 고부하 작업에 적합' },
|
||||
{ id: 'premium', name: 'Premium', tooltip: 'AMD EPYC 9004 · 1-64 vCPU · 2-512GB RAM\nDDR5 메모리 · NVMe 스토리지\n최신 세대 고성능 컴퓨팅' },
|
||||
{ id: 'highmem', name: 'High Memory', tooltip: '고밀도 메모리 · 2-16 vCPU · 24-300GB RAM\nvCPU당 RAM 비율 최대화\n데이터베이스, 캐싱, 분석 워크로드' },
|
||||
],
|
||||
|
||||
// 서울 서브탭 (인스턴스 타입별)
|
||||
seoulTypes: [
|
||||
{ id: 'vc2', name: 'Cloud Compute', tooltip: '일반 SSD · 1-24 vCPU · 1-96GB RAM\n가성비 좋은 범용 인스턴스\n웹호스팅, 소규모 앱에 적합' },
|
||||
{ id: 'vhf', name: 'High Frequency', tooltip: 'NVMe SSD · 3GHz+ CPU · 1-12 vCPU\n고클럭 프로세서로 단일 스레드 성능 극대화\n게임서버, 실시간 처리에 적합' },
|
||||
{ id: 'vhp', name: 'High Performance', tooltip: 'AMD EPYC · 1-16 vCPU · 1-64GB RAM\n전용 CPU 코어 · NVMe 스토리지\n고성능 컴퓨팅, ML 추론에 적합' },
|
||||
{ id: 'voc', name: 'Optimized', tooltip: '특화 인스턴스 · 다양한 구성\nCPU/메모리/스토리지 최적화 버전\n특정 워크로드에 맞춤 선택' },
|
||||
{ id: 'vx1', name: 'Extreme', tooltip: '초고밀도 · 4-24 vCPU · 96-384GB RAM\n고메모리 비율 인스턴스\n대규모 DB, 인메모리 캐시에 적합' },
|
||||
],
|
||||
|
||||
// 데이터 상태
|
||||
instances: [],
|
||||
filteredInstances: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
fromCache: false,
|
||||
|
||||
// 지원 리전 패턴 (Tokyo, Osaka, Singapore = Linode / Seoul = Vultr)
|
||||
supportedRegions: ['Tokyo', 'Osaka', 'Singapore', 'Seoul', 'ap-northeast', 'ap-southeast', 'jp-osa'],
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
await this.loadInstances();
|
||||
},
|
||||
|
||||
// 캐시에서 로드 또는 API 호출
|
||||
async loadInstances() {
|
||||
// 캐시 확인
|
||||
const cached = this.getCache();
|
||||
if (cached) {
|
||||
console.log('[PricingTable] Using cached data');
|
||||
this.instances = this.filterAndSort(cached.instances);
|
||||
this.filteredInstances = this.applyFilters(this.instances);
|
||||
this.lastUpdate = new Date(cached.timestamp);
|
||||
this.fromCache = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 캐시 없으면 API 호출
|
||||
await this.fetchFromApi();
|
||||
},
|
||||
|
||||
// 캐시 조회
|
||||
getCache() {
|
||||
try {
|
||||
const data = localStorage.getItem(CACHE_KEY);
|
||||
if (!data) return null;
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
const age = Date.now() - parsed.timestamp;
|
||||
|
||||
if (age > CACHE_TTL) {
|
||||
console.log('[PricingTable] Cache expired');
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.error('[PricingTable] Cache read error:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// 캐시 저장
|
||||
setCache(instances) {
|
||||
try {
|
||||
const data = {
|
||||
instances,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
||||
console.log('[PricingTable] Cache saved');
|
||||
} catch (e) {
|
||||
console.error('[PricingTable] Cache write error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// API에서 인스턴스 가져오기 (D1 직접 조회 - rate limit 없음)
|
||||
async fetchFromApi() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.fromCache = false;
|
||||
|
||||
try {
|
||||
// D1 직접 조회 엔드포인트 사용 (rate limit 없음)
|
||||
const response = await fetch('/api/pricing');
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const instances = data.instances || [];
|
||||
console.log('[PricingTable] Fetched', instances.length, 'instances from D1');
|
||||
console.log('[PricingTable] Region counts:', data.region_counts);
|
||||
|
||||
if (instances.length > 0) {
|
||||
// 캐시에 저장
|
||||
this.setCache(instances);
|
||||
|
||||
this.instances = this.filterAndSort(instances);
|
||||
this.filteredInstances = this.applyFilters(this.instances);
|
||||
this.lastUpdate = new Date();
|
||||
|
||||
console.log('[PricingTable] Loaded', this.instances.length, 'instances (Linode: JP/SG, Vultr: KR)');
|
||||
} else {
|
||||
throw new Error('인스턴스 데이터가 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PricingTable] Error:', err);
|
||||
|
||||
// 에러 시 만료된 캐시 사용
|
||||
const expiredCache = localStorage.getItem(CACHE_KEY);
|
||||
if (expiredCache) {
|
||||
try {
|
||||
const parsed = JSON.parse(expiredCache);
|
||||
this.instances = this.filterAndSort(parsed.instances);
|
||||
this.filteredInstances = this.applyFilters(this.instances);
|
||||
this.lastUpdate = new Date(parsed.timestamp);
|
||||
this.fromCache = true;
|
||||
this.error = null;
|
||||
console.log('[PricingTable] Using expired cache as fallback');
|
||||
return;
|
||||
} catch (e) {
|
||||
// 캐시 파싱 실패, fallback 사용
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시도 없으면 fallback 데이터 사용
|
||||
console.log('[PricingTable] Using fallback data');
|
||||
this.instances = this.filterAndSort(FALLBACK_DATA);
|
||||
this.filteredInstances = this.applyFilters(this.instances);
|
||||
this.fromCache = true;
|
||||
this.error = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 필터 및 정렬 적용 (모든 인스턴스 표시)
|
||||
filterAndSort(instances) {
|
||||
let filtered = [...instances];
|
||||
|
||||
// 정렬 적용
|
||||
filtered.sort((a, b) => {
|
||||
switch (this.sortBy) {
|
||||
case 'price':
|
||||
return (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
|
||||
case 'vcpu':
|
||||
return (b.vcpu || 0) - (a.vcpu || 0);
|
||||
case 'memory':
|
||||
return (b.memory_mb || 0) - (a.memory_mb || 0);
|
||||
default:
|
||||
return (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
// 리전 이름 정규화 (Tokyo 2, JP -> Tokyo)
|
||||
normalizeRegion(regionName) {
|
||||
const name = regionName.toLowerCase();
|
||||
if (name.includes('osaka') || name.includes('jp-osa')) return 'Osaka';
|
||||
if (name.includes('tokyo')) return 'Tokyo';
|
||||
if (name.includes('singapore')) return 'Singapore';
|
||||
if (name.includes('seoul')) return 'Seoul';
|
||||
return regionName.split(',')[0].trim();
|
||||
},
|
||||
|
||||
// 필터 변경 핸들러 (도시 필터 + 정렬 적용)
|
||||
onFilterChange() {
|
||||
this.filteredInstances = this.applyFilters(this.instances);
|
||||
},
|
||||
|
||||
// 정렬 토글 (같은 컬럼 클릭 시 오름/내림 전환)
|
||||
toggleSort(column) {
|
||||
if (this.sortBy === column) {
|
||||
// 같은 컬럼: 방향 토글
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// 다른 컬럼: 기본 방향 설정
|
||||
this.sortBy = column;
|
||||
// vcpu, memory는 내림차순이 기본, price는 오름차순이 기본
|
||||
this.sortOrder = column === 'price' ? 'asc' : 'desc';
|
||||
}
|
||||
this.onFilterChange();
|
||||
},
|
||||
|
||||
// 도시 필터 + 정렬 적용
|
||||
applyFilters(instances) {
|
||||
let filtered = [...instances];
|
||||
|
||||
// 도시 필터 (항상 적용)
|
||||
filtered = filtered.filter(inst => {
|
||||
const regionName = (inst.region?.region_name || '').toLowerCase();
|
||||
const regionCode = (inst.region?.region_code || '').toLowerCase();
|
||||
const instId = (inst.id || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.gpu_count > 0;
|
||||
switch (this.selectedCity) {
|
||||
case 'global':
|
||||
// Global: Tokyo 2 + 서브탭(Standard/Dedicated/Premium/HighMem) 필터링, GPU 제외
|
||||
if (!regionName.includes('tokyo 2') || hasGpu) return false;
|
||||
switch (this.selectedGlobalType) {
|
||||
case 'standard': return instId.startsWith('g6-nanode') || instId.startsWith('g6-standard');
|
||||
case 'dedicated': return instId.startsWith('g6-dedicated');
|
||||
case 'premium': return instId.startsWith('g7-premium');
|
||||
case 'highmem': return instId.startsWith('g7-highmem');
|
||||
default: return false;
|
||||
}
|
||||
case 'seoul':
|
||||
// 서울은 서브탭(selectedSeoulType)으로 필터링, GPU 제외
|
||||
const isSeoul = regionName.includes('seoul');
|
||||
if (!isSeoul || !instId.startsWith(this.selectedSeoulType + '-') || hasGpu) return false;
|
||||
// vhp: AMD/Intel 중복 방지 → AMD만 표시 (Intel은 레거시)
|
||||
if (this.selectedSeoulType === 'vhp' && instId.endsWith('-intel')) return false;
|
||||
return true;
|
||||
case 'gpu-japan':
|
||||
// 일본 GPU (도쿄만, 중복 방지)
|
||||
return regionName.includes('tokyo 2') && hasGpu;
|
||||
case 'gpu-korea':
|
||||
// 한국 GPU
|
||||
return regionName.includes('seoul') && hasGpu;
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 정렬 (sortOrder 반영, 보조 정렬: 가격)
|
||||
const order = this.sortOrder === 'asc' ? 1 : -1;
|
||||
filtered.sort((a, b) => {
|
||||
let diff = 0;
|
||||
switch (this.sortBy) {
|
||||
case 'price':
|
||||
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
|
||||
break;
|
||||
case 'vcpu':
|
||||
diff = (a.vcpu || 0) - (b.vcpu || 0);
|
||||
if (diff === 0) {
|
||||
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
|
||||
}
|
||||
break;
|
||||
case 'memory':
|
||||
diff = (a.memory_mb || 0) - (b.memory_mb || 0);
|
||||
if (diff === 0) {
|
||||
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
diff = (a.vcpu || 0) - (b.vcpu || 0);
|
||||
}
|
||||
return diff * order;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
// 도시별 인스턴스 수 조회
|
||||
getInstanceCountByCity(cityId) {
|
||||
return this.instances.filter(inst => {
|
||||
const regionName = (inst.region?.region_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.gpu_count > 0;
|
||||
switch (cityId) {
|
||||
case 'global':
|
||||
return regionName.includes('tokyo 2') && !hasGpu;
|
||||
case 'seoul':
|
||||
return regionName.includes('seoul') && !hasGpu;
|
||||
case 'gpu-japan':
|
||||
return regionName.includes('tokyo 2') && hasGpu;
|
||||
case 'gpu-korea':
|
||||
return regionName.includes('seoul') && hasGpu;
|
||||
default: return false;
|
||||
}
|
||||
}).length;
|
||||
},
|
||||
|
||||
// 서울 서브탭별 인스턴스 수 조회
|
||||
getSeoulTypeCount(typeId) {
|
||||
return this.instances.filter(inst => {
|
||||
const regionName = (inst.region?.region_name || '').toLowerCase();
|
||||
const instId = (inst.id || '').toLowerCase();
|
||||
return regionName.includes('seoul') && instId.startsWith(typeId + '-');
|
||||
}).length;
|
||||
},
|
||||
|
||||
// 글로벌 서브탭별 인스턴스 수 조회
|
||||
getGlobalTypeCount(typeId) {
|
||||
return this.instances.filter(inst => {
|
||||
const regionName = (inst.region?.region_name || '').toLowerCase();
|
||||
const instId = (inst.id || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.gpu_count > 0;
|
||||
if (!regionName.includes('tokyo 2') || hasGpu) return false;
|
||||
switch (typeId) {
|
||||
case 'standard': return instId.startsWith('g6-nanode') || instId.startsWith('g6-standard');
|
||||
case 'dedicated': return instId.startsWith('g6-dedicated');
|
||||
case 'premium': return instId.startsWith('g7-premium');
|
||||
case 'highmem': return instId.startsWith('g7-highmem');
|
||||
default: return false;
|
||||
}
|
||||
}).length;
|
||||
},
|
||||
|
||||
// 강제 새로고침 (캐시 삭제 후 API 호출)
|
||||
async forceRefresh() {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
await this.fetchFromApi();
|
||||
},
|
||||
|
||||
// 가격 포맷 (USD)
|
||||
formatUsd(price) {
|
||||
if (price == null) return '-';
|
||||
return '$' + price.toFixed(2);
|
||||
},
|
||||
|
||||
// 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
|
||||
formatKrw(krwPrice) {
|
||||
if (krwPrice == null) return '-';
|
||||
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
|
||||
},
|
||||
|
||||
// 시간당 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
|
||||
formatKrwHourly(krwPrice) {
|
||||
if (krwPrice == null) return '-';
|
||||
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
|
||||
},
|
||||
|
||||
// 메모리 포맷
|
||||
formatMemory(mb) {
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
|
||||
}
|
||||
return mb + ' MB';
|
||||
},
|
||||
|
||||
// 스토리지 포맷
|
||||
formatStorage(gb) {
|
||||
if (gb == null) return '-';
|
||||
if (gb >= 1000) {
|
||||
return (gb / 1000).toFixed(1) + ' TB';
|
||||
}
|
||||
return gb + ' GB';
|
||||
},
|
||||
|
||||
// 트래픽 포맷
|
||||
formatTransfer(tb) {
|
||||
if (tb == null) return '-';
|
||||
if (tb >= 1) {
|
||||
return tb.toFixed(tb % 1 === 0 ? 0 : 1) + ' TB';
|
||||
}
|
||||
return Math.round(tb * 1024) + ' GB';
|
||||
},
|
||||
|
||||
// 프로바이더 색상
|
||||
getProviderColor(provider) {
|
||||
const colors = {
|
||||
'linode': 'bg-green-500/20 text-green-400',
|
||||
'vultr': 'bg-blue-500/20 text-blue-400',
|
||||
'aws': 'bg-orange-500/20 text-orange-400',
|
||||
};
|
||||
return colors[provider?.toLowerCase()] || 'bg-slate-500/20 text-slate-400';
|
||||
},
|
||||
|
||||
// 리전 플래그 이모지
|
||||
getRegionFlag(regionName) {
|
||||
const flags = {
|
||||
'tokyo': '🇯🇵', 'osaka': '🇯🇵', 'japan': '🇯🇵',
|
||||
'singapore': '🇸🇬',
|
||||
'hong kong': '🇭🇰',
|
||||
'seoul': '🇰🇷', 'korea': '🇰🇷',
|
||||
'mumbai': '🇮🇳', 'bangalore': '🇮🇳', 'india': '🇮🇳',
|
||||
'sydney': '🇦🇺', 'australia': '🇦🇺',
|
||||
'amsterdam': '🇳🇱',
|
||||
'frankfurt': '🇩🇪', 'germany': '🇩🇪',
|
||||
'london': '🇬🇧', 'uk': '🇬🇧',
|
||||
'paris': '🇫🇷', 'france': '🇫🇷',
|
||||
'us': '🇺🇸', 'america': '🇺🇸', 'atlanta': '🇺🇸', 'dallas': '🇺🇸',
|
||||
'chicago': '🇺🇸', 'miami': '🇺🇸', 'new york': '🇺🇸', 'seattle': '🇺🇸',
|
||||
'los angeles': '🇺🇸', 'silicon valley': '🇺🇸'
|
||||
};
|
||||
|
||||
const lower = (regionName || '').toLowerCase();
|
||||
for (const [key, flag] of Object.entries(flags)) {
|
||||
if (lower.includes(key)) return flag;
|
||||
}
|
||||
return '🌐';
|
||||
},
|
||||
|
||||
// 마지막 업데이트 시간 표시
|
||||
getLastUpdateText() {
|
||||
if (!this.lastUpdate) return '';
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - this.lastUpdate) / 1000);
|
||||
if (diff < 60) return '방금 전';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
|
||||
return this.lastUpdate.toLocaleTimeString('ko-KR');
|
||||
},
|
||||
|
||||
// 인스턴스 선택 상태
|
||||
selectedInstance: null,
|
||||
|
||||
// 모달 상태
|
||||
showInstanceModal: false,
|
||||
selectedInstanceDetail: null,
|
||||
|
||||
// 클립보드 복사 상태
|
||||
copiedToClipboard: false,
|
||||
|
||||
// 인스턴스 선택 핸들러
|
||||
selectInstance(inst) {
|
||||
// 같은 인스턴스 다시 클릭하면 선택 해제 및 모달 닫기
|
||||
if (this.selectedInstance?.id === inst.id &&
|
||||
this.selectedInstance?.region?.region_code === inst.region?.region_code) {
|
||||
this.selectedInstance = null;
|
||||
this.showInstanceModal = false;
|
||||
this.selectedInstanceDetail = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedInstance = inst;
|
||||
this.selectedInstanceDetail = inst;
|
||||
this.showInstanceModal = true;
|
||||
},
|
||||
|
||||
// 모달 닫기
|
||||
closeInstanceModal() {
|
||||
this.showInstanceModal = false;
|
||||
},
|
||||
|
||||
// 인스턴스 스펙 복사
|
||||
copyInstanceSpec() {
|
||||
const inst = this.selectedInstanceDetail;
|
||||
if (!inst) return;
|
||||
|
||||
const spec = `vCPU: ${inst.vcpu}, RAM: ${this.formatMemory(inst.memory_mb)}, Storage: ${this.formatStorage(inst.storage_gb)}, 월 ${this.formatKrw(inst.pricing?.monthly_price_krw)}`;
|
||||
|
||||
navigator.clipboard.writeText(spec).then(() => {
|
||||
console.log('[PricingTable] Copied to clipboard:', spec);
|
||||
this.copiedToClipboard = true;
|
||||
setTimeout(() => { this.copiedToClipboard = false; }, 2000);
|
||||
}).catch(err => {
|
||||
console.error('[PricingTable] Failed to copy:', err);
|
||||
});
|
||||
},
|
||||
|
||||
// 텔레그램 링크 가져오기
|
||||
getInstanceTelegramLink() {
|
||||
if (!this.selectedInstanceDetail) return '#';
|
||||
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
132
js/utils.js
Normal file
132
js/utils.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Utility Functions
|
||||
* 유틸리티 함수 모음
|
||||
*/
|
||||
|
||||
/**
|
||||
* 가격 포맷팅 (한국 원화)
|
||||
*/
|
||||
export function formatPrice(price) {
|
||||
return '₩' + price.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환 (n8n/Terraform)
|
||||
*/
|
||||
export function switchTab(tab) {
|
||||
const btnN8n = document.getElementById('btn-n8n');
|
||||
const btnTf = document.getElementById('btn-tf');
|
||||
const panelN8n = document.getElementById('panel-n8n');
|
||||
const panelTf = document.getElementById('panel-tf');
|
||||
|
||||
// Null checks for DOM elements
|
||||
if (!btnN8n || !btnTf || !panelN8n || !panelTf) return;
|
||||
|
||||
if (tab === 'n8n') {
|
||||
// Update ARIA states for n8n tab
|
||||
btnN8n.setAttribute('aria-selected', 'true');
|
||||
btnN8n.setAttribute('tabindex', '0');
|
||||
btnTf.setAttribute('aria-selected', 'false');
|
||||
btnTf.setAttribute('tabindex', '-1');
|
||||
|
||||
// Update classes
|
||||
btnN8n.className = 'px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-bold transition shadow-lg shadow-purple-500/20';
|
||||
btnTf.className = 'px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-sm font-bold border border-slate-700 hover:text-white transition';
|
||||
panelN8n.classList.remove('hidden');
|
||||
panelTf.classList.add('hidden');
|
||||
} else {
|
||||
// Update ARIA states for Terraform tab
|
||||
btnTf.setAttribute('aria-selected', 'true');
|
||||
btnTf.setAttribute('tabindex', '0');
|
||||
btnN8n.setAttribute('aria-selected', 'false');
|
||||
btnN8n.setAttribute('tabindex', '-1');
|
||||
|
||||
// Update classes
|
||||
btnN8n.className = 'px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-sm font-bold border border-slate-700 hover:text-white transition';
|
||||
btnTf.className = 'px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-bold transition shadow-lg shadow-blue-500/20';
|
||||
panelN8n.classList.add('hidden');
|
||||
panelTf.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 Ping 시뮬레이션
|
||||
*/
|
||||
export function updatePing() {
|
||||
const regions = [
|
||||
{ id: 'ping-kr', base: 2, variance: 2 },
|
||||
{ id: 'ping-jp', base: 35, variance: 5 },
|
||||
{ id: 'ping-hk', base: 45, variance: 8 },
|
||||
{ id: 'ping-sg', base: 65, variance: 10 }
|
||||
];
|
||||
|
||||
regions.forEach(region => {
|
||||
const el = document.getElementById(region.id);
|
||||
if (el) {
|
||||
const jitter = Math.floor(Math.random() * region.variance) - (region.variance / 2);
|
||||
let val = Math.max(1, Math.floor(region.base + jitter));
|
||||
el.innerText = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ping 업데이트 시작 (visibility-aware)
|
||||
let pingInterval;
|
||||
|
||||
export function startPingUpdates() {
|
||||
if (!pingInterval) {
|
||||
pingInterval = setInterval(updatePing, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopPingUpdates() {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility change handler
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopPingUpdates();
|
||||
} else {
|
||||
startPingUpdates();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial start
|
||||
startPingUpdates();
|
||||
|
||||
// Tab switching with event listeners and keyboard navigation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btnN8n = document.getElementById('btn-n8n');
|
||||
const btnTf = document.getElementById('btn-tf');
|
||||
|
||||
if (btnN8n && btnTf) {
|
||||
// Click event listeners
|
||||
btnN8n.addEventListener('click', () => switchTab('n8n'));
|
||||
btnTf.addEventListener('click', () => switchTab('tf'));
|
||||
|
||||
// Keyboard navigation (Arrow keys)
|
||||
[btnN8n, btnTf].forEach(btn => {
|
||||
btn.addEventListener('keydown', (e) => {
|
||||
const currentTab = btn.getAttribute('data-tab');
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
if (currentTab === 'n8n') {
|
||||
btnTf.focus();
|
||||
switchTab('tf');
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
if (currentTab === 'tf') {
|
||||
btnN8n.focus();
|
||||
switchTab('n8n');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
199
js/wizard.js
Normal file
199
js/wizard.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Server Recommendation Wizard
|
||||
* 서버 추천 마법사 로직
|
||||
*/
|
||||
|
||||
import { WIZARD_CONFIG } from './config.js';
|
||||
|
||||
/**
|
||||
* 서버 추천 로직 (룰 기반)
|
||||
* @param {string[]} selectedStacks - 선택된 스택 ID 목록
|
||||
* @param {string} scale - 규모 키 (personal, small, medium, large)
|
||||
* @returns {Object} 추천 결과 { economy, recommended, performance }
|
||||
*/
|
||||
export function calculateRecommendation(selectedStacks, scale) {
|
||||
// 기본 요구사항 계산
|
||||
let totalRam = 0;
|
||||
let totalCpu = 0;
|
||||
let needsGpu = false;
|
||||
|
||||
selectedStacks.forEach(stackId => {
|
||||
// 모든 카테고리에서 스택 찾기
|
||||
for (const category of Object.values(WIZARD_CONFIG.stacks)) {
|
||||
const stack = category.find(s => s.id === stackId);
|
||||
if (stack) {
|
||||
totalRam += stack.ram;
|
||||
totalCpu += stack.cpu;
|
||||
if (stack.gpu) needsGpu = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 규모에 따른 배율 적용 (이제 객체 형태)
|
||||
const scaleConfig = WIZARD_CONFIG.scales[scale];
|
||||
const multiplier = scaleConfig?.multiplier || 1;
|
||||
|
||||
totalRam = Math.ceil(totalRam * multiplier);
|
||||
totalCpu = Math.ceil(totalCpu * multiplier);
|
||||
|
||||
// 최소/최대 제한
|
||||
totalRam = Math.max(1024, Math.min(totalRam, 65536));
|
||||
totalCpu = Math.max(1, Math.min(totalCpu, 16));
|
||||
|
||||
// 가격 계산 (대략적인 추정, 실제로는 API 호출 필요)
|
||||
const estimatePrice = (cpu, ram) => {
|
||||
// 기본 가격: vCPU당 $5, GB당 $2 (월간)
|
||||
return cpu * 5 + (ram / 1024) * 2;
|
||||
};
|
||||
|
||||
// 추천 플랜 결정 (객체 형태로 반환)
|
||||
return {
|
||||
economy: {
|
||||
cpu: Math.max(1, Math.floor(totalCpu * 0.7)),
|
||||
ram: Math.max(1024, Math.floor(totalRam * 0.7)),
|
||||
price: estimatePrice(Math.max(1, Math.floor(totalCpu * 0.7)), Math.max(1024, Math.floor(totalRam * 0.7)))
|
||||
},
|
||||
recommended: {
|
||||
cpu: totalCpu,
|
||||
ram: totalRam,
|
||||
price: estimatePrice(totalCpu, totalRam)
|
||||
},
|
||||
performance: {
|
||||
cpu: Math.min(16, Math.ceil(totalCpu * 1.5)),
|
||||
ram: Math.min(65536, Math.ceil(totalRam * 1.5)),
|
||||
price: estimatePrice(Math.min(16, Math.ceil(totalCpu * 1.5)), Math.min(65536, Math.ceil(totalRam * 1.5)))
|
||||
},
|
||||
needsGpu,
|
||||
totalStacks: selectedStacks.length,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 마법사 앱 상태 및 메서드
|
||||
* anvilApp에서 사용할 마법사 관련 로직
|
||||
*/
|
||||
export function createWizardMethods() {
|
||||
return {
|
||||
// 마법사 상태
|
||||
wizardOpen: false,
|
||||
wizardCurrentStep: 0, // 0: purpose, 1: stack, 2: scale, 3: result
|
||||
wizardPurpose: null,
|
||||
wizardStacks: [], // 선택된 스택들 (다중 선택)
|
||||
wizardScale: null,
|
||||
wizardRecommendations: null,
|
||||
wizardSelectedPlan: null,
|
||||
|
||||
// 마법사 데이터 접근자
|
||||
get wizardPurposes() { return WIZARD_CONFIG.purposes; },
|
||||
get wizardScales() { return WIZARD_CONFIG.scales; },
|
||||
|
||||
// 현재 용도에 맞는 스택 목록
|
||||
get wizardAvailableStacks() {
|
||||
if (!this.wizardPurpose) return [];
|
||||
return WIZARD_CONFIG.stacks[this.wizardPurpose] || [];
|
||||
},
|
||||
|
||||
// 마법사 열기
|
||||
openWizard() {
|
||||
this.wizardOpen = true;
|
||||
this.wizardCurrentStep = 0;
|
||||
this.wizardPurpose = null;
|
||||
this.wizardStacks = [];
|
||||
this.wizardScale = null;
|
||||
this.wizardRecommendations = null;
|
||||
this.wizardSelectedPlan = null;
|
||||
},
|
||||
|
||||
// 마법사 닫기
|
||||
closeWizard() {
|
||||
this.wizardOpen = false;
|
||||
},
|
||||
|
||||
// 용도 선택
|
||||
selectWizardPurpose(purposeId) {
|
||||
this.wizardPurpose = purposeId;
|
||||
this.wizardStacks = []; // 스택 초기화
|
||||
this.wizardCurrentStep = 1;
|
||||
},
|
||||
|
||||
// 스택 토글 (다중 선택)
|
||||
toggleWizardStack(stackId) {
|
||||
const idx = this.wizardStacks.indexOf(stackId);
|
||||
if (idx === -1) {
|
||||
this.wizardStacks.push(stackId);
|
||||
} else {
|
||||
this.wizardStacks.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
|
||||
// 스택 선택 완료 → 규모 선택으로
|
||||
confirmWizardStacks() {
|
||||
if (this.wizardStacks.length === 0) {
|
||||
alert('최소 1개의 기술 스택을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
this.wizardCurrentStep = 2;
|
||||
},
|
||||
|
||||
// 규모 선택 → 추천 결과로
|
||||
selectWizardScale(scaleId) {
|
||||
this.wizardScale = scaleId;
|
||||
this.wizardRecommendations = calculateRecommendation(this.wizardStacks, scaleId);
|
||||
this.wizardCurrentStep = 3;
|
||||
},
|
||||
|
||||
// 이전 단계로
|
||||
wizardGoBack() {
|
||||
if (this.wizardCurrentStep > 0) {
|
||||
this.wizardCurrentStep--;
|
||||
}
|
||||
},
|
||||
|
||||
// 추천 플랜 선택 → 텔레그램으로 연결
|
||||
selectWizardPlan(tierKey) {
|
||||
const plan = this.wizardRecommendations[tierKey];
|
||||
if (!plan) return;
|
||||
|
||||
this.wizardSelectedPlan = plan;
|
||||
|
||||
// 텔레그램 봇으로 연결 (선택한 사양 정보 포함)
|
||||
window.open(`https://t.me/AnvilForgeBot?start=wizard_${tierKey}_${plan.cpu}c_${plan.ram}m`, '_blank');
|
||||
|
||||
this.closeWizard();
|
||||
},
|
||||
|
||||
// RAM 포맷팅
|
||||
formatWizardRam(mb) {
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
|
||||
}
|
||||
return mb + ' MB';
|
||||
},
|
||||
|
||||
// 현재 용도에 맞는 스택 그룹 반환
|
||||
getWizardStacksByPurpose() {
|
||||
if (!this.wizardPurpose) return {};
|
||||
const purposeStacks = WIZARD_CONFIG.stacks[this.wizardPurpose] || [];
|
||||
// 카테고리별로 그룹핑
|
||||
const grouped = {};
|
||||
for (const stack of purposeStacks) {
|
||||
if (!grouped[stack.category]) {
|
||||
grouped[stack.category] = [];
|
||||
}
|
||||
grouped[stack.category].push(stack);
|
||||
}
|
||||
return grouped;
|
||||
},
|
||||
|
||||
// 스택 ID로 이름 찾기
|
||||
getWizardStackName(stackId) {
|
||||
for (const purposeStacks of Object.values(WIZARD_CONFIG.stacks)) {
|
||||
const stack = purposeStacks.find(s => s.id === stackId);
|
||||
if (stack) return stack.name;
|
||||
}
|
||||
return stackId;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user