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

@@ -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>