834 lines
37 KiB
HTML
834 lines
37 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 - 엔지니어가 관리하는 Linux 서버</title>
|
|
|
|
<!-- SEO Meta Tags -->
|
|
<meta name="description" content="엔지니어가 직접 튜닝한 Linux 서버. 웹, DB, API, 게임서버 뭐든 돌립니다. 고객의 문제가 해결될 때까지, 우리는 멈추지 않습니다.">
|
|
<meta name="keywords" content="Linux 서버, 클라우드 서버, VPS, 도쿄 서버, 서울 서버, 관리형 호스팅, 서버 호스팅">
|
|
<meta name="author" content="Anvil Hosting">
|
|
<meta name="robots" content="index, follow">
|
|
<link rel="canonical" href="https://hosting.inouter.com">
|
|
|
|
<!-- Open Graph -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://hosting.inouter.com">
|
|
<meta property="og:title" content="Anvil Hosting - 엔지니어가 관리하는 Linux 서버">
|
|
<meta property="og:description" content="엔지니어가 직접 튜닝한 Linux 서버. 고객의 문제가 해결될 때까지, 우리는 멈추지 않습니다.">
|
|
<meta property="og:image" content="https://hosting.inouter.com/og-image.png">
|
|
<meta property="og:image:width" content="1200">
|
|
<meta property="og:image:height" content="630">
|
|
<meta property="og:image:alt" content="Anvil Hosting - 엔지니어가 관리하는 Linux 서버">
|
|
<meta property="og:locale" content="ko_KR">
|
|
|
|
<!-- Twitter Cards -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="Anvil Hosting - 엔지니어가 관리하는 Linux 서버">
|
|
<meta name="twitter:description" content="엔지니어가 직접 튜닝한 Linux 서버. 고객의 문제가 해결될 때까지, 우리는 멈추지 않습니다.">
|
|
<meta name="twitter:image" content="https://hosting.inouter.com/og-image.png">
|
|
|
|
<!-- 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 (prebuilt) -->
|
|
<link href="style.css" rel="stylesheet"/>
|
|
<style>
|
|
/* Custom colors for terminal theme */
|
|
:root {
|
|
--color-primary: #3fb950;
|
|
--color-background-dark: #0d1117;
|
|
--color-terminal-bg: #010409;
|
|
--color-terminal-border: #30363d;
|
|
--color-terminal-text: #c9d1d9;
|
|
--color-terminal-muted: #8b949e;
|
|
--color-terminal-cyan: #58a6ff;
|
|
--color-terminal-amber: #d29922;
|
|
--color-terminal-red: #ff7b72;
|
|
--color-terminal-blue: #79c0ff;
|
|
--color-terminal-purple: #d2a8ff;
|
|
}
|
|
|
|
.bg-background-dark { background-color: var(--color-background-dark); }
|
|
.bg-terminal-bg { background-color: var(--color-terminal-bg); }
|
|
.bg-terminal-bg\/50 { background-color: rgba(1, 4, 9, 0.5); }
|
|
.border-terminal-border { border-color: var(--color-terminal-border); }
|
|
.border-terminal-border\/50 { border-color: rgba(48, 54, 61, 0.5); }
|
|
.border-terminal-border\/20 { border-color: rgba(48, 54, 61, 0.2); }
|
|
.text-terminal-text { color: var(--color-terminal-text); }
|
|
.text-terminal-muted { color: var(--color-terminal-muted); }
|
|
.text-terminal-cyan { color: var(--color-terminal-cyan); }
|
|
.text-terminal-amber { color: var(--color-terminal-amber); }
|
|
.text-terminal-red { color: var(--color-terminal-red); }
|
|
.text-terminal-blue { color: var(--color-terminal-blue); }
|
|
.text-terminal-purple { color: var(--color-terminal-purple); }
|
|
.text-primary { color: var(--color-primary); }
|
|
.bg-primary { background-color: var(--color-primary); }
|
|
.border-primary { border-color: var(--color-primary); }
|
|
.border-primary\/50 { border-color: rgba(63, 185, 80, 0.5); }
|
|
.hover\:text-primary:hover { color: var(--color-primary); }
|
|
.hover\:border-primary\/50:hover { border-color: rgba(63, 185, 80, 0.5); }
|
|
.selection\:bg-terminal-cyan::selection { background-color: var(--color-terminal-cyan); }
|
|
.selection\:text-background-dark::selection { color: var(--color-background-dark); }
|
|
|
|
/* Font families */
|
|
.font-display { font-family: "Space Grotesk", sans-serif; }
|
|
.font-mono { font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
|
|
/* Accessibility - Screen Reader Only */
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border: 0;
|
|
}
|
|
.focus\:not-sr-only:focus {
|
|
position: absolute;
|
|
width: auto;
|
|
height: auto;
|
|
padding: 0;
|
|
margin: 0;
|
|
overflow: visible;
|
|
clip: auto;
|
|
white-space: normal;
|
|
}
|
|
</style>
|
|
<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% } }
|
|
|
|
/* Respect user's motion preferences */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.cursor-blink { animation: none; }
|
|
* {
|
|
animation-duration: 0.01ms !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
</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">
|
|
|
|
<!-- Skip Link for Accessibility -->
|
|
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-background-dark focus:rounded focus:outline-none">
|
|
메인 콘텐츠로 건너뛰기
|
|
</a>
|
|
|
|
<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" aria-hidden="true">
|
|
<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]" aria-hidden="true">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" aria-label="텔레그램 봇 @AnvilForgeBot 열기">
|
|
<span>@AnvilForgeBot</span>
|
|
<span class="material-symbols-outlined text-sm" aria-hidden="true">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" id="main-content">
|
|
<!-- ASCII Art Logo -->
|
|
<div class="flex items-end gap-3">
|
|
<pre aria-hidden="true" class="text-primary font-bold leading-[0.85] text-[8px] sm:text-[10px] md:text-xs select-none"> █████╗ ███╗ ██╗██╗ ██╗██╗██╗
|
|
██╔══██╗████╗ ██║██║ ██║██║██║
|
|
███████║██╔██╗ ██║██║ ██║██║██║
|
|
██╔══██║██║╚██╗██║╚██╗ ██╔╝██║██║
|
|
██║ ██║██║ ╚████║ ╚████╔╝ ██║███████╗
|
|
╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═══╝ ╚═╝╚══════╝</pre>
|
|
<span class="text-primary/70 text-xs md:text-sm font-medium mb-1">HOSTING</span>
|
|
</div>
|
|
|
|
<!-- Main Headline with Rotation -->
|
|
<div class="space-y-6 max-w-4xl mt-4" x-data="headlineRotator()">
|
|
<div class="min-h-[120px] md:min-h-[160px] lg:min-h-[200px] flex flex-col justify-center" aria-live="polite" aria-atomic="true">
|
|
<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><span x-text="displayedLine1"></span><span x-show="isTypingLine1" class="text-primary cursor-blink">_</span><br/>
|
|
<span class="text-terminal-muted mr-2">></span><span class="text-primary" x-text="displayedLine2"></span><span x-show="isTypingLine2 || (!isTypingLine1 && !isTypingLine2)" class="text-primary cursor-blink">_</span>
|
|
</h1>
|
|
</div>
|
|
</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'" aria-live="polite">
|
|
<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 scope="col" 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 scope="col" 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 scope="col" 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 scope="col" class="px-4 py-3 font-bold text-terminal-muted text-center hidden sm:table-cell">DISK</th>
|
|
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted text-center hidden md:table-cell">TRAFFIC</th>
|
|
<th scope="col" 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 aria-live="polite">
|
|
<!-- 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" aria-label="가격 정보 새로고침">
|
|
<span class="material-symbols-outlined text-sm" :class="loading && 'animate-spin'" aria-hidden="true">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">
|
|
© 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"
|
|
integrity="sha384-iZD2X8o1Zdq0HR5H/7oa8W30WS4No+zWCKUPD7fHRay9I1Gf+C4F8sVmw7zec1wW"
|
|
crossorigin="anonymous"></script>
|
|
<script>
|
|
function headlineRotator() {
|
|
return {
|
|
headlines: [
|
|
{ line1: '웹, DB, API, 게임서버', line2: '거뜬합니다' },
|
|
{ line1: '엔지니어가 튜닝한', line2: '최적의 컨디션' },
|
|
{ line1: '고객의 문제가 해결될 때까지', line2: '우리는 멈추지 않습니다' },
|
|
{ line1: '도쿄 · 오사카 · 서울 · 싱가포르', line2: '아시아 전역 커버' },
|
|
{ line1: '복잡한 서버 관리는 이제 그만', line2: '지금부터 AI가 합니다' },
|
|
],
|
|
currentIndex: 0,
|
|
progress: 0,
|
|
displayedLine1: '',
|
|
displayedLine2: '',
|
|
isTypingLine1: true,
|
|
isTypingLine2: false,
|
|
typingId: null,
|
|
progressId: null,
|
|
|
|
init() {
|
|
this.startTyping();
|
|
},
|
|
|
|
startTyping() {
|
|
this.displayedLine1 = '';
|
|
this.displayedLine2 = '';
|
|
this.isTypingLine1 = true;
|
|
this.isTypingLine2 = false;
|
|
this.progress = 0;
|
|
|
|
if (this.typingId) clearInterval(this.typingId);
|
|
if (this.progressId) clearInterval(this.progressId);
|
|
|
|
const line1 = this.headlines[this.currentIndex].line1;
|
|
const line2 = this.headlines[this.currentIndex].line2;
|
|
let charIndex = 0;
|
|
|
|
// Type line 1
|
|
this.typingId = setInterval(() => {
|
|
if (charIndex < line1.length) {
|
|
this.displayedLine1 = line1.substring(0, charIndex + 1);
|
|
charIndex++;
|
|
} else {
|
|
clearInterval(this.typingId);
|
|
this.isTypingLine1 = false;
|
|
this.isTypingLine2 = true;
|
|
charIndex = 0;
|
|
|
|
// Type line 2
|
|
this.typingId = setInterval(() => {
|
|
if (charIndex < line2.length) {
|
|
this.displayedLine2 = line2.substring(0, charIndex + 1);
|
|
charIndex++;
|
|
} else {
|
|
clearInterval(this.typingId);
|
|
this.isTypingLine2 = false;
|
|
this.startProgress();
|
|
}
|
|
}, 60);
|
|
}
|
|
}, 60);
|
|
},
|
|
|
|
startProgress() {
|
|
this.progress = 0;
|
|
const duration = 3000;
|
|
const step = 100 / (duration / 50);
|
|
|
|
this.progressId = setInterval(() => {
|
|
this.progress += step;
|
|
if (this.progress >= 100) {
|
|
clearInterval(this.progressId);
|
|
this.next();
|
|
}
|
|
}, 50);
|
|
},
|
|
|
|
next() {
|
|
this.currentIndex = (this.currentIndex + 1) % this.headlines.length;
|
|
this.startTyping();
|
|
},
|
|
|
|
goTo(index) {
|
|
if (this.typingId) clearInterval(this.typingId);
|
|
if (this.progressId) clearInterval(this.progressId);
|
|
this.currentIndex = index;
|
|
this.startTyping();
|
|
}
|
|
};
|
|
}
|
|
|
|
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>
|