feat: add terminal-style pricing table with region filtering
- Add dynamic pricing section with kubectl-style table UI - Implement region tabs (Tokyo 1-3, Osaka 1-2, Seoul 1, Singapore 1) - Integrate with existing /api/pricing endpoint - Add localStorage caching with 1-hour TTL - Apply terminal theme (Fira Code, GitHub Dark colors) - Sort by price, filter by region Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Pricing endpoint - Direct D1 query (no rate limiting)
|
||||
* GET /api/pricing → D1 cloud-instances-db
|
||||
* Pricing endpoint - Anvil 가격표 (D1 직접 조회)
|
||||
* GET /api/pricing → D1 cloud-instances-db (anvil_* 테이블)
|
||||
*
|
||||
* Supported regions:
|
||||
* - Tokyo, Osaka, Singapore: Linode
|
||||
* - Seoul: Vultr
|
||||
* Anvil Regions:
|
||||
* - Tokyo 1-3 (Linode × 2 + Vultr)
|
||||
* - Osaka 1-2 (Linode + Vultr)
|
||||
* - Seoul 1 (Vultr)
|
||||
* - Singapore 1 (Linode)
|
||||
*/
|
||||
|
||||
import { type PagesFunction } from '@cloudflare/workers-types';
|
||||
@@ -13,24 +15,58 @@ interface Env {
|
||||
DB: D1Database;
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
instance_id: string;
|
||||
interface AnvilPricingRow {
|
||||
instance_id: number;
|
||||
instance_name: string;
|
||||
vcpu: number;
|
||||
memory_mb: number;
|
||||
storage_gb: number | null;
|
||||
display_name: string;
|
||||
category: string;
|
||||
vcpus: number;
|
||||
memory_gb: number;
|
||||
disk_gb: number;
|
||||
transfer_tb: number | null;
|
||||
provider_name: string;
|
||||
network_gbps: number | null;
|
||||
gpu_model: string | null;
|
||||
gpu_vram_gb: number | null;
|
||||
region_id: number;
|
||||
region_name: string;
|
||||
region_code: string;
|
||||
region_display_name: string;
|
||||
country_code: string;
|
||||
source_provider: string;
|
||||
hourly_price: number;
|
||||
monthly_price: number;
|
||||
hourly_price: number | null;
|
||||
monthly_price_krw: number | null;
|
||||
hourly_price_krw: number | null;
|
||||
// GPU fields
|
||||
has_gpu?: number;
|
||||
gpu_count?: number;
|
||||
gpu_type?: string;
|
||||
}
|
||||
|
||||
interface TransferPricingRow {
|
||||
region_name: string;
|
||||
region_display_name: string;
|
||||
price_per_gb: number;
|
||||
}
|
||||
|
||||
// 기본 환율 (API 실패 시 fallback)
|
||||
const DEFAULT_USD_TO_KRW = 1450;
|
||||
|
||||
// 실시간 환율 조회 (exchangerate-api.com)
|
||||
async function fetchExchangeRate(): Promise<{ rate: number; source: string }> {
|
||||
try {
|
||||
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
|
||||
cf: { cacheTtl: 3600 }, // 1시간 캐시
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Exchange rate API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { rates?: { KRW?: number } };
|
||||
const rate = data.rates?.KRW;
|
||||
|
||||
if (rate && rate > 0) {
|
||||
return { rate: Math.round(rate), source: 'realtime' };
|
||||
}
|
||||
throw new Error('Invalid exchange rate data');
|
||||
} catch (error) {
|
||||
console.error('[Pricing] Exchange rate fetch failed:', error);
|
||||
return { rate: DEFAULT_USD_TO_KRW, source: 'fallback' };
|
||||
}
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
@@ -41,165 +77,151 @@ const CORS_HEADERS = {
|
||||
|
||||
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
|
||||
try {
|
||||
// 1. 일반 인스턴스 쿼리 - Linode(Tokyo, Osaka, Singapore) + Vultr(Seoul)
|
||||
const regularSql = `
|
||||
// 실시간 환율 조회
|
||||
const exchangeRate = await fetchExchangeRate();
|
||||
const USD_TO_KRW = exchangeRate.rate;
|
||||
|
||||
// Anvil 가격 조회 (인스턴스 + 리전 + 가격 조인)
|
||||
const pricingSql = `
|
||||
SELECT
|
||||
it.instance_id,
|
||||
it.instance_name,
|
||||
it.vcpu,
|
||||
it.memory_mb,
|
||||
it.storage_gb,
|
||||
it.transfer_tb,
|
||||
p.name as provider_name,
|
||||
r.region_name,
|
||||
r.region_code,
|
||||
pr.monthly_price,
|
||||
pr.hourly_price,
|
||||
pr.monthly_price_krw,
|
||||
pr.hourly_price_krw,
|
||||
0 as has_gpu,
|
||||
0 as gpu_count,
|
||||
NULL as gpu_type
|
||||
FROM instance_types it
|
||||
JOIN providers p ON it.provider_id = p.id
|
||||
JOIN pricing pr ON it.id = pr.instance_type_id
|
||||
JOIN regions r ON pr.region_id = r.id
|
||||
WHERE pr.available = 1
|
||||
AND it.instance_id NOT LIKE '%-sc1'
|
||||
AND (
|
||||
(p.name = 'linode' AND (
|
||||
r.region_name LIKE '%Tokyo%' OR
|
||||
r.region_name LIKE '%Osaka%' OR
|
||||
r.region_code LIKE '%jp-osa%' OR
|
||||
r.region_name LIKE '%Singapore%'
|
||||
))
|
||||
OR
|
||||
(p.name = 'vultr' AND (
|
||||
r.region_name LIKE '%Seoul%' OR
|
||||
r.region_code LIKE '%icn%'
|
||||
))
|
||||
)
|
||||
ORDER BY pr.monthly_price ASC
|
||||
ai.id as instance_id,
|
||||
ai.name as instance_name,
|
||||
ai.display_name,
|
||||
ai.category,
|
||||
ai.vcpus,
|
||||
ai.memory_gb,
|
||||
ai.disk_gb,
|
||||
ai.transfer_tb,
|
||||
ai.network_gbps,
|
||||
ai.gpu_model,
|
||||
ai.gpu_vram_gb,
|
||||
ar.id as region_id,
|
||||
ar.name as region_name,
|
||||
ar.display_name as region_display_name,
|
||||
ar.country_code,
|
||||
ar.source_provider,
|
||||
ap.hourly_price,
|
||||
ap.monthly_price
|
||||
FROM anvil_pricing ap
|
||||
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
|
||||
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||
WHERE ai.active = 1 AND ar.active = 1
|
||||
ORDER BY ar.id, ap.monthly_price ASC
|
||||
`;
|
||||
|
||||
// 2. GPU 인스턴스 쿼리 - Linode(Tokyo) + Vultr(Seoul)
|
||||
const gpuSql = `
|
||||
// 트래픽 가격 조회
|
||||
const transferSql = `
|
||||
SELECT
|
||||
gi.instance_id,
|
||||
gi.instance_name,
|
||||
gi.vcpu,
|
||||
gi.memory_mb,
|
||||
gi.storage_gb,
|
||||
gi.transfer_tb,
|
||||
p.name as provider_name,
|
||||
r.region_name,
|
||||
r.region_code,
|
||||
gp.monthly_price,
|
||||
gp.hourly_price,
|
||||
gp.monthly_price_krw,
|
||||
gp.hourly_price_krw,
|
||||
1 as has_gpu,
|
||||
gi.gpu_count,
|
||||
gi.gpu_type
|
||||
FROM gpu_instances gi
|
||||
JOIN providers p ON gi.provider_id = p.id
|
||||
JOIN gpu_pricing gp ON gi.id = gp.gpu_instance_id
|
||||
JOIN regions r ON gp.region_id = r.id
|
||||
WHERE gp.available = 1
|
||||
AND (
|
||||
(p.name = 'linode' AND (
|
||||
r.region_name LIKE '%Tokyo%' OR
|
||||
r.region_name LIKE '%Osaka%' OR
|
||||
r.region_code LIKE '%jp-osa%'
|
||||
))
|
||||
OR
|
||||
(p.name = 'vultr' AND (
|
||||
r.region_name LIKE '%Seoul%' OR
|
||||
r.region_code LIKE '%icn%'
|
||||
))
|
||||
)
|
||||
ORDER BY gp.monthly_price ASC
|
||||
ar.name as region_name,
|
||||
ar.display_name as region_display_name,
|
||||
atp.price_per_gb
|
||||
FROM anvil_transfer_pricing atp
|
||||
JOIN anvil_regions ar ON atp.anvil_region_id = ar.id
|
||||
WHERE ar.active = 1
|
||||
`;
|
||||
|
||||
const [regularResult, gpuResult] = await Promise.all([
|
||||
env.DB.prepare(regularSql).all<InstanceRow>(),
|
||||
env.DB.prepare(gpuSql).all<InstanceRow>(),
|
||||
const [pricingResult, transferResult] = await Promise.all([
|
||||
env.DB.prepare(pricingSql).all<AnvilPricingRow>(),
|
||||
env.DB.prepare(transferSql).all<TransferPricingRow>(),
|
||||
]);
|
||||
|
||||
// Transform to frontend format
|
||||
const transformRow = (row: InstanceRow) => ({
|
||||
id: row.instance_id,
|
||||
// 인스턴스 변환 (프론트엔드 형식)
|
||||
const instances = pricingResult.results.map((row) => ({
|
||||
id: row.instance_name,
|
||||
instance_name: row.instance_name,
|
||||
vcpu: row.vcpu,
|
||||
memory_mb: row.memory_mb,
|
||||
storage_gb: row.storage_gb,
|
||||
display_name: row.display_name,
|
||||
category: row.category,
|
||||
vcpu: row.vcpus,
|
||||
memory_mb: Math.round(row.memory_gb * 1024),
|
||||
memory_gb: row.memory_gb,
|
||||
storage_gb: row.disk_gb,
|
||||
transfer_tb: row.transfer_tb,
|
||||
provider: { name: row.provider_name },
|
||||
network_gbps: row.network_gbps,
|
||||
gpu_model: row.gpu_model,
|
||||
gpu_vram_gb: row.gpu_vram_gb,
|
||||
has_gpu: row.category === 'gpu' || row.gpu_model !== null,
|
||||
region: {
|
||||
region_name: row.region_name,
|
||||
region_code: row.region_code
|
||||
id: row.region_id,
|
||||
name: row.region_name,
|
||||
display_name: row.region_display_name,
|
||||
country_code: row.country_code,
|
||||
},
|
||||
provider: row.source_provider,
|
||||
pricing: {
|
||||
monthly_price: row.monthly_price,
|
||||
hourly_price: row.hourly_price,
|
||||
monthly_price_krw: row.monthly_price_krw,
|
||||
hourly_price_krw: row.hourly_price_krw,
|
||||
monthly_price: row.monthly_price,
|
||||
hourly_price_krw: Math.round(row.hourly_price * USD_TO_KRW), // 1원 단위 반올림
|
||||
monthly_price_krw: Math.round(row.monthly_price * USD_TO_KRW / 500) * 500, // 500원 단위 반올림
|
||||
},
|
||||
has_gpu: row.has_gpu === 1,
|
||||
gpu_count: row.gpu_count || 0,
|
||||
gpu_type: row.gpu_type || null,
|
||||
});
|
||||
}));
|
||||
|
||||
const regularInstances = regularResult.results.map(transformRow);
|
||||
const gpuInstances = gpuResult.results.map(transformRow);
|
||||
const instances = [...regularInstances, ...gpuInstances];
|
||||
// 트래픽 가격 변환 (리전별) - 1원 단위 반올림
|
||||
const transferPricing = transferResult.results.reduce((acc, row) => {
|
||||
acc[row.region_display_name] = {
|
||||
price_per_gb: row.price_per_gb,
|
||||
price_per_gb_krw: Math.round(row.price_per_gb * USD_TO_KRW),
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, { price_per_gb: number; price_per_gb_krw: number }>);
|
||||
|
||||
// Count by region
|
||||
// 리전별 카운트
|
||||
const regionCounts: Record<string, number> = {};
|
||||
for (const inst of instances) {
|
||||
const name = inst.region.region_name.toLowerCase();
|
||||
if (name.includes('tokyo')) regionCounts['tokyo'] = (regionCounts['tokyo'] || 0) + 1;
|
||||
else if (name.includes('osaka')) regionCounts['osaka'] = (regionCounts['osaka'] || 0) + 1;
|
||||
else if (name.includes('singapore')) regionCounts['singapore'] = (regionCounts['singapore'] || 0) + 1;
|
||||
else if (name.includes('seoul')) regionCounts['seoul'] = (regionCounts['seoul'] || 0) + 1;
|
||||
const regionKey = inst.region.display_name.toLowerCase().replace(/\s+/g, '-');
|
||||
regionCounts[regionKey] = (regionCounts[regionKey] || 0) + 1;
|
||||
}
|
||||
|
||||
// GPU counts
|
||||
const gpuCounts = {
|
||||
'gpu-japan': gpuInstances.filter(i =>
|
||||
i.region.region_name.toLowerCase().includes('tokyo') ||
|
||||
i.region.region_name.toLowerCase().includes('osaka')
|
||||
).length,
|
||||
'gpu-korea': gpuInstances.filter(i =>
|
||||
i.region.region_name.toLowerCase().includes('seoul')
|
||||
).length,
|
||||
};
|
||||
// 사용 가능한 리전 목록 (순서 유지: Tokyo 1,2,3 → Osaka 1,2 → Seoul 1 → Singapore 1)
|
||||
const seenRegions = new Set<number>();
|
||||
const regions = instances
|
||||
.filter((i) => {
|
||||
if (seenRegions.has(i.region.id)) return false;
|
||||
seenRegions.add(i.region.id);
|
||||
return true;
|
||||
})
|
||||
.map((i) => ({
|
||||
id: i.region.display_name.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: i.region.display_name,
|
||||
country_code: i.region.country_code,
|
||||
count: regionCounts[i.region.display_name.toLowerCase().replace(/\s+/g, '-')] || 0,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
total: instances.length,
|
||||
region_counts: regionCounts,
|
||||
gpu_counts: gpuCounts,
|
||||
instances,
|
||||
}), {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=300', // 5분 캐시
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
total: instances.length,
|
||||
exchange_rate: {
|
||||
usd_to_krw: USD_TO_KRW,
|
||||
source: exchangeRate.source,
|
||||
},
|
||||
region_counts: regionCounts,
|
||||
regions,
|
||||
transfer_pricing: transferPricing,
|
||||
instances,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=300', // 5분 캐시
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Pricing] D1 query error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Database query failed',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Database query failed',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user