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:
kappa
2026-01-30 07:38:27 +09:00
parent a4e9dc2dd8
commit f3dd6c8d5a
4 changed files with 570 additions and 338 deletions

View File

@@ -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',
},
}
);
}
};