feat: add server recommendation wizard
- Add 4-step wizard (purpose → stack → scale → recommendation) - Rule-based recommendation engine (WIZARD_CONFIG) - 6 purpose categories: web, game, AI/ML, dev, database, other - Multiple stack selection with category grouping - 4 scale options with RAM/CPU multipliers - 3-tier recommendations: economy, recommended, performance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
337
app.js
337
app.js
@@ -229,6 +229,139 @@ function formatPrice(price) {
|
||||
return '₩' + price.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 서버 추천 마법사 - 기술 스택 기반 추천 규칙
|
||||
// ============================================================
|
||||
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: '🏙️' }
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 서버 추천 로직 (룰 기반)
|
||||
* @param {string[]} selectedStacks - 선택된 스택 ID 목록
|
||||
* @param {string} scale - 규모 키 (personal, small, medium, large)
|
||||
* @returns {Object} 추천 결과 { economy, recommended, performance }
|
||||
*/
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alpine.js 메인 앱 데이터 - 대화형 위자드
|
||||
*/
|
||||
@@ -240,6 +373,133 @@ function anvilApp() {
|
||||
wizardStep: 0, // 0: region, 1: plan, 2: os, 3: payment, 4: confirm, 5+: deploying
|
||||
deployStep: 0,
|
||||
|
||||
// ============================================================
|
||||
// 서버 추천 마법사 상태
|
||||
// ============================================================
|
||||
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;
|
||||
|
||||
// 텔레그램 봇으로 연결 (선택한 사양 정보 포함)
|
||||
const purposeName = WIZARD_CONFIG.purposes[this.wizardPurpose]?.name || this.wizardPurpose;
|
||||
const scaleName = WIZARD_CONFIG.scales[this.wizardScale]?.name || this.wizardScale;
|
||||
const stackNames = this.wizardStacks.map(id => this.getWizardStackName(id)).join(', ');
|
||||
const tierLabels = { economy: 'Economy', recommended: 'Recommended', performance: 'Performance' };
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
// 대시보드 상태
|
||||
dashboardMode: false, // true면 대시보드, false면 랜딩
|
||||
currentView: 'servers', // 'servers' | 'stats' | 'notifications'
|
||||
@@ -937,19 +1197,19 @@ function pricingTable() {
|
||||
|
||||
// 글로벌(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' },
|
||||
{ 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: '가성비', desc: '일반 SSD' },
|
||||
{ id: 'vhf', name: '고주파', desc: 'NVMe·3GHz+' },
|
||||
{ id: 'vhp', name: '고성능', desc: 'AMD/Intel' },
|
||||
{ id: 'voc', name: '최적화', desc: 'CPU/메모리/스토리지' },
|
||||
{ id: 'vx1', name: '차세대', desc: '고밀도 메모리' },
|
||||
{ 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, 인메모리 캐시에 적합' },
|
||||
],
|
||||
|
||||
// 데이터 상태
|
||||
@@ -1157,8 +1417,12 @@ function pricingTable() {
|
||||
}
|
||||
case 'seoul':
|
||||
// 서울은 서브탭(selectedSeoulType)으로 필터링, GPU 제외
|
||||
// vhp는 AMD/Intel 중복 → AMD만 표시
|
||||
const isSeoul = regionName.includes('seoul');
|
||||
return isSeoul && instId.startsWith(this.selectedSeoulType + '-') && !hasGpu;
|
||||
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;
|
||||
@@ -1342,6 +1606,59 @@ function pricingTable() {
|
||||
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)}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user