- 5 rotating promotional commands with typing effect - Progress bar and navigation dots - Messages: instant deploy, DDoS protection, pricing, Telegram bot, regions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
680 lines
30 KiB
HTML
680 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html class="dark scroll-smooth" lang="ko">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
<title>Anvil Hosting - 개발자를 위한 컨테이너 클라우드</title>
|
|
|
|
<!-- SEO Meta Tags -->
|
|
<meta name="description" content="Incus/LXD 기반 초경량 컨테이너 호스팅. VM 오버헤드 없이 네이티브 성능을 제공하며, n8n, Ansible 자동화 파이프라인과 즉시 연동됩니다.">
|
|
<meta name="keywords" content="컨테이너 호스팅, LXD, Incus, 클라우드 서버, VPS, 도쿄 서버, 서울 서버, 개발자 호스팅">
|
|
<meta name="author" content="Anvil Hosting">
|
|
<meta name="robots" content="index, follow">
|
|
<link rel="canonical" href="https://hosting.anvil.it.com">
|
|
|
|
<!-- Open Graph -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://hosting.anvil.it.com">
|
|
<meta property="og:title" content="Anvil Hosting - 개발자를 위한 컨테이너 클라우드">
|
|
<meta property="og:description" content="Incus/LXD 기반 초경량 컨테이너 호스팅. 네이티브 성능과 자동화 파이프라인을 경험하세요.">
|
|
<meta property="og:locale" content="ko_KR">
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
|
|
<!-- Fonts -->
|
|
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
|
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
|
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: "class",
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
"primary": "#3fb950",
|
|
"background-dark": "#0d1117",
|
|
"terminal-bg": "#010409",
|
|
"terminal-border": "#30363d",
|
|
"terminal-text": "#c9d1d9",
|
|
"terminal-muted": "#8b949e",
|
|
"terminal-cyan": "#58a6ff",
|
|
"terminal-amber": "#d29922",
|
|
"terminal-red": "#ff7b72",
|
|
"terminal-blue": "#79c0ff",
|
|
"terminal-purple": "#d2a8ff",
|
|
},
|
|
fontFamily: {
|
|
"display": ["Space Grotesk", "sans-serif"],
|
|
"mono": ["Fira Code", "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "monospace"],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
<style>
|
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
::-webkit-scrollbar-track { background: #0d1117; }
|
|
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 5px; }
|
|
::-webkit-scrollbar-thumb:hover { background: #8b949e; }
|
|
|
|
.cursor-blink { animation: blink 1s step-end infinite; }
|
|
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
|
|
|
.syntax-key { color: #58a6ff; }
|
|
.syntax-string { color: #3fb950; }
|
|
.syntax-number { color: #79c0ff; }
|
|
.syntax-colon { color: #c9d1d9; }
|
|
.line-num { color: #484f58; user-select: none; text-align: right; padding-right: 1rem; }
|
|
|
|
.typing-effect { overflow: hidden; white-space: nowrap; animation: typing 2s steps(40, end); }
|
|
@keyframes typing { from { width: 0 } to { width: 100% } }
|
|
</style>
|
|
</head>
|
|
<body class="bg-background-dark text-terminal-text font-mono min-h-screen flex flex-col overflow-x-hidden selection:bg-terminal-cyan selection:text-background-dark">
|
|
|
|
<div class="layout-container flex h-full grow flex-col items-center justify-center p-4 md:p-8 lg:p-12">
|
|
<!-- Main Terminal Window -->
|
|
<div class="w-full max-w-[1400px] bg-background-dark border border-terminal-border rounded-lg shadow-[0_0_50px_-12px_rgba(0,0,0,0.7)] overflow-hidden flex flex-col">
|
|
|
|
<!-- Window Title Bar -->
|
|
<div class="bg-[#161b22] px-4 py-3 flex items-center justify-between border-b border-terminal-border select-none">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-3 h-3 rounded-full bg-[#ff5f56]"></div>
|
|
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
|
|
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
|
|
</div>
|
|
<div class="flex items-center gap-2 opacity-70">
|
|
<span class="material-symbols-outlined text-[14px]">terminal</span>
|
|
<span class="text-xs md:text-sm text-terminal-muted font-display">root@anvil-cloud:~</span>
|
|
</div>
|
|
<a href="https://t.me/AnvilForgeBot" target="_blank" class="text-xs text-terminal-muted hover:text-primary transition flex items-center gap-1">
|
|
<span>@AnvilForgeBot</span>
|
|
<span class="material-symbols-outlined text-sm">open_in_new</span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Terminal Content -->
|
|
<div class="p-6 md:p-10 flex flex-col gap-12 relative bg-gradient-to-br from-background-dark to-[#0f141a]">
|
|
<!-- Background Grid -->
|
|
<div class="absolute inset-0 opacity-[0.03]" style="background-image: linear-gradient(#30363d 1px, transparent 1px), linear-gradient(90deg, #30363d 1px, transparent 1px); background-size: 40px 40px;"></div>
|
|
|
|
<!-- Prompt -->
|
|
<div class="flex flex-wrap items-center gap-2 text-sm z-10 font-bold">
|
|
<span class="text-primary">anvil@hosting</span>
|
|
<span class="text-terminal-muted">:</span>
|
|
<span class="text-terminal-blue">~</span>
|
|
<span class="text-terminal-muted">$</span>
|
|
<span class="text-terminal-text">cat README.md</span>
|
|
</div>
|
|
|
|
<!-- Hero Section -->
|
|
<div class="flex flex-col gap-6 z-10">
|
|
<!-- ASCII Art Logo -->
|
|
<pre class="text-primary font-bold leading-[0.9] text-[10px] sm:text-xs md:text-sm select-none">
|
|
_ _ ___ _____ _
|
|
/ \ | \ | \ \ / /_ _| |
|
|
/ _ \ | \| |\ \ / / | || |
|
|
/ ___ \| |\ | \ V / | || |___
|
|
/_/ \_\_| \_| \_/ |___|_____|
|
|
</pre>
|
|
|
|
<!-- Main Headline -->
|
|
<div class="space-y-4 max-w-4xl mt-4">
|
|
<h1 class="text-2xl md:text-4xl lg:text-5xl font-display font-bold text-white tracking-tight leading-tight">
|
|
<span class="text-terminal-muted mr-2">></span>Incus/LXD 기반<br/>
|
|
<span class="text-terminal-muted mr-2">></span>초경량 컨테이너 호스팅<span class="text-primary cursor-blink text-3xl md:text-5xl align-middle ml-1">▋</span>
|
|
</h1>
|
|
<p class="text-terminal-muted text-base md:text-lg max-w-2xl border-l-2 border-terminal-border pl-4 mt-4">
|
|
# VM 오버헤드 없이 네이티브 성능<br/>
|
|
# 하이퍼바이저 없는 순수 Linux 컨테이너<br/>
|
|
# 도쿄 · 서울 · 싱가포르 · 홍콩 리전
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Typing Carousel -->
|
|
<div class="w-full max-w-3xl mt-6" x-data="typingCarousel()">
|
|
<div class="bg-terminal-bg border border-terminal-border rounded-lg overflow-hidden shadow-lg">
|
|
<!-- Terminal Header -->
|
|
<div class="bg-[#161b22] px-4 py-2 border-b border-terminal-border flex items-center gap-2">
|
|
<span class="text-terminal-muted text-xs">anvil-cli v2.4.0</span>
|
|
<span class="ml-auto text-xs text-terminal-muted" x-text="(currentIndex + 1) + '/' + commands.length"></span>
|
|
</div>
|
|
<!-- Command Display -->
|
|
<div class="p-4 md:p-6 min-h-[120px]">
|
|
<div class="flex items-start gap-3">
|
|
<span class="text-primary font-bold select-none">$</span>
|
|
<div class="flex-1">
|
|
<div class="text-sm md:text-base flex flex-wrap items-center gap-1">
|
|
<span class="text-terminal-text" x-html="displayedCommand"></span>
|
|
<span class="text-primary cursor-blink text-lg" x-show="isTyping">▋</span>
|
|
</div>
|
|
<!-- Result Message -->
|
|
<div class="mt-4 text-terminal-muted text-sm border-l-2 border-primary/50 pl-3 transition-opacity duration-300"
|
|
:class="showResult ? 'opacity-100' : 'opacity-0'">
|
|
<span class="text-primary">✓</span>
|
|
<span x-text="commands[currentIndex]?.result"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Progress Bar -->
|
|
<div class="h-1 bg-terminal-border">
|
|
<div class="h-full bg-primary transition-all duration-100 ease-linear" :style="'width: ' + progress + '%'"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Navigation Dots -->
|
|
<div class="flex justify-center gap-2 mt-4">
|
|
<template x-for="(cmd, idx) in commands" :key="idx">
|
|
<button @click="goTo(idx)"
|
|
class="w-2 h-2 rounded-full transition-all"
|
|
:class="idx === currentIndex ? 'bg-primary w-6' : 'bg-terminal-border hover:bg-terminal-muted'"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Features Grid -->
|
|
<div class="grid md:grid-cols-3 gap-6 z-10 border-t border-terminal-border/50 pt-12" id="features">
|
|
<div class="bg-terminal-bg/50 border border-terminal-border rounded-lg p-6 hover:border-primary/50 transition-colors">
|
|
<div class="text-primary text-2xl mb-3">⚡</div>
|
|
<h3 class="font-display font-bold text-white mb-2">즉시 프로비저닝</h3>
|
|
<p class="text-terminal-muted text-sm">0.4초 내 인스턴스 생성. 하이퍼바이저 부팅 대기 없음.</p>
|
|
</div>
|
|
<div class="bg-terminal-bg/50 border border-terminal-border rounded-lg p-6 hover:border-terminal-cyan/50 transition-colors">
|
|
<div class="text-terminal-cyan text-2xl mb-3">🛡️</div>
|
|
<h3 class="font-display font-bold text-white mb-2">1Tbps+ DDoS 방어</h3>
|
|
<p class="text-terminal-muted text-sm">ForgeShield 통합 보호. L3/L4/L7 완전 방어.</p>
|
|
</div>
|
|
<div class="bg-terminal-bg/50 border border-terminal-border rounded-lg p-6 hover:border-terminal-amber/50 transition-colors">
|
|
<div class="text-terminal-amber text-2xl mb-3">🤖</div>
|
|
<h3 class="font-display font-bold text-white mb-2">텔레그램 봇 관리</h3>
|
|
<p class="text-terminal-muted text-sm">@AnvilForgeBot으로 서버 생성, 모니터링, 도메인 등록.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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-primary font-bold text-lg" x-text="'₩' + (inst.pricing?.monthly_price_krw || 0).toLocaleString()"></span>
|
|
<span class="text-terminal-muted text-xs block" x-text="'$' + inst.pricing?.monthly_price?.toFixed(2)"></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>
|
|
|
|
<!-- CTA Section -->
|
|
<div class="flex flex-col items-center justify-center py-12 z-10 gap-8 border-t border-terminal-border/50">
|
|
<div class="text-center space-y-2">
|
|
<h2 class="text-3xl font-display font-bold text-white">$ ./deploy --now</h2>
|
|
<p class="text-terminal-muted">텔레그램에서 바로 서버를 생성하세요</p>
|
|
</div>
|
|
|
|
<a href="https://t.me/AnvilForgeBot" target="_blank" class="group relative flex items-center justify-center">
|
|
<div class="absolute -inset-1 rounded-lg bg-gradient-to-r from-primary via-terminal-cyan to-primary opacity-75 blur transition duration-500 group-hover:opacity-100 animate-pulse"></div>
|
|
<div class="relative flex items-center bg-terminal-bg rounded-lg px-6 py-4 border border-primary">
|
|
<span class="text-primary font-bold mr-3">$</span>
|
|
<span class="text-terminal-text font-bold text-lg">curl -sSL t.me/AnvilForgeBot | start</span>
|
|
<span class="ml-4 material-symbols-outlined text-primary group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
|
</div>
|
|
</a>
|
|
|
|
<div class="text-xs text-terminal-muted flex gap-6">
|
|
<a class="hover:text-primary underline decoration-primary/30 underline-offset-4" href="/terms.html">--terms</a>
|
|
<a class="hover:text-primary underline decoration-primary/30 underline-offset-4" href="/privacy.html">--privacy</a>
|
|
<a class="hover:text-primary underline decoration-primary/30 underline-offset-4" href="/sla.html">--sla</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer / Vim Status Bar -->
|
|
<div class="bg-primary text-background-dark text-xs font-bold py-1 px-4 flex justify-between items-center select-none">
|
|
<div class="flex gap-4">
|
|
<span class="bg-background-dark/20 px-2 py-0.5 rounded text-white/90">NORMAL</span>
|
|
<span>index.html</span>
|
|
</div>
|
|
<div class="flex gap-4">
|
|
<span class="hidden sm:inline">utf-8</span>
|
|
<span class="hidden sm:inline">unix</span>
|
|
<span>100%</span>
|
|
<span>Ln 1, Col 1</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="mt-8 text-terminal-muted text-xs text-center opacity-50">
|
|
© 2024 Anvil Hosting. All containers running. PID 1 healthy.
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Alpine.js -->
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
|
<script>
|
|
function typingCarousel() {
|
|
return {
|
|
commands: [
|
|
{
|
|
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">deploy</span> <span class="text-terminal-cyan">--tokyo</span> <span class="text-terminal-amber">--instant</span>',
|
|
plain: 'anvil deploy --tokyo --instant',
|
|
result: '0.4초 만에 서버 생성 완료. VM 부팅 대기? 그런 건 없습니다.'
|
|
},
|
|
{
|
|
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">protect</span> <span class="text-terminal-cyan">--ddos</span> <span class="text-terminal-amber">1Tbps+</span>',
|
|
plain: 'anvil protect --ddos 1Tbps+',
|
|
result: 'ForgeShield 무료 제공. DDoS 걱정은 이제 그만.'
|
|
},
|
|
{
|
|
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">pricing</span> <span class="text-terminal-cyan">--monthly</span> <span class="text-terminal-amber">₩7,000~</span>',
|
|
plain: 'anvil pricing --monthly ₩7,000~',
|
|
result: '커피 두 잔 값으로 나만의 서버를. 연간 결제 시 17% 할인.'
|
|
},
|
|
{
|
|
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">connect</span> <span class="text-terminal-cyan">@AnvilForgeBot</span>',
|
|
plain: 'anvil connect @AnvilForgeBot',
|
|
result: '텔레그램으로 서버 관리. 24시간 어디서든 컨트롤.'
|
|
},
|
|
{
|
|
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">regions</span> <span class="text-terminal-cyan">--list</span>',
|
|
plain: 'anvil regions --list',
|
|
result: '도쿄 · 오사카 · 서울 · 싱가포르. 아시아 전역 커버.'
|
|
},
|
|
],
|
|
currentIndex: 0,
|
|
displayedCommand: '',
|
|
isTyping: true,
|
|
showResult: false,
|
|
progress: 0,
|
|
charIndex: 0,
|
|
intervalId: null,
|
|
progressId: null,
|
|
|
|
init() {
|
|
this.startTyping();
|
|
},
|
|
|
|
startTyping() {
|
|
this.isTyping = true;
|
|
this.showResult = false;
|
|
this.displayedCommand = '';
|
|
this.charIndex = 0;
|
|
this.progress = 0;
|
|
|
|
const plainText = this.commands[this.currentIndex].plain;
|
|
const htmlText = this.commands[this.currentIndex].text;
|
|
|
|
// Clear any existing intervals
|
|
if (this.intervalId) clearInterval(this.intervalId);
|
|
if (this.progressId) clearInterval(this.progressId);
|
|
|
|
// Typing effect
|
|
this.intervalId = setInterval(() => {
|
|
if (this.charIndex < plainText.length) {
|
|
this.charIndex++;
|
|
// Map plain text position to HTML
|
|
this.displayedCommand = this.getPartialHtml(htmlText, this.charIndex);
|
|
} else {
|
|
clearInterval(this.intervalId);
|
|
this.isTyping = false;
|
|
this.showResult = true;
|
|
this.startProgress();
|
|
}
|
|
}, 50);
|
|
},
|
|
|
|
getPartialHtml(html, charCount) {
|
|
let result = '';
|
|
let visibleChars = 0;
|
|
let inTag = false;
|
|
|
|
for (let i = 0; i < html.length && visibleChars < charCount; i++) {
|
|
if (html[i] === '<') inTag = true;
|
|
if (!inTag) visibleChars++;
|
|
result += html[i];
|
|
if (html[i] === '>') inTag = false;
|
|
}
|
|
|
|
// Close any open tags
|
|
const openTags = result.match(/<span[^>]*>/g) || [];
|
|
const closeTags = result.match(/<\/span>/g) || [];
|
|
const unclosed = openTags.length - closeTags.length;
|
|
for (let i = 0; i < unclosed; i++) {
|
|
result += '</span>';
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
startProgress() {
|
|
const duration = 4000; // 4 seconds display time
|
|
const interval = 50;
|
|
const step = (interval / duration) * 100;
|
|
|
|
this.progressId = setInterval(() => {
|
|
this.progress += step;
|
|
if (this.progress >= 100) {
|
|
clearInterval(this.progressId);
|
|
this.next();
|
|
}
|
|
}, interval);
|
|
},
|
|
|
|
next() {
|
|
this.currentIndex = (this.currentIndex + 1) % this.commands.length;
|
|
this.startTyping();
|
|
},
|
|
|
|
goTo(index) {
|
|
if (this.intervalId) clearInterval(this.intervalId);
|
|
if (this.progressId) clearInterval(this.progressId);
|
|
this.currentIndex = index;
|
|
this.startTyping();
|
|
}
|
|
};
|
|
}
|
|
|
|
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>
|