- Add spec diversity: recommend Budget/Balanced/Premium tiers instead of same spec - Add bandwidth-based filtering: prioritize servers with adequate transfer allowance - Fix KRW rounding: server price 500원, TB cost 500원, GB cost 1원 - Add bandwidth warning to infrastructure_tips when traffic exceeds 2x included Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
/**
|
||
* Bandwidth estimation utility functions
|
||
*/
|
||
|
||
import type { BandwidthEstimate, BandwidthInfo, UseCaseConfig } from '../types';
|
||
import { USE_CASE_CONFIGS } from '../config';
|
||
|
||
/**
|
||
* Find use case configuration by matching patterns
|
||
*/
|
||
export function findUseCaseConfig(useCase: string): UseCaseConfig {
|
||
const useCaseLower = useCase.toLowerCase();
|
||
|
||
for (const config of USE_CASE_CONFIGS) {
|
||
if (config.patterns.test(useCaseLower)) {
|
||
return config;
|
||
}
|
||
}
|
||
|
||
// Default configuration
|
||
return {
|
||
category: 'default',
|
||
patterns: /.*/,
|
||
dauMultiplier: { min: 10, max: 14 },
|
||
activeRatio: 0.5,
|
||
cdnCacheHitRate: 0.50 // 기본값: 50%
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get CDN cache hit rate based on use case
|
||
* Returns default rate for the use case category
|
||
*/
|
||
export function getCdnCacheHitRate(useCase: string): number {
|
||
return findUseCaseConfig(useCase).cdnCacheHitRate;
|
||
}
|
||
|
||
/**
|
||
* Get DAU multiplier based on use case (how many daily active users per concurrent user)
|
||
*/
|
||
export function getDauMultiplier(useCase: string): { min: number; max: number } {
|
||
return findUseCaseConfig(useCase).dauMultiplier;
|
||
}
|
||
|
||
/**
|
||
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
|
||
*/
|
||
export function getActiveUserRatio(useCase: string): number {
|
||
return findUseCaseConfig(useCase).activeRatio;
|
||
}
|
||
|
||
export interface CdnOptions {
|
||
enabled?: boolean; // CDN 사용 여부 (기본: true)
|
||
cacheHitRate?: number; // 캐시 히트율 override (0.0-1.0)
|
||
}
|
||
|
||
/**
|
||
* Estimate monthly bandwidth based on concurrent users and use case
|
||
* @param concurrentUsers Expected concurrent users
|
||
* @param useCase Use case description
|
||
* @param trafficPattern Traffic pattern (steady, spiky, growing)
|
||
* @param cdnOptions CDN configuration options
|
||
*/
|
||
export function estimateBandwidth(
|
||
concurrentUsers: number,
|
||
useCase: string,
|
||
trafficPattern?: string,
|
||
cdnOptions?: CdnOptions
|
||
): BandwidthEstimate {
|
||
const useCaseLower = useCase.toLowerCase();
|
||
|
||
// Get use case configuration
|
||
const config = findUseCaseConfig(useCase);
|
||
const useCaseCategory = config.category;
|
||
|
||
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
|
||
const dauMultiplier = config.dauMultiplier;
|
||
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
|
||
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
|
||
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
|
||
const activeUserRatio = config.activeRatio;
|
||
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
|
||
|
||
// Traffic pattern adjustment
|
||
let patternMultiplier = 1.0;
|
||
if (trafficPattern === 'spiky') {
|
||
patternMultiplier = 1.5; // Account for peak loads
|
||
} else if (trafficPattern === 'growing') {
|
||
patternMultiplier = 1.3; // Headroom for growth
|
||
}
|
||
|
||
let dailyBandwidthGB: number;
|
||
let bandwidthModel: string;
|
||
|
||
// ========== IMPROVED BANDWIDTH MODELS ==========
|
||
// Each use case uses the most appropriate calculation method
|
||
|
||
switch (useCaseCategory) {
|
||
case 'video': {
|
||
// VIDEO/STREAMING: Bitrate-based model
|
||
const is4K = /4k|uhd|ultra/i.test(useCaseLower);
|
||
const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD
|
||
const avgWatchTimeHours = is4K ? 1.0 : 1.5;
|
||
const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`;
|
||
break;
|
||
}
|
||
|
||
case 'file': {
|
||
// FILE DOWNLOAD: File-size based model
|
||
const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower);
|
||
const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2;
|
||
const downloadsPerUser = isLargeFiles ? 1 : 3;
|
||
const gbPerActiveUser = avgFileSizeGB * downloadsPerUser;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`;
|
||
break;
|
||
}
|
||
|
||
case 'gaming': {
|
||
// GAMING: Session-duration based model
|
||
const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower);
|
||
const mbPerHour = isMinecraft ? 150 : 80;
|
||
const avgSessionHours = isMinecraft ? 3 : 2.5;
|
||
const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`;
|
||
break;
|
||
}
|
||
|
||
case 'api': {
|
||
// API/SAAS: Request-based model
|
||
const avgRequestKB = 20;
|
||
const requestsPerUserPerDay = 1000;
|
||
const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024);
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`;
|
||
break;
|
||
}
|
||
|
||
case 'ecommerce': {
|
||
// E-COMMERCE: Page-based model (images heavy)
|
||
const avgPageSizeMB = 2.5;
|
||
const pagesPerSession = 20;
|
||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||
break;
|
||
}
|
||
|
||
case 'forum': {
|
||
// FORUM/COMMUNITY: Page-based model (text + some images)
|
||
const avgPageSizeMB = 0.7;
|
||
const pagesPerSession = 30;
|
||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||
break;
|
||
}
|
||
|
||
case 'blog': {
|
||
// STATIC/BLOG: Lightweight page-based model
|
||
const avgPageSizeMB = 1.5;
|
||
const pagesPerSession = 4;
|
||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||
break;
|
||
}
|
||
|
||
case 'chat': {
|
||
// CHAT/MESSAGING: Message-based model
|
||
const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages
|
||
const attachmentBandwidthMB = 20; // occasional images/files
|
||
const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`;
|
||
break;
|
||
}
|
||
|
||
default: {
|
||
// DEFAULT: General web app (page-based)
|
||
const avgPageSizeMB = 1.0;
|
||
const pagesPerSession = 10;
|
||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||
bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// CDN configuration
|
||
const cdnEnabled = cdnOptions?.enabled !== false; // 기본값: true
|
||
const cdnCacheHitRate = cdnOptions?.cacheHitRate ?? config.cdnCacheHitRate;
|
||
|
||
console.log(`[Bandwidth] Model: ${bandwidthModel}`);
|
||
console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`);
|
||
|
||
// Monthly bandwidth (gross - before CDN)
|
||
const grossMonthlyGB = dailyBandwidthGB * 30;
|
||
const grossMonthlyTB = grossMonthlyGB / 1024;
|
||
|
||
// Origin bandwidth (after CDN cache hit)
|
||
const originMonthlyTB = cdnEnabled
|
||
? grossMonthlyTB * (1 - cdnCacheHitRate)
|
||
: grossMonthlyTB;
|
||
const originMonthlyGB = originMonthlyTB * 1024;
|
||
|
||
// Use origin traffic for categorization (actual server load)
|
||
const monthlyGB = originMonthlyGB;
|
||
const monthlyTB = originMonthlyTB;
|
||
|
||
console.log(`[Bandwidth] CDN: ${cdnEnabled ? 'enabled' : 'disabled'}, Hit Rate: ${(cdnCacheHitRate * 100).toFixed(0)}%`);
|
||
console.log(`[Bandwidth] Gross: ${grossMonthlyTB.toFixed(1)} TB → Origin: ${originMonthlyTB.toFixed(1)} TB (${((1 - cdnCacheHitRate) * 100).toFixed(0)}%)`);
|
||
|
||
// Categorize based on ORIGIN traffic (actual server load)
|
||
let category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
|
||
let description: string;
|
||
|
||
if (monthlyTB < 0.5) {
|
||
category = 'light';
|
||
description = cdnEnabled
|
||
? `총 ${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${Math.round(monthlyGB)}GB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시)`
|
||
: `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`;
|
||
} else if (monthlyTB < 2) {
|
||
category = 'moderate';
|
||
description = cdnEnabled
|
||
? `총 ${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${monthlyTB.toFixed(1)}TB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시)`
|
||
: `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`;
|
||
} else if (monthlyTB < 6) {
|
||
category = 'heavy';
|
||
description = cdnEnabled
|
||
? `총 ${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${monthlyTB.toFixed(1)}TB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시) - 대역폭 여유 있는 플랜 권장`
|
||
: `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`;
|
||
} else {
|
||
category = 'very_heavy';
|
||
description = cdnEnabled
|
||
? `총 ${grossMonthlyTB.toFixed(1)}TB 중 원본 서버 ~${monthlyTB.toFixed(1)}TB/월 (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시) - 고대역폭 플랜 필수`
|
||
: `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`;
|
||
}
|
||
|
||
return {
|
||
monthly_gb: Math.round(monthlyGB),
|
||
monthly_tb: Math.round(monthlyTB * 10) / 10,
|
||
daily_gb: Math.round(dailyBandwidthGB * 10) / 10,
|
||
category,
|
||
description,
|
||
estimated_dau_min: estimatedDauMin,
|
||
estimated_dau_max: estimatedDauMax,
|
||
active_ratio: activeUserRatio,
|
||
// CDN breakdown
|
||
cdn_enabled: cdnEnabled,
|
||
cdn_cache_hit_rate: cdnCacheHitRate,
|
||
gross_monthly_tb: Math.round(grossMonthlyTB * 10) / 10,
|
||
origin_monthly_tb: Math.round(originMonthlyTB * 10) / 10
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get provider bandwidth allocation based on memory size
|
||
* Returns included transfer in TB/month
|
||
*/
|
||
export function getProviderBandwidthAllocation(providerName: string, memoryGb: number): {
|
||
included_tb: number;
|
||
overage_per_gb: number;
|
||
overage_per_tb: number;
|
||
} {
|
||
const provider = providerName.toLowerCase();
|
||
|
||
if (provider.includes('linode')) {
|
||
// Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.)
|
||
const includedTb = Math.min(Math.max(memoryGb, 1), 20);
|
||
return {
|
||
included_tb: includedTb,
|
||
overage_per_gb: 0.005, // $0.005/GB = $5/TB
|
||
overage_per_tb: 5
|
||
};
|
||
} else if (provider.includes('vultr')) {
|
||
// Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large
|
||
let includedTb: number;
|
||
if (memoryGb <= 2) includedTb = 1;
|
||
else if (memoryGb <= 4) includedTb = 2;
|
||
else if (memoryGb <= 8) includedTb = 3;
|
||
else if (memoryGb <= 16) includedTb = 4;
|
||
else if (memoryGb <= 32) includedTb = 5;
|
||
else includedTb = Math.min(memoryGb / 4, 10);
|
||
|
||
return {
|
||
included_tb: includedTb,
|
||
overage_per_gb: 0.01, // $0.01/GB = $10/TB
|
||
overage_per_tb: 10
|
||
};
|
||
} else {
|
||
// Default/Other providers: conservative estimate
|
||
return {
|
||
included_tb: Math.min(memoryGb, 5),
|
||
overage_per_gb: 0.01,
|
||
overage_per_tb: 10
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate bandwidth cost info for a server
|
||
* Uses actual DB values from anvil_transfer_pricing when available
|
||
* @param server Server object
|
||
* @param bandwidthEstimate Bandwidth estimate
|
||
* @param lang Language code (ko = KRW, others = USD)
|
||
* @param exchangeRate Exchange rate (USD to KRW)
|
||
*/
|
||
export function calculateBandwidthInfo(
|
||
server: import('../types').Server,
|
||
bandwidthEstimate: BandwidthEstimate,
|
||
lang: string = 'en',
|
||
exchangeRate: number = 1
|
||
): BandwidthInfo {
|
||
// Use actual DB values if available (Anvil servers), fallback to provider-based estimation
|
||
let includedTb: number;
|
||
let overagePerGbUsd: number;
|
||
let overagePerTbUsd: number;
|
||
|
||
if (server.transfer_tb !== null && server.transfer_price_per_gb !== null) {
|
||
// Use actual values from anvil_instances + anvil_transfer_pricing
|
||
includedTb = server.transfer_tb;
|
||
overagePerGbUsd = server.transfer_price_per_gb;
|
||
overagePerTbUsd = server.transfer_price_per_gb * 1024;
|
||
} else {
|
||
// Fallback to provider-based estimation for non-Anvil servers
|
||
const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb);
|
||
includedTb = allocation.included_tb;
|
||
overagePerGbUsd = allocation.overage_per_gb;
|
||
overagePerTbUsd = allocation.overage_per_tb;
|
||
}
|
||
|
||
const estimatedTb = bandwidthEstimate.monthly_tb;
|
||
const overageTb = Math.max(0, estimatedTb - includedTb);
|
||
const overageCostUsd = overageTb * overagePerTbUsd;
|
||
|
||
// Get server price in USD for total calculation
|
||
const serverPriceUsd = server.currency === 'KRW'
|
||
? server.monthly_price / exchangeRate
|
||
: server.monthly_price;
|
||
|
||
const totalCostUsd = serverPriceUsd + overageCostUsd;
|
||
|
||
// Convert to KRW if Korean language, round to nearest 100
|
||
const isKorean = lang === 'ko';
|
||
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
|
||
|
||
// KRW 반올림: GB당 1원, TB당 500원, 총 비용 1원
|
||
const toKrw = (usd: number) => Math.round(usd * exchangeRate); // 1원 단위
|
||
const toKrw500 = (usd: number) => Math.round((usd * exchangeRate) / 500) * 500; // 500원 단위
|
||
|
||
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
|
||
const overagePerTb = isKorean ? toKrw500(overagePerTbUsd) : overagePerTbUsd; // 500원 단위
|
||
const overageCost = isKorean ? toKrw500(overageCostUsd) : overageCostUsd; // 500원 단위
|
||
// totalCost: 서버 가격(500원) + 초과 비용(500원)
|
||
const totalCost = isKorean ? server.monthly_price + toKrw500(overageCostUsd) : totalCostUsd;
|
||
|
||
// CDN savings calculation
|
||
const cdnEnabled = bandwidthEstimate.cdn_enabled;
|
||
const cdnCacheHitRate = bandwidthEstimate.cdn_cache_hit_rate;
|
||
const grossMonthlyTb = bandwidthEstimate.gross_monthly_tb;
|
||
const cdnSavingsTb = cdnEnabled ? grossMonthlyTb - estimatedTb : 0;
|
||
const cdnSavingsCostUsd = cdnSavingsTb * overagePerTbUsd;
|
||
const cdnSavingsCost = isKorean ? toKrw500(cdnSavingsCostUsd) : cdnSavingsCostUsd; // 500원 단위
|
||
|
||
let warning: string | undefined;
|
||
if (overageTb > includedTb) {
|
||
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||
warning = cdnEnabled
|
||
? `⚠️ CDN 적용 후에도 원본 서버 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`
|
||
: `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
|
||
} else if (overageTb > 0) {
|
||
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||
warning = cdnEnabled
|
||
? `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (CDN ${(cdnCacheHitRate * 100).toFixed(0)}% 캐시 적용 후, 추가 비용 ~${costStr}/월)`
|
||
: `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`;
|
||
}
|
||
|
||
return {
|
||
included_transfer_tb: includedTb,
|
||
overage_cost_per_gb: overagePerGb, // 반올림 없음
|
||
overage_cost_per_tb: overagePerTb, // 반올림 없음
|
||
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
||
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
||
estimated_overage_cost: overageCost, // 반올림 없음
|
||
total_estimated_cost: totalCost, // 서버 가격(반올림) + 초과 비용(반올림 X)
|
||
currency,
|
||
warning,
|
||
// CDN breakdown
|
||
cdn_enabled: cdnEnabled,
|
||
cdn_cache_hit_rate: cdnCacheHitRate,
|
||
gross_monthly_tb: Math.round(grossMonthlyTb * 10) / 10,
|
||
cdn_savings_tb: Math.round(cdnSavingsTb * 10) / 10,
|
||
cdn_savings_cost: cdnSavingsCost // 반올림 없음
|
||
};
|
||
}
|