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:
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>
|
||||
|
||||
Reference in New Issue
Block a user