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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
348
index.html
348
index.html
@@ -176,51 +176,132 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specs Section -->
|
||||
<div class="grid lg:grid-cols-2 gap-12 z-10 items-start border-t border-terminal-border/50 pt-12" id="pricing">
|
||||
<div class="flex flex-col gap-6">
|
||||
<h2 class="text-2xl font-display font-bold text-white flex items-center gap-2">
|
||||
<span class="text-terminal-amber">$</span> cat /etc/server-specs.json
|
||||
</h2>
|
||||
<ul class="text-terminal-muted space-y-3">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-primary">✓</span> NVMe Gen 4 스토리지
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-primary">✓</span> 25Gbps 프라이빗 네트워크
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-primary">✓</span> AMD EPYC / Intel Xeon
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-primary">✓</span> 실시간 스냅샷 & 백업
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-primary">✓</span> IPv4 + IPv6 듀얼스택
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Pricing Section -->
|
||||
<div class="z-10 border-t border-terminal-border/50 pt-12" id="pricing" x-data="pricingTerminal()">
|
||||
<!-- Section Header -->
|
||||
<div class="flex items-center gap-2 text-sm text-terminal-muted mb-6">
|
||||
<span class="text-primary">$</span> kubectl get instances --all-regions
|
||||
</div>
|
||||
|
||||
<!-- JSON Code Block -->
|
||||
<div class="w-full">
|
||||
<div class="rounded-lg border border-terminal-border bg-terminal-bg overflow-hidden shadow-2xl text-sm leading-relaxed">
|
||||
<div class="flex border-b border-terminal-border bg-[#161b22] px-4 py-2 text-xs text-terminal-muted justify-between">
|
||||
<span>pricing.json</span>
|
||||
<span class="text-terminal-amber uppercase text-[10px] font-bold tracking-widest">Global Region</span>
|
||||
</div>
|
||||
<div class="p-4 overflow-x-auto">
|
||||
<div class="table w-full">
|
||||
<div class="table-row"><div class="table-cell line-num">1</div><div class="table-cell"><span class="text-terminal-amber">{</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">2</div><div class="table-cell pl-4"><span class="syntax-key">"plan"</span><span class="syntax-colon">:</span> <span class="syntax-string">"Starter"</span><span class="syntax-colon">,</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">3</div><div class="table-cell pl-4"><span class="syntax-key">"cpu"</span><span class="syntax-colon">:</span> <span class="syntax-number">1</span><span class="syntax-colon">,</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">4</div><div class="table-cell pl-4"><span class="syntax-key">"memory"</span><span class="syntax-colon">:</span> <span class="syntax-string">"1GB"</span><span class="syntax-colon">,</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">5</div><div class="table-cell pl-4"><span class="syntax-key">"storage"</span><span class="syntax-colon">:</span> <span class="syntax-string">"25GB NVMe"</span><span class="syntax-colon">,</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">6</div><div class="table-cell pl-4"><span class="syntax-key">"bandwidth"</span><span class="syntax-colon">:</span> <span class="syntax-string">"1TB"</span><span class="syntax-colon">,</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">7</div><div class="table-cell pl-4"><span class="syntax-key">"price_monthly"</span><span class="syntax-colon">:</span> <span class="syntax-string">"$5"</span><span class="syntax-colon">,</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">8</div><div class="table-cell pl-4"><span class="syntax-key">"regions"</span><span class="syntax-colon">:</span> <span class="text-terminal-amber">[</span><span class="syntax-string">"tokyo"</span>, <span class="syntax-string">"seoul"</span>, <span class="syntax-string">"singapore"</span><span class="text-terminal-amber">]</span></div></div>
|
||||
<div class="table-row"><div class="table-cell line-num">9</div><div class="table-cell"><span class="text-terminal-amber">}</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Region Tabs -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<template x-for="region in regions" :key="region.id">
|
||||
<button
|
||||
@click="selectedRegion = region.id; filterInstances()"
|
||||
:class="selectedRegion === region.id ? 'bg-primary text-background-dark' : 'bg-terminal-bg text-terminal-text hover:bg-terminal-border'"
|
||||
class="px-3 py-1.5 rounded text-xs font-bold transition-colors border border-terminal-border flex items-center gap-2"
|
||||
>
|
||||
<span x-text="region.flag"></span>
|
||||
<span x-text="region.name"></span>
|
||||
<span class="opacity-60" x-text="'(' + getRegionCount(region.id) + ')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Command Line -->
|
||||
<div class="bg-terminal-bg border border-terminal-border rounded-t-lg px-4 py-2 text-xs text-terminal-muted flex items-center justify-between">
|
||||
<span>
|
||||
<span class="text-primary">$</span> kubectl get instances --region=<span class="text-terminal-cyan" x-text="selectedRegion"></span> --sort-by=<span class="text-terminal-amber" x-text="sortBy"></span>
|
||||
</span>
|
||||
<span x-show="loading" class="text-terminal-amber animate-pulse">fetching...</span>
|
||||
<span x-show="!loading && lastUpdate" class="text-terminal-muted" x-text="'synced ' + getLastUpdateText()"></span>
|
||||
</div>
|
||||
|
||||
<!-- Instances Table -->
|
||||
<div class="bg-terminal-bg border-x border-b border-terminal-border rounded-b-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-terminal-border bg-[#161b22] text-left">
|
||||
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white" @click="toggleSort('name')">
|
||||
NAME <span x-show="sortBy === 'name'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-center" @click="toggleSort('vcpu')">
|
||||
CPU <span x-show="sortBy === 'vcpu'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-center" @click="toggleSort('memory')">
|
||||
RAM <span x-show="sortBy === 'memory'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-bold text-terminal-muted text-center hidden sm:table-cell">DISK</th>
|
||||
<th class="px-4 py-3 font-bold text-terminal-muted text-center hidden md:table-cell">TRAFFIC</th>
|
||||
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-right" @click="toggleSort('price')">
|
||||
PRICE/MO <span x-show="sortBy === 'price'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Loading State -->
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-terminal-muted">
|
||||
<span class="animate-pulse">Fetching instances from API...</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="!loading && filteredInstances.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-terminal-muted">
|
||||
No instances found for this region.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Instance Rows -->
|
||||
<template x-for="(inst, index) in filteredInstances" :key="inst.id + '-' + inst.region?.display_name">
|
||||
<tr class="border-b border-terminal-border/50 hover:bg-terminal-border/20 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-terminal-cyan" x-text="inst.id || inst.instance_name"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="text-terminal-amber" x-text="inst.vcpu"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="text-primary" x-text="formatMemory(inst.memory_mb)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-terminal-muted hidden sm:table-cell" x-text="inst.storage_gb + 'GB'"></td>
|
||||
<td class="px-4 py-3 text-center text-terminal-muted hidden md:table-cell" x-text="inst.transfer_tb ? inst.transfer_tb + 'TB' : '-'"></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<span class="text-white font-bold" x-text="'$' + inst.pricing?.monthly_price?.toFixed(0)"></span>
|
||||
<span class="text-terminal-muted text-xs block" x-text="'₩' + (inst.pricing?.monthly_price_krw || 0).toLocaleString()"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Table Footer -->
|
||||
<div class="px-4 py-3 border-t border-terminal-border bg-[#161b22] text-xs text-terminal-muted flex justify-between items-center">
|
||||
<span>
|
||||
<span class="text-primary" x-text="filteredInstances.length"></span> instances
|
||||
<span x-show="fromCache" class="text-terminal-amber ml-2">(cached)</span>
|
||||
</span>
|
||||
<button @click="forceRefresh()" class="hover:text-primary transition-colors flex items-center gap-1" :disabled="loading">
|
||||
<span class="material-symbols-outlined text-sm" :class="loading && 'animate-spin'">refresh</span>
|
||||
refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Specs -->
|
||||
<div class="mt-8 grid md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
|
||||
<div class="text-terminal-muted text-xs mb-1"># Storage</div>
|
||||
<div class="text-white font-bold">NVMe Gen 4</div>
|
||||
</div>
|
||||
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
|
||||
<div class="text-terminal-muted text-xs mb-1"># Network</div>
|
||||
<div class="text-white font-bold">25Gbps Private</div>
|
||||
</div>
|
||||
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
|
||||
<div class="text-terminal-muted text-xs mb-1"># CPU</div>
|
||||
<div class="text-white font-bold">AMD EPYC / Xeon</div>
|
||||
</div>
|
||||
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
|
||||
<div class="text-terminal-muted text-xs mb-1"># IP</div>
|
||||
<div class="text-white font-bold">IPv4 + IPv6</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,5 +350,186 @@
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
function pricingTerminal() {
|
||||
return {
|
||||
// State
|
||||
instances: [],
|
||||
filteredInstances: [],
|
||||
selectedRegion: 'tokyo-1',
|
||||
sortBy: 'price',
|
||||
sortOrder: 'asc',
|
||||
loading: false,
|
||||
lastUpdate: null,
|
||||
fromCache: false,
|
||||
|
||||
// Region definitions
|
||||
regions: [
|
||||
{ id: 'tokyo-1', name: 'Tokyo 1', flag: '🇯🇵', filter: ['tokyo 1'] },
|
||||
{ id: 'tokyo-2', name: 'Tokyo 2', flag: '🇯🇵', filter: ['tokyo 2'] },
|
||||
{ id: 'tokyo-3', name: 'Tokyo 3', flag: '🇯🇵', filter: ['tokyo 3'] },
|
||||
{ id: 'osaka-1', name: 'Osaka 1', flag: '🇯🇵', filter: ['osaka 1'] },
|
||||
{ id: 'osaka-2', name: 'Osaka 2', flag: '🇯🇵', filter: ['osaka 2'] },
|
||||
{ id: 'seoul-1', name: 'Seoul 1', flag: '🇰🇷', filter: ['seoul'] },
|
||||
{ id: 'singapore-1', name: 'Singapore 1', flag: '🇸🇬', filter: ['singapore'] },
|
||||
],
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
await this.loadInstances();
|
||||
},
|
||||
|
||||
// Load from API
|
||||
async loadInstances() {
|
||||
// Check cache first
|
||||
const cached = this.getCache();
|
||||
if (cached) {
|
||||
this.instances = cached.instances;
|
||||
this.lastUpdate = new Date(cached.timestamp);
|
||||
this.fromCache = true;
|
||||
this.filterInstances();
|
||||
return;
|
||||
}
|
||||
await this.fetchFromApi();
|
||||
},
|
||||
|
||||
// Fetch from API
|
||||
async fetchFromApi() {
|
||||
this.loading = true;
|
||||
this.fromCache = false;
|
||||
try {
|
||||
const res = await fetch('/api/pricing');
|
||||
const data = await res.json();
|
||||
if (data.success && data.instances) {
|
||||
this.instances = data.instances;
|
||||
this.setCache(data.instances);
|
||||
this.lastUpdate = new Date();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Pricing] Fetch error:', e);
|
||||
// Use fallback data
|
||||
this.instances = this.getFallbackData();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.filterInstances();
|
||||
}
|
||||
},
|
||||
|
||||
// Filter instances by region
|
||||
filterInstances() {
|
||||
const region = this.regions.find(r => r.id === this.selectedRegion);
|
||||
if (!region) return;
|
||||
|
||||
this.filteredInstances = this.instances.filter(inst => {
|
||||
const displayName = (inst.region?.display_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
||||
// GPU 제외, 리전 필터 매칭
|
||||
const matchesFilter = region.filter.some(f => displayName.includes(f));
|
||||
return matchesFilter && !hasGpu;
|
||||
});
|
||||
|
||||
// Sort
|
||||
this.sortInstances();
|
||||
},
|
||||
|
||||
// Sort instances
|
||||
sortInstances() {
|
||||
const order = this.sortOrder === 'asc' ? 1 : -1;
|
||||
this.filteredInstances.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);
|
||||
break;
|
||||
case 'memory':
|
||||
diff = (a.memory_mb || 0) - (b.memory_mb || 0);
|
||||
break;
|
||||
case 'name':
|
||||
diff = (a.id || '').localeCompare(b.id || '');
|
||||
break;
|
||||
}
|
||||
return diff * order;
|
||||
});
|
||||
},
|
||||
|
||||
// Toggle sort
|
||||
toggleSort(column) {
|
||||
if (this.sortBy === column) {
|
||||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortBy = column;
|
||||
this.sortOrder = column === 'price' ? 'asc' : 'desc';
|
||||
}
|
||||
this.sortInstances();
|
||||
},
|
||||
|
||||
// Get region count
|
||||
getRegionCount(regionId) {
|
||||
const region = this.regions.find(r => r.id === regionId);
|
||||
if (!region) return 0;
|
||||
return this.instances.filter(inst => {
|
||||
const displayName = (inst.region?.display_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
||||
const matchesFilter = region.filter.some(f => displayName.includes(f));
|
||||
return matchesFilter && !hasGpu;
|
||||
}).length;
|
||||
},
|
||||
|
||||
// Format memory
|
||||
formatMemory(mb) {
|
||||
if (!mb) return '-';
|
||||
return mb >= 1024 ? (mb / 1024) + 'GB' : mb + 'MB';
|
||||
},
|
||||
|
||||
// Last update text
|
||||
getLastUpdateText() {
|
||||
if (!this.lastUpdate) return '';
|
||||
const diff = Math.floor((Date.now() - this.lastUpdate) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
return this.lastUpdate.toLocaleTimeString();
|
||||
},
|
||||
|
||||
// Force refresh
|
||||
async forceRefresh() {
|
||||
localStorage.removeItem('anvil_pricing_cache_v6');
|
||||
await this.fetchFromApi();
|
||||
},
|
||||
|
||||
// Cache helpers
|
||||
getCache() {
|
||||
try {
|
||||
const data = localStorage.getItem('anvil_pricing_cache_v6');
|
||||
if (!data) return null;
|
||||
const parsed = JSON.parse(data);
|
||||
if (Date.now() - parsed.timestamp > 3600000) return null; // 1h TTL
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
},
|
||||
|
||||
setCache(instances) {
|
||||
try {
|
||||
localStorage.setItem('anvil_pricing_cache_v6', JSON.stringify({
|
||||
instances, timestamp: Date.now()
|
||||
}));
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// Fallback data
|
||||
getFallbackData() {
|
||||
return [
|
||||
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
|
||||
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
|
||||
{ id: 'anvil-4g-2c', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 24, monthly_price_krw: 34000 } },
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
228
js/pricing.js
228
js/pricing.js
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pricing Table Component
|
||||
* API 연동 가격표 (아시아 전용, 캐싱 적용)
|
||||
* Anvil 가격표 (D1 직접 조회)
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -8,32 +8,27 @@
|
||||
*/
|
||||
export function pricingTable() {
|
||||
// 캐시 키 및 유효 시간 (1시간)
|
||||
const CACHE_KEY = 'anvil_pricing_cache_v3'; // v3: Global 탭 통합 (도쿄/오사카/싱가폴)
|
||||
const CACHE_KEY = 'anvil_pricing_cache_v6'; // v6: D1 API 형식 호환
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1시간
|
||||
|
||||
// Fallback 데이터 (API 실패 시 사용) - Linode: Tokyo R1/R2/Osaka/Singapore, Vultr: Seoul
|
||||
// Fallback 데이터 (API 실패 시 사용)
|
||||
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 } },
|
||||
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
|
||||
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
|
||||
{ id: 'anvil-4g-2c', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 24, monthly_price_krw: 34000 } },
|
||||
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, category: 'vm', provider: 'vultr', region: { display_name: 'Seoul 1', country_code: 'KR' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
|
||||
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, category: 'vm', provider: 'vultr', region: { display_name: 'Seoul 1', country_code: 'KR' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
|
||||
];
|
||||
|
||||
return {
|
||||
// 필터 상태
|
||||
sortBy: 'vcpu', // 'vcpu' | 'memory' | 'price'
|
||||
sortBy: 'price', // 'vcpu' | 'memory' | 'price'
|
||||
sortOrder: 'asc', // 'asc' | 'desc'
|
||||
selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea'
|
||||
selectedSeoulType: 'vc2', // 'vc2' | 'vhf' (서울 서브탭)
|
||||
selectedGlobalType: 'standard', // 'standard' | 'dedicated' | 'premium' (글로벌 서브탭)
|
||||
selectedSeoulType: 'all', // 'all' (서브탭 단순화)
|
||||
selectedGlobalType: 'all', // 'all' (서브탭 단순화)
|
||||
|
||||
// 도시 목록: Linode(도쿄/오사카/싱가폴 동일가격), Seoul(Vultr), GPU
|
||||
// 도시 목록
|
||||
cities: [
|
||||
{ id: 'global', name: '도쿄/오사카/싱가폴', flag: '🌏', provider: 'linode' },
|
||||
{ id: 'seoul', name: 'Seoul', flag: '🇰🇷', provider: 'vultr' },
|
||||
@@ -41,21 +36,14 @@ export function pricingTable() {
|
||||
{ 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데이터베이스, 캐싱, 분석 워크로드' },
|
||||
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\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, 인메모리 캐시에 적합' },
|
||||
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n서울 리전' },
|
||||
],
|
||||
|
||||
// 데이터 상태
|
||||
@@ -66,9 +54,6 @@ export function pricingTable() {
|
||||
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();
|
||||
@@ -127,14 +112,13 @@ export function pricingTable() {
|
||||
}
|
||||
},
|
||||
|
||||
// API에서 인스턴스 가져오기 (D1 직접 조회 - rate limit 없음)
|
||||
// API에서 인스턴스 가져오기
|
||||
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();
|
||||
|
||||
@@ -147,14 +131,11 @@ export function pricingTable() {
|
||||
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)');
|
||||
console.log('[PricingTable] Loaded', this.instances.length, 'instances');
|
||||
} else {
|
||||
throw new Error('인스턴스 데이터가 없습니다.');
|
||||
}
|
||||
@@ -174,7 +155,7 @@ export function pricingTable() {
|
||||
console.log('[PricingTable] Using expired cache as fallback');
|
||||
return;
|
||||
} catch (e) {
|
||||
// 캐시 파싱 실패, fallback 사용
|
||||
// 캐시 파싱 실패
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +170,10 @@ export function pricingTable() {
|
||||
}
|
||||
},
|
||||
|
||||
// 필터 및 정렬 적용 (모든 인스턴스 표시)
|
||||
// 필터 및 정렬 적용
|
||||
filterAndSort(instances) {
|
||||
let filtered = [...instances];
|
||||
|
||||
// 정렬 적용
|
||||
filtered.sort((a, b) => {
|
||||
switch (this.sortBy) {
|
||||
case 'price':
|
||||
@@ -210,30 +190,27 @@ export function pricingTable() {
|
||||
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();
|
||||
// 리전 이름에서 도시 추출
|
||||
getRegionCity(displayName) {
|
||||
const name = (displayName || '').toLowerCase();
|
||||
if (name.includes('tokyo')) return 'tokyo';
|
||||
if (name.includes('osaka')) return 'osaka';
|
||||
if (name.includes('singapore')) return 'singapore';
|
||||
if (name.includes('seoul')) return 'seoul';
|
||||
return name;
|
||||
},
|
||||
|
||||
// 필터 변경 핸들러 (도시 필터 + 정렬 적용)
|
||||
// 필터 변경 핸들러
|
||||
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();
|
||||
@@ -243,41 +220,33 @@ export function pricingTable() {
|
||||
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;
|
||||
const displayName = (inst.region?.display_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
||||
|
||||
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;
|
||||
}
|
||||
// Global: 도쿄/오사카/싱가폴 (GPU 제외)
|
||||
const isGlobal = displayName.includes('tokyo') ||
|
||||
displayName.includes('osaka') ||
|
||||
displayName.includes('singapore');
|
||||
return isGlobal && !hasGpu;
|
||||
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;
|
||||
// 서울 (GPU 제외)
|
||||
return displayName.includes('seoul') && !hasGpu;
|
||||
case 'gpu-japan':
|
||||
// 일본 GPU (도쿄만, 중복 방지)
|
||||
return regionName.includes('tokyo 2') && hasGpu;
|
||||
// 일본 GPU
|
||||
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
|
||||
case 'gpu-korea':
|
||||
// 한국 GPU
|
||||
return regionName.includes('seoul') && hasGpu;
|
||||
default: return false;
|
||||
return displayName.includes('seoul') && hasGpu;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 정렬 (sortOrder 반영, 보조 정렬: 가격)
|
||||
// 정렬
|
||||
const order = this.sortOrder === 'asc' ? 1 : -1;
|
||||
filtered.sort((a, b) => {
|
||||
let diff = 0;
|
||||
@@ -287,15 +256,11 @@ export function pricingTable() {
|
||||
break;
|
||||
case 'vcpu':
|
||||
diff = (a.vcpu || 0) - (b.vcpu || 0);
|
||||
if (diff === 0) {
|
||||
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 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);
|
||||
}
|
||||
if (diff === 0) diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
|
||||
break;
|
||||
default:
|
||||
diff = (a.vcpu || 0) - (b.vcpu || 0);
|
||||
@@ -309,49 +274,55 @@ export function pricingTable() {
|
||||
// 도시별 인스턴스 수 조회
|
||||
getInstanceCountByCity(cityId) {
|
||||
return this.instances.filter(inst => {
|
||||
const regionName = (inst.region?.region_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.gpu_count > 0;
|
||||
const displayName = (inst.region?.display_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
||||
|
||||
switch (cityId) {
|
||||
case 'global':
|
||||
return regionName.includes('tokyo 2') && !hasGpu;
|
||||
const isGlobal = displayName.includes('tokyo') ||
|
||||
displayName.includes('osaka') ||
|
||||
displayName.includes('singapore');
|
||||
return isGlobal && !hasGpu;
|
||||
case 'seoul':
|
||||
return regionName.includes('seoul') && !hasGpu;
|
||||
return displayName.includes('seoul') && !hasGpu;
|
||||
case 'gpu-japan':
|
||||
return regionName.includes('tokyo 2') && hasGpu;
|
||||
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
|
||||
case 'gpu-korea':
|
||||
return regionName.includes('seoul') && hasGpu;
|
||||
default: return false;
|
||||
return displayName.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;
|
||||
if (typeId === 'all') {
|
||||
return this.instances.filter(inst => {
|
||||
const displayName = (inst.region?.display_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
||||
return displayName.includes('seoul') && !hasGpu;
|
||||
}).length;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// 글로벌 서브탭별 인스턴스 수 조회
|
||||
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;
|
||||
if (typeId === 'all') {
|
||||
return this.instances.filter(inst => {
|
||||
const displayName = (inst.region?.display_name || '').toLowerCase();
|
||||
const hasGpu = inst.has_gpu || inst.category === 'gpu';
|
||||
const isGlobal = displayName.includes('tokyo') ||
|
||||
displayName.includes('osaka') ||
|
||||
displayName.includes('singapore');
|
||||
return isGlobal && !hasGpu;
|
||||
}).length;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// 강제 새로고침 (캐시 삭제 후 API 호출)
|
||||
// 강제 새로고침
|
||||
async forceRefresh() {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
await this.fetchFromApi();
|
||||
@@ -363,13 +334,13 @@ export function pricingTable() {
|
||||
return '$' + price.toFixed(2);
|
||||
},
|
||||
|
||||
// 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
|
||||
// 가격 포맷 (KRW)
|
||||
formatKrw(krwPrice) {
|
||||
if (krwPrice == null) return '-';
|
||||
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
|
||||
},
|
||||
|
||||
// 시간당 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
|
||||
// 시간당 가격 포맷 (KRW)
|
||||
formatKrwHourly(krwPrice) {
|
||||
if (krwPrice == null) return '-';
|
||||
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
|
||||
@@ -377,6 +348,7 @@ export function pricingTable() {
|
||||
|
||||
// 메모리 포맷
|
||||
formatMemory(mb) {
|
||||
if (mb == null) return '-';
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
|
||||
}
|
||||
@@ -401,32 +373,12 @@ export function pricingTable() {
|
||||
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();
|
||||
@@ -448,19 +400,14 @@ export function pricingTable() {
|
||||
|
||||
// 인스턴스 선택 상태
|
||||
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?.region?.display_name === inst.region?.display_name) {
|
||||
this.selectedInstance = null;
|
||||
this.showInstanceModal = false;
|
||||
this.selectedInstanceDetail = null;
|
||||
@@ -493,10 +440,11 @@ export function pricingTable() {
|
||||
});
|
||||
},
|
||||
|
||||
// 텔레그램 링크 가져오기
|
||||
// 텔레그램 링크
|
||||
getInstanceTelegramLink() {
|
||||
if (!this.selectedInstanceDetail) return '#';
|
||||
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}`;
|
||||
const region = this.selectedInstanceDetail.region?.display_name || '';
|
||||
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}_${encodeURIComponent(region.toLowerCase().replace(/\s+/g, '-'))}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user