Remove old Vuexy wrapper components (AppTextField, AppSelect, AppTextarea, FlashMessages, NotificationBell)

All pages now use native Vuetify components directly. Flash messages are handled
by the ToastStack component via Pinia store. Notifications use NotificationPanel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-03-14 17:10:23 -04:00
parent dd1a5d7ffc
commit 40c1ecc6fe
90 changed files with 20113 additions and 457 deletions

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1773518893795}

View File

@@ -0,0 +1,18 @@
{"type":"server-started","port":61433,"host":"0.0.0.0","url_host":"0.0.0.0","url":"http://0.0.0.0:61433","screen_dir":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/typography-comparison.html"}
{"source":"user-event","type":"click","text":"D\n \n Plus Jakarta Sans\n Modern & friendly — Stripe, Figma feel\n Cloud Infrastructure\n Deploy VPS in seconds. Scale without limits.\n High-performance NVMe SSD storage, dedicated vCPUs, and 10Gbps network connectivity. Built for developers who demand reliability.\n \n $4.99\n /month\n \n \n $ ssh root@vps-01.ezscale.cloud","choice":"plus-jakarta","id":null,"timestamp":1773516090128}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/marketing-nav-comparison.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/waiting.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/hero-styles.html"}
{"source":"user-event","type":"click","text":"C Illustration-Driven DigitalOcean, Vultr style\n \n \n EZSCALE\n ProductsPricingDocs\n \n \n \n Cloud InfrastructureBuilt for Performance\n Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.\n \n Get Started Free\n View Pricing →\n \n \n \n \n \n \n \n \n \n \n \n Server illustration area","choice":"illustration","id":null,"timestamp":1773516421073}
{"source":"user-event","type":"click","text":"B Dark + Subtle Grid Pattern Vercel, Linear style\n \n \n \n \n \n \n EZSCALE\n ProductsPricingDocs\n \n \n Cloud InfrastructureBuilt for Performance\n Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.\n \n Get Started Free\n View Pricing →\n \n \n 99.99%Uptime SLA\n 15+Global Locations\n <1msAvg Latency","choice":"dark-pattern","id":null,"timestamp":1773516422860}
{"source":"user-event","type":"click","text":"B Dark + Subtle Grid Pattern Vercel, Linear style\n \n \n \n \n \n \n EZSCALE\n ProductsPricingDocs\n \n \n Cloud InfrastructureBuilt for Performance\n Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.\n \n Get Started Free\n View Pricing →\n \n \n 99.99%Uptime SLA\n 15+Global Locations\n <1msAvg Latency","choice":"dark-pattern","id":null,"timestamp":1773516423941}
{"source":"user-event","type":"click","text":"C Illustration-Driven DigitalOcean, Vultr style\n \n \n EZSCALE\n ProductsPricingDocs\n \n \n \n Cloud InfrastructureBuilt for Performance\n Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.\n \n Get Started Free\n View Pricing →\n \n \n \n \n \n \n \n \n \n \n \n Server illustration area","choice":"illustration","id":null,"timestamp":1773516424653}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/waiting-2.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/hero-visuals.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/waiting-3.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/pricing-cards.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/waiting-4.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/color-palettes.html"}
{"type":"screen-added","file":"/opt/projects/ezscale_site/.superpowers/brainstorm/752312-1773515533/waiting-5.html"}
{"type":"server-stopped","reason":"idle timeout"}

View File

@@ -0,0 +1 @@
752320

View File

@@ -0,0 +1,153 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; box-sizing: border-box; }
.palette-row { display: flex; gap: 4px; margin: 12px 0 8px; }
.swatch { flex: 1; height: 48px; border-radius: 6px; display: flex; align-items: flex-end; padding: 4px 6px; }
.swatch span { font-size: 9px; font-family: 'JetBrains Mono'; opacity: 0.8; }
.palette-demo { margin-top: 16px; padding: 20px; border-radius: 10px; }
.demo-btn { display: inline-block; padding: 8px 20px; border-radius: 8px; font-weight: 600; font-size: 13px; margin-right: 8px; }
.demo-chip { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.demo-stat { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; }
.semantic-row { display: flex; gap: 12px; margin-top: 12px; }
.semantic-swatch { width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; }
</style>
<h2>Color Palette Comparison</h2>
<p class="subtitle">Each palette shown with full shade range, UI samples, and semantic colors. Click to select.</p>
<div style="display:flex;flex-direction:column;gap:32px;margin-top:24px;">
<!-- A: Deep Navy Blue -->
<div class="card" data-choice="navy" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div><span class="label">A</span> <strong>Deep Navy Blue</strong></div>
<span style="font-size:12px;opacity:0.4;">Authoritative, enterprise</span>
</div>
<div class="palette-row">
<div class="swatch" style="background:#0c1929;"><span style="color:#fff;">#0c1929</span></div>
<div class="swatch" style="background:#142640;"><span style="color:#fff;">#142640</span></div>
<div class="swatch" style="background:#1e3a5f;"><span style="color:#fff;">#1e3a5f</span></div>
<div class="swatch" style="background:#1d4ed8;"><span style="color:#fff;">#1d4ed8</span></div>
<div class="swatch" style="background:#2563eb;"><span style="color:#fff;">#2563eb</span></div>
<div class="swatch" style="background:#3b82f6;"><span style="color:#fff;">#3b82f6</span></div>
<div class="swatch" style="background:#60a5fa;"><span style="color:#000;">#60a5fa</span></div>
<div class="swatch" style="background:#93c5fd;"><span style="color:#000;">#93c5fd</span></div>
</div>
<div class="palette-demo" style="background:#0c1929;border:1px solid rgba(30,58,95,0.4);">
<div style="display:flex;gap:20px;align-items:center;margin-bottom:16px;">
<span class="demo-btn" style="background:#1d4ed8;color:white;">Deploy Server</span>
<span class="demo-btn" style="background:transparent;color:#60a5fa;border:1px solid rgba(29,78,216,0.4);">View Plans</span>
<span class="demo-chip" style="background:rgba(29,78,216,0.2);color:#60a5fa;">Active</span>
<span class="demo-stat" style="color:#2563eb;">99.99%</span>
</div>
<div class="semantic-row">
<div><div class="semantic-swatch" style="background:#1d4ed8;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Primary</div></div>
<div><div class="semantic-swatch" style="background:#16a34a;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Success</div></div>
<div><div class="semantic-swatch" style="background:#dc2626;">!</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Error</div></div>
<div><div class="semantic-swatch" style="background:#d97706;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Warning</div></div>
<div><div class="semantic-swatch" style="background:#64748b;">i</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Secondary</div></div>
</div>
</div>
</div>
<!-- B: Vibrant Electric Blue -->
<div class="card" data-choice="electric" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div><span class="label">B</span> <strong>Vibrant Electric Blue</strong></div>
<span style="font-size:12px;opacity:0.4;">Modern, energetic</span>
</div>
<div class="palette-row">
<div class="swatch" style="background:#0a0f1a;"><span style="color:#fff;">#0a0f1a</span></div>
<div class="swatch" style="background:#111827;"><span style="color:#fff;">#111827</span></div>
<div class="swatch" style="background:#1e293b;"><span style="color:#fff;">#1e293b</span></div>
<div class="swatch" style="background:#2563eb;"><span style="color:#fff;">#2563eb</span></div>
<div class="swatch" style="background:#3b82f6;"><span style="color:#fff;">#3b82f6</span></div>
<div class="swatch" style="background:#60a5fa;"><span style="color:#000;">#60a5fa</span></div>
<div class="swatch" style="background:#93c5fd;"><span style="color:#000;">#93c5fd</span></div>
<div class="swatch" style="background:#dbeafe;"><span style="color:#000;">#dbeafe</span></div>
</div>
<div class="palette-demo" style="background:#0a0f1a;border:1px solid rgba(59,130,246,0.15);">
<div style="display:flex;gap:20px;align-items:center;margin-bottom:16px;">
<span class="demo-btn" style="background:#3b82f6;color:white;">Deploy Server</span>
<span class="demo-btn" style="background:transparent;color:#60a5fa;border:1px solid rgba(59,130,246,0.3);">View Plans</span>
<span class="demo-chip" style="background:rgba(59,130,246,0.15);color:#60a5fa;">Active</span>
<span class="demo-stat" style="color:#3b82f6;">99.99%</span>
</div>
<div class="semantic-row">
<div><div class="semantic-swatch" style="background:#3b82f6;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Primary</div></div>
<div><div class="semantic-swatch" style="background:#22c55e;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Success</div></div>
<div><div class="semantic-swatch" style="background:#ef4444;">!</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Error</div></div>
<div><div class="semantic-swatch" style="background:#f59e0b;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Warning</div></div>
<div><div class="semantic-swatch" style="background:#6b7280;">i</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Secondary</div></div>
</div>
</div>
</div>
<!-- C: Teal / Cyan Blue -->
<div class="card" data-choice="teal" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div><span class="label">C</span> <strong>Teal / Cyan Blue</strong></div>
<span style="font-size:12px;opacity:0.4;">Distinctive, cool</span>
</div>
<div class="palette-row">
<div class="swatch" style="background:#0a1419;"><span style="color:#fff;">#0a1419</span></div>
<div class="swatch" style="background:#0f1f2a;"><span style="color:#fff;">#0f1f2a</span></div>
<div class="swatch" style="background:#164e63;"><span style="color:#fff;">#164e63</span></div>
<div class="swatch" style="background:#0891b2;"><span style="color:#fff;">#0891b2</span></div>
<div class="swatch" style="background:#06b6d4;"><span style="color:#fff;">#06b6d4</span></div>
<div class="swatch" style="background:#22d3ee;"><span style="color:#000;">#22d3ee</span></div>
<div class="swatch" style="background:#67e8f9;"><span style="color:#000;">#67e8f9</span></div>
<div class="swatch" style="background:#cffafe;"><span style="color:#000;">#cffafe</span></div>
</div>
<div class="palette-demo" style="background:#0a1419;border:1px solid rgba(8,145,178,0.2);">
<div style="display:flex;gap:20px;align-items:center;margin-bottom:16px;">
<span class="demo-btn" style="background:#0891b2;color:white;">Deploy Server</span>
<span class="demo-btn" style="background:transparent;color:#22d3ee;border:1px solid rgba(8,145,178,0.3);">View Plans</span>
<span class="demo-chip" style="background:rgba(8,145,178,0.15);color:#22d3ee;">Active</span>
<span class="demo-stat" style="color:#06b6d4;">99.99%</span>
</div>
<div class="semantic-row">
<div><div class="semantic-swatch" style="background:#0891b2;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Primary</div></div>
<div><div class="semantic-swatch" style="background:#22c55e;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Success</div></div>
<div><div class="semantic-swatch" style="background:#ef4444;">!</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Error</div></div>
<div><div class="semantic-swatch" style="background:#f59e0b;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Warning</div></div>
<div><div class="semantic-swatch" style="background:#6b7280;">i</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Secondary</div></div>
</div>
</div>
</div>
<!-- D: Royal / Indigo Blue -->
<div class="card" data-choice="indigo" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div><span class="label">D</span> <strong>Royal Indigo Blue</strong></div>
<span style="font-size:12px;opacity:0.4;">Premium, distinctive — between purple and blue</span>
</div>
<div class="palette-row">
<div class="swatch" style="background:#0c0a1a;"><span style="color:#fff;">#0c0a1a</span></div>
<div class="swatch" style="background:#151030;"><span style="color:#fff;">#151030</span></div>
<div class="swatch" style="background:#312e81;"><span style="color:#fff;">#312e81</span></div>
<div class="swatch" style="background:#4f46e5;"><span style="color:#fff;">#4f46e5</span></div>
<div class="swatch" style="background:#6366f1;"><span style="color:#fff;">#6366f1</span></div>
<div class="swatch" style="background:#818cf8;"><span style="color:#000;">#818cf8</span></div>
<div class="swatch" style="background:#a5b4fc;"><span style="color:#000;">#a5b4fc</span></div>
<div class="swatch" style="background:#e0e7ff;"><span style="color:#000;">#e0e7ff</span></div>
</div>
<div class="palette-demo" style="background:#0c0a1a;border:1px solid rgba(79,70,229,0.2);">
<div style="display:flex;gap:20px;align-items:center;margin-bottom:16px;">
<span class="demo-btn" style="background:#4f46e5;color:white;">Deploy Server</span>
<span class="demo-btn" style="background:transparent;color:#818cf8;border:1px solid rgba(79,70,229,0.3);">View Plans</span>
<span class="demo-chip" style="background:rgba(79,70,229,0.15);color:#818cf8;">Active</span>
<span class="demo-stat" style="color:#6366f1;">99.99%</span>
</div>
<div class="semantic-row">
<div><div class="semantic-swatch" style="background:#4f46e5;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Primary</div></div>
<div><div class="semantic-swatch" style="background:#22c55e;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Success</div></div>
<div><div class="semantic-swatch" style="background:#ef4444;">!</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Error</div></div>
<div><div class="semantic-swatch" style="background:#f59e0b;"></div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Warning</div></div>
<div><div class="semantic-swatch" style="background:#6b7280;">i</div><div style="font-size:10px;opacity:0.4;margin-top:2px;">Secondary</div></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,134 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; }
.hero-demo { border-radius: 12px; overflow: hidden; min-height: 280px; position: relative; }
.hero-nav { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; position: relative; z-index: 2; }
.hero-nav-logo { font-weight: 800; font-size: 18px; color: #60a5fa; }
.hero-nav-links { display: flex; gap: 24px; font-size: 14px; opacity: 0.5; }
.hero-body { padding: 24px 32px 40px; position: relative; z-index: 2; }
.hero-body h3 { font-size: 36px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 12px; line-height: 1.2; }
.hero-body p { font-size: 16px; opacity: 0.6; max-width: 500px; line-height: 1.6; margin-bottom: 20px; }
.hero-cta { display: flex; gap: 12px; }
.hero-cta .primary { background: #3b82f6; color: white; padding: 12px 28px; border-radius: 10px; font-weight: 600; font-size: 14px; }
.hero-cta .secondary { background: rgba(255,255,255,0.08); color: white; padding: 12px 28px; border-radius: 10px; font-weight: 500; font-size: 14px; border: 1px solid rgba(255,255,255,0.12); }
.stat-row { display: flex; gap: 32px; margin-top: 24px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.08); }
.stat-item { font-size: 12px; opacity: 0.4; }
.stat-item strong { display: block; font-size: 20px; font-weight: 700; opacity: 1; color: #60a5fa; margin-bottom: 2px; }
</style>
<h2>Marketing Hero Style</h2>
<p class="subtitle">Click the hero style that best represents EZSCALE's brand</p>
<div style="display:flex;flex-direction:column;gap:32px;margin-top:24px;">
<!-- Option A: Gradient Backgrounds -->
<div class="card" data-choice="gradient" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">A</span> <strong>Gradient Backgrounds</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Cloudflare, OVHcloud style</span></div>
<div class="hero-demo" style="background:linear-gradient(135deg, #0f172a 0%, #1e3a5f 40%, #1e40af 70%, #3b82f6 100%);">
<div class="hero-nav">
<span class="hero-nav-logo">EZSCALE</span>
<div class="hero-nav-links"><span>Products</span><span>Pricing</span><span>Docs</span></div>
</div>
<div class="hero-body">
<h3>Cloud Infrastructure<br>Built for Performance</h3>
<p>Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.</p>
<div class="hero-cta">
<span class="primary">Get Started Free</span>
<span class="secondary">View Pricing →</span>
</div>
<div class="stat-row">
<div class="stat-item"><strong>99.99%</strong>Uptime SLA</div>
<div class="stat-item"><strong>15+</strong>Global Locations</div>
<div class="stat-item"><strong>&lt;1ms</strong>Avg Latency</div>
</div>
</div>
</div>
</div>
<!-- Option B: Dark with Subtle Patterns -->
<div class="card" data-choice="dark-pattern" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">B</span> <strong>Dark + Subtle Grid Pattern</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Vercel, Linear style</span></div>
<div class="hero-demo" style="background:#0a0a0f;position:relative;">
<!-- Grid pattern overlay -->
<div style="position:absolute;inset:0;background-image:linear-gradient(rgba(59,130,246,0.05) 1px, transparent 1px),linear-gradient(90deg, rgba(59,130,246,0.05) 1px, transparent 1px);background-size:48px 48px;"></div>
<!-- Radial glow -->
<div style="position:absolute;top:-60px;left:50%;transform:translateX(-50%);width:600px;height:400px;background:radial-gradient(ellipse,rgba(59,130,246,0.12) 0%,transparent 70%);"></div>
<div class="hero-nav" style="position:relative;z-index:2;">
<span class="hero-nav-logo">EZSCALE</span>
<div class="hero-nav-links"><span>Products</span><span>Pricing</span><span>Docs</span></div>
</div>
<div class="hero-body" style="position:relative;z-index:2;">
<h3>Cloud Infrastructure<br>Built for Performance</h3>
<p>Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.</p>
<div class="hero-cta">
<span class="primary">Get Started Free</span>
<span class="secondary">View Pricing →</span>
</div>
<div class="stat-row">
<div class="stat-item"><strong>99.99%</strong>Uptime SLA</div>
<div class="stat-item"><strong>15+</strong>Global Locations</div>
<div class="stat-item"><strong>&lt;1ms</strong>Avg Latency</div>
</div>
</div>
</div>
</div>
<!-- Option C: Illustration-Driven -->
<div class="card" data-choice="illustration" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">C</span> <strong>Illustration-Driven</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">DigitalOcean, Vultr style</span></div>
<div class="hero-demo" style="background:linear-gradient(180deg, #0c1222 0%, #111827 100%);">
<div class="hero-nav">
<span class="hero-nav-logo">EZSCALE</span>
<div class="hero-nav-links"><span>Products</span><span>Pricing</span><span>Docs</span></div>
</div>
<div style="display:flex;padding:0 32px 40px;">
<div class="hero-body" style="flex:1;padding:24px 0;">
<h3>Cloud Infrastructure<br>Built for Performance</h3>
<p>Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.</p>
<div class="hero-cta">
<span class="primary">Get Started Free</span>
<span class="secondary">View Pricing →</span>
</div>
</div>
<!-- Illustration placeholder -->
<div style="flex:0 0 280px;display:flex;align-items:center;justify-content:center;">
<div style="width:220px;height:180px;border-radius:16px;background:linear-gradient(135deg,rgba(59,130,246,0.1),rgba(59,130,246,0.05));border:1px solid rgba(59,130,246,0.15);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;">
<div style="display:flex;gap:8px;">
<div style="width:40px;height:48px;background:rgba(59,130,246,0.15);border-radius:6px;border:1px solid rgba(59,130,246,0.2);"></div>
<div style="width:40px;height:48px;background:rgba(59,130,246,0.15);border-radius:6px;border:1px solid rgba(59,130,246,0.2);"></div>
<div style="width:40px;height:48px;background:rgba(59,130,246,0.15);border-radius:6px;border:1px solid rgba(59,130,246,0.2);"></div>
</div>
<div style="width:140px;height:3px;background:rgba(59,130,246,0.2);border-radius:4px;"></div>
<div style="font-size:11px;opacity:0.3;">Server illustration area</div>
</div>
</div>
</div>
</div>
</div>
<!-- Option D: Minimal / Strong Typography -->
<div class="card" data-choice="minimal-typo" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">D</span> <strong>Minimal + Strong Typography</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Hetzner style — confident, no-nonsense</span></div>
<div class="hero-demo" style="background:#0b0e14;">
<div class="hero-nav">
<span class="hero-nav-logo">EZSCALE</span>
<div class="hero-nav-links"><span>Products</span><span>Pricing</span><span>Docs</span></div>
</div>
<div class="hero-body" style="text-align:center;max-width:none;display:flex;flex-direction:column;align-items:center;">
<h3 style="font-size:44px;max-width:600px;letter-spacing:-1px;">Cloud Infrastructure<br>Built for Performance</h3>
<p style="text-align:center;max-width:480px;">Deploy high-performance VPS, dedicated servers, and web hosting with enterprise-grade reliability.</p>
<div class="hero-cta" style="justify-content:center;">
<span class="primary">Get Started Free</span>
<span class="secondary">View Pricing →</span>
</div>
<div class="stat-row" style="justify-content:center;border-top:none;padding-top:8px;">
<div class="stat-item"><strong>99.99%</strong>Uptime SLA</div>
<div class="stat-item"><strong>15+</strong>Global Locations</div>
<div class="stat-item"><strong>&lt;1ms</strong>Avg Latency</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,233 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; box-sizing: border-box; }
.hero-wrap { border-radius: 12px; overflow: hidden; position: relative; background: #0a0a0f; }
.hero-grid-bg { position: absolute; inset: 0; background-image: linear-gradient(rgba(59,130,246,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(59,130,246,0.04) 1px, transparent 1px); background-size: 40px 40px; }
.hero-glow { position: absolute; top: -80px; right: 10%; width: 500px; height: 400px; background: radial-gradient(ellipse, rgba(59,130,246,0.1) 0%, transparent 70%); }
.hero-content { position: relative; z-index: 2; display: flex; padding: 40px 32px; gap: 40px; align-items: center; }
.hero-text { flex: 1; }
.hero-text h3 { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 10px; }
.hero-text p { font-size: 14px; opacity: 0.5; line-height: 1.6; }
.hero-visual { flex: 0 0 320px; display: flex; align-items: center; justify-content: center; min-height: 240px; }
@keyframes pulse-ring { 0% { transform: scale(1); opacity: 0.3; } 50% { transform: scale(1.15); opacity: 0.1; } 100% { transform: scale(1); opacity: 0.3; } }
@keyframes data-flow { 0% { transform: translateY(0); opacity: 0; } 20% { opacity: 1; } 80% { opacity: 1; } 100% { transform: translateY(-60px); opacity: 0; } }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@keyframes dash { to { stroke-dashoffset: -20; } }
</style>
<h2>Hero Visual Elements</h2>
<p class="subtitle">Each shown on the dark grid background you selected. Click your preference.</p>
<div style="display: flex; flex-direction: column; gap: 32px; margin-top: 24px;">
<!-- A: CSS/SVG Geometric Art -->
<div class="card" data-choice="css-geometric" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">A</span> <strong>CSS/SVG Geometric Art</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">No external assets needed</span></div>
<div class="hero-wrap">
<div class="hero-grid-bg"></div>
<div class="hero-glow"></div>
<div class="hero-content">
<div class="hero-text">
<h3>Cloud VPS Hosting</h3>
<p>Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
</div>
<div class="hero-visual">
<!-- CSS Server Rack -->
<div style="position:relative;">
<!-- Central node -->
<div style="width:80px;height:80px;border-radius:50%;background:rgba(59,130,246,0.1);border:2px solid rgba(59,130,246,0.3);display:flex;align-items:center;justify-content:center;position:relative;z-index:2;">
<div style="width:40px;height:40px;border-radius:50%;background:rgba(59,130,246,0.2);border:1px solid rgba(59,130,246,0.4);display:flex;align-items:center;justify-content:center;">
<div style="font-size:18px;"></div>
</div>
</div>
<!-- Orbiting nodes -->
<div style="position:absolute;top:-50px;left:50%;transform:translateX(-50%);">
<div style="width:44px;height:44px;border-radius:10px;background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);display:flex;align-items:center;justify-content:center;animation:float 3s ease-in-out infinite;">
<div style="font-size:11px;font-weight:600;color:#60a5fa;">VPS</div>
</div>
</div>
<div style="position:absolute;top:20px;right:-80px;">
<div style="width:44px;height:44px;border-radius:10px;background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2);display:flex;align-items:center;justify-content:center;animation:float 3s ease-in-out 0.5s infinite;">
<div style="font-size:11px;font-weight:600;color:#4ade80;">DB</div>
</div>
</div>
<div style="position:absolute;top:20px;left:-80px;">
<div style="width:44px;height:44px;border-radius:10px;background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.2);display:flex;align-items:center;justify-content:center;animation:float 3s ease-in-out 1s infinite;">
<div style="font-size:11px;font-weight:600;color:#fbbf24;">CDN</div>
</div>
</div>
<div style="position:absolute;bottom:-50px;left:50%;transform:translateX(-50%);">
<div style="width:44px;height:44px;border-radius:10px;background:rgba(168,85,247,0.08);border:1px solid rgba(168,85,247,0.2);display:flex;align-items:center;justify-content:center;animation:float 3s ease-in-out 1.5s infinite;">
<div style="font-size:11px;font-weight:600;color:#a855f7;">API</div>
</div>
</div>
<!-- Connection lines via SVG -->
<svg style="position:absolute;top:-50px;left:-80px;width:240px;height:180px;pointer-events:none;z-index:1;" viewBox="0 0 240 180">
<line x1="120" y1="30" x2="120" y2="70" stroke="rgba(59,130,246,0.2)" stroke-width="1" stroke-dasharray="4 4" style="animation:dash 1s linear infinite;"/>
<line x1="180" y1="90" x2="160" y2="90" stroke="rgba(34,197,94,0.2)" stroke-width="1" stroke-dasharray="4 4" style="animation:dash 1s linear infinite;"/>
<line x1="60" y1="90" x2="80" y2="90" stroke="rgba(251,191,36,0.2)" stroke-width="1" stroke-dasharray="4 4" style="animation:dash 1s linear infinite;"/>
<line x1="120" y1="110" x2="120" y2="150" stroke="rgba(168,85,247,0.2)" stroke-width="1" stroke-dasharray="4 4" style="animation:dash 1s linear infinite;"/>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- B: 3D Render / Static Images -->
<div class="card" data-choice="3d-render" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">B</span> <strong>3D Renders / Static Images</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Needs asset creation or purchase</span></div>
<div class="hero-wrap">
<div class="hero-grid-bg"></div>
<div class="hero-glow"></div>
<div class="hero-content">
<div class="hero-text">
<h3>Cloud VPS Hosting</h3>
<p>Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
</div>
<div class="hero-visual">
<!-- Simulated 3D isometric server -->
<div style="position:relative;transform:perspective(500px) rotateY(-8deg) rotateX(5deg);">
<div style="width:200px;background:linear-gradient(180deg, #1a2744 0%, #0f1a2e 100%);border-radius:12px;border:1px solid rgba(59,130,246,0.15);padding:16px;box-shadow:0 20px 60px rgba(0,0,0,0.5),0 0 40px rgba(59,130,246,0.05);">
<!-- Server unit 1 -->
<div style="background:rgba(59,130,246,0.06);border:1px solid rgba(59,130,246,0.12);border-radius:6px;padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:8px;">
<div style="display:flex;gap:3px;">
<div style="width:6px;height:6px;border-radius:50%;background:#4ade80;"></div>
<div style="width:6px;height:6px;border-radius:50%;background:#4ade80;"></div>
</div>
<div style="flex:1;height:3px;background:rgba(59,130,246,0.15);border-radius:2px;"><div style="width:75%;height:100%;background:#3b82f6;border-radius:2px;"></div></div>
<span style="font-size:9px;font-family:'JetBrains Mono';color:#60a5fa;">vps-01</span>
</div>
<!-- Server unit 2 -->
<div style="background:rgba(59,130,246,0.06);border:1px solid rgba(59,130,246,0.12);border-radius:6px;padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:8px;">
<div style="display:flex;gap:3px;">
<div style="width:6px;height:6px;border-radius:50%;background:#4ade80;"></div>
<div style="width:6px;height:6px;border-radius:50%;background:#fbbf24;"></div>
</div>
<div style="flex:1;height:3px;background:rgba(59,130,246,0.15);border-radius:2px;"><div style="width:45%;height:100%;background:#3b82f6;border-radius:2px;"></div></div>
<span style="font-size:9px;font-family:'JetBrains Mono';color:#60a5fa;">vps-02</span>
</div>
<!-- Server unit 3 -->
<div style="background:rgba(59,130,246,0.06);border:1px solid rgba(59,130,246,0.12);border-radius:6px;padding:10px 12px;display:flex;align-items:center;gap:8px;">
<div style="display:flex;gap:3px;">
<div style="width:6px;height:6px;border-radius:50%;background:#4ade80;"></div>
<div style="width:6px;height:6px;border-radius:50%;background:#4ade80;"></div>
</div>
<div style="flex:1;height:3px;background:rgba(59,130,246,0.15);border-radius:2px;"><div style="width:90%;height:100%;background:#3b82f6;border-radius:2px;"></div></div>
<span style="font-size:9px;font-family:'JetBrains Mono';color:#60a5fa;">vps-03</span>
</div>
</div>
<!-- Shadow/glow under -->
<div style="position:absolute;bottom:-12px;left:10%;width:80%;height:20px;background:radial-gradient(ellipse,rgba(59,130,246,0.15),transparent);filter:blur(8px);"></div>
</div>
</div>
</div>
</div>
</div>
<!-- C: Animated SVG Diagrams -->
<div class="card" data-choice="animated-svg" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">C</span> <strong>Animated SVG Diagrams</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Eye-catching, more complex</span></div>
<div class="hero-wrap">
<div class="hero-grid-bg"></div>
<div class="hero-glow"></div>
<div class="hero-content">
<div class="hero-text">
<h3>Cloud VPS Hosting</h3>
<p>Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
</div>
<div class="hero-visual">
<svg width="280" height="200" viewBox="0 0 280 200">
<!-- Network nodes -->
<circle cx="140" cy="100" r="24" fill="rgba(59,130,246,0.15)" stroke="rgba(59,130,246,0.4)" stroke-width="1.5"/>
<text x="140" y="104" text-anchor="middle" fill="#60a5fa" font-size="10" font-weight="600" font-family="Plus Jakarta Sans">CORE</text>
<!-- Outer nodes -->
<circle cx="60" cy="40" r="18" fill="rgba(59,130,246,0.08)" stroke="rgba(59,130,246,0.2)" stroke-width="1"/>
<text x="60" y="44" text-anchor="middle" fill="#60a5fa" font-size="8" font-family="Plus Jakarta Sans">US-E</text>
<circle cx="220" cy="40" r="18" fill="rgba(34,197,94,0.08)" stroke="rgba(34,197,94,0.2)" stroke-width="1"/>
<text x="220" y="44" text-anchor="middle" fill="#4ade80" font-size="8" font-family="Plus Jakarta Sans">EU-W</text>
<circle cx="40" cy="140" r="18" fill="rgba(251,191,36,0.08)" stroke="rgba(251,191,36,0.2)" stroke-width="1"/>
<text x="40" y="144" text-anchor="middle" fill="#fbbf24" font-size="8" font-family="Plus Jakarta Sans">AP-SE</text>
<circle cx="240" cy="140" r="18" fill="rgba(168,85,247,0.08)" stroke="rgba(168,85,247,0.2)" stroke-width="1"/>
<text x="240" y="144" text-anchor="middle" fill="#a855f7" font-size="8" font-family="Plus Jakarta Sans">US-W</text>
<!-- Animated connection lines -->
<line x1="78" y1="52" x2="118" y2="84" stroke="rgba(59,130,246,0.2)" stroke-width="1" stroke-dasharray="4 3">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1.5s" repeatCount="indefinite"/>
</line>
<line x1="202" y1="52" x2="162" y2="84" stroke="rgba(34,197,94,0.2)" stroke-width="1" stroke-dasharray="4 3">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1.5s" repeatCount="indefinite"/>
</line>
<line x1="56" y1="124" x2="122" y2="108" stroke="rgba(251,191,36,0.2)" stroke-width="1" stroke-dasharray="4 3">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1.5s" repeatCount="indefinite"/>
</line>
<line x1="224" y1="124" x2="158" y2="108" stroke="rgba(168,85,247,0.2)" stroke-width="1" stroke-dasharray="4 3">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1.5s" repeatCount="indefinite"/>
</line>
<!-- Pulse rings on center -->
<circle cx="140" cy="100" r="32" fill="none" stroke="rgba(59,130,246,0.15)" stroke-width="1">
<animate attributeName="r" values="32;48;32" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.3;0;0.3" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="140" cy="100" r="40" fill="none" stroke="rgba(59,130,246,0.1)" stroke-width="1">
<animate attributeName="r" values="40;60;40" dur="3s" begin="0.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.2;0;0.2" dur="3s" begin="0.5s" repeatCount="indefinite"/>
</circle>
<!-- Data packets flowing -->
<circle r="2" fill="#60a5fa">
<animateMotion dur="2s" repeatCount="indefinite" path="M78,52 L118,84"/>
</circle>
<circle r="2" fill="#4ade80">
<animateMotion dur="2.2s" repeatCount="indefinite" path="M202,52 L162,84"/>
</circle>
<circle r="2" fill="#fbbf24">
<animateMotion dur="2.4s" repeatCount="indefinite" path="M56,124 L122,108"/>
</circle>
<circle r="2" fill="#a855f7">
<animateMotion dur="1.8s" repeatCount="indefinite" path="M224,124 L158,108"/>
</circle>
</svg>
</div>
</div>
</div>
</div>
<!-- D: Minimal Abstract Shapes -->
<div class="card" data-choice="minimal-abstract" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">D</span> <strong>Minimal Abstract Shapes</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Low effort, clean accent</span></div>
<div class="hero-wrap">
<div class="hero-grid-bg"></div>
<div class="hero-glow"></div>
<div class="hero-content">
<div class="hero-text">
<h3>Cloud VPS Hosting</h3>
<p>Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
</div>
<div class="hero-visual">
<div style="position:relative;width:200px;height:200px;">
<!-- Abstract circles -->
<div style="position:absolute;top:20px;right:20px;width:120px;height:120px;border-radius:50%;border:1px solid rgba(59,130,246,0.12);"></div>
<div style="position:absolute;top:40px;right:40px;width:80px;height:80px;border-radius:50%;border:1px solid rgba(59,130,246,0.08);"></div>
<div style="position:absolute;top:55px;right:55px;width:50px;height:50px;border-radius:50%;background:rgba(59,130,246,0.06);border:1px solid rgba(59,130,246,0.15);"></div>
<!-- Floating dots -->
<div style="position:absolute;top:10px;left:30px;width:6px;height:6px;border-radius:50%;background:rgba(59,130,246,0.3);animation:float 4s ease-in-out infinite;"></div>
<div style="position:absolute;bottom:30px;right:10px;width:4px;height:4px;border-radius:50%;background:rgba(59,130,246,0.2);animation:float 3s ease-in-out 0.5s infinite;"></div>
<div style="position:absolute;top:80px;left:10px;width:8px;height:8px;border-radius:50%;background:rgba(59,130,246,0.15);animation:float 5s ease-in-out 1s infinite;"></div>
<!-- Subtle lines -->
<div style="position:absolute;top:35px;left:50px;width:60px;height:1px;background:linear-gradient(90deg,transparent,rgba(59,130,246,0.15),transparent);"></div>
<div style="position:absolute;bottom:50px;left:20px;width:40px;height:1px;background:linear-gradient(90deg,transparent,rgba(59,130,246,0.1),transparent);"></div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,129 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; }
.nav-demo { border-radius: 12px; overflow: hidden; border: 1px solid rgba(255,255,255,0.1); margin-bottom: 8px; }
.nav-content { padding: 32px; min-height: 200px; }
.nav-content h4 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.nav-content p { font-size: 14px; opacity: 0.5; }
.feature-pills { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.feature-pill { background: rgba(59,130,246,0.1); color: #60a5fa; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; }
</style>
<h2>Marketing Site Navigation</h2>
<p class="subtitle">Click the navigation style that fits EZSCALE best</p>
<div style="display:flex;flex-direction:column;gap:32px;margin-top:24px;">
<!-- Option A: Horizontal Top Navbar -->
<div class="card" data-choice="horizontal" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">A — Horizontal Top Navbar</span> <span style="opacity:0.4;font-size:12px;margin-left:8px;">DigitalOcean, Vultr, Hetzner style</span></div>
<div class="nav-demo">
<!-- Navbar -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 32px;background:rgba(255,255,255,0.03);border-bottom:1px solid rgba(255,255,255,0.08);">
<div style="display:flex;align-items:center;gap:32px;">
<span style="font-weight:800;font-size:18px;color:#60a5fa;">EZSCALE</span>
<div style="display:flex;gap:24px;font-size:14px;">
<span style="opacity:0.9;font-weight:500;">Products</span>
<span style="opacity:0.5;">Pricing</span>
<span style="opacity:0.5;">Docs</span>
<span style="opacity:0.5;">About</span>
<span style="opacity:0.5;">Contact</span>
</div>
</div>
<div style="display:flex;gap:12px;align-items:center;">
<span style="font-size:14px;opacity:0.6;">Log In</span>
<span style="background:#3b82f6;color:white;padding:8px 20px;border-radius:8px;font-size:14px;font-weight:600;">Get Started</span>
</div>
</div>
<!-- Page content -->
<div class="nav-content" style="background:linear-gradient(180deg, rgba(59,130,246,0.05) 0%, transparent 100%);">
<h4>Cloud VPS Hosting</h4>
<p>Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
<div class="feature-pills">
<span class="feature-pill">NVMe SSD</span>
<span class="feature-pill">10Gbps Network</span>
<span class="feature-pill">99.99% Uptime</span>
<span class="feature-pill">Root Access</span>
</div>
</div>
</div>
</div>
<!-- Option B: Vertical Sidebar -->
<div class="card" data-choice="sidebar" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">B — Vertical Sidebar</span> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Current EZSCALE style — app-like feel</span></div>
<div class="nav-demo">
<div style="display:flex;">
<!-- Sidebar -->
<div style="width:220px;min-height:260px;background:rgba(255,255,255,0.03);border-right:1px solid rgba(255,255,255,0.08);padding:20px 16px;">
<span style="font-weight:800;font-size:16px;color:#60a5fa;display:block;margin-bottom:24px;">EZSCALE</span>
<div style="display:flex;flex-direction:column;gap:4px;font-size:13px;">
<div style="padding:10px 12px;border-radius:8px;background:linear-gradient(90deg,#3b82f6,rgba(59,130,246,0.7));color:white;font-weight:500;">Products</div>
<div style="padding:10px 12px;opacity:0.5;">Pricing</div>
<div style="padding:10px 12px;opacity:0.5;">Documentation</div>
<div style="padding:10px 12px;opacity:0.5;">About</div>
<div style="padding:10px 12px;opacity:0.5;">Contact</div>
<div style="margin-top:16px;padding:10px 12px;opacity:0.4;font-size:12px;border-top:1px solid rgba(255,255,255,0.08);padding-top:20px;">Log In</div>
<div style="padding:8px 12px;background:#3b82f6;color:white;border-radius:8px;text-align:center;font-weight:600;font-size:13px;margin-top:4px;">Get Started</div>
</div>
</div>
<!-- Page content -->
<div class="nav-content" style="flex:1;">
<h4>Cloud VPS Hosting</h4>
<p>Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
<div class="feature-pills">
<span class="feature-pill">NVMe SSD</span>
<span class="feature-pill">10Gbps Network</span>
<span class="feature-pill">99.99% Uptime</span>
<span class="feature-pill">Root Access</span>
</div>
</div>
</div>
</div>
</div>
<!-- Option C: Transparent / Minimal Header -->
<div class="card" data-choice="transparent" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">C — Transparent / Minimal Header</span> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Vercel, Cloudflare style — goes solid on scroll</span></div>
<div class="nav-demo">
<!-- Transparent navbar overlaying hero -->
<div style="position:relative;">
<div style="background:linear-gradient(180deg, #0c1425 0%, #162036 100%);min-height:260px;padding-top:0;">
<!-- Navbar floating on top -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:20px 32px;">
<div style="display:flex;align-items:center;gap:32px;">
<span style="font-weight:800;font-size:18px;color:#60a5fa;">EZSCALE</span>
<div style="display:flex;gap:24px;font-size:14px;">
<span style="opacity:0.8;font-weight:500;">Products</span>
<span style="opacity:0.4;">Pricing</span>
<span style="opacity:0.4;">Docs</span>
<span style="opacity:0.4;">About</span>
</div>
</div>
<div style="display:flex;gap:12px;align-items:center;">
<span style="font-size:14px;opacity:0.5;">Log In</span>
<span style="background:rgba(59,130,246,0.15);color:#60a5fa;padding:8px 20px;border-radius:8px;font-size:14px;font-weight:600;border:1px solid rgba(59,130,246,0.3);">Get Started</span>
</div>
</div>
<!-- Hero content -->
<div style="padding:32px 32px 40px;">
<h4 style="font-size:28px;margin-bottom:8px;">Cloud VPS Hosting</h4>
<p style="opacity:0.5;max-width:500px;">Deploy high-performance virtual servers in seconds with NVMe SSD storage and dedicated vCPUs.</p>
<div class="feature-pills" style="margin-top:20px;">
<span class="feature-pill">NVMe SSD</span>
<span class="feature-pill">10Gbps Network</span>
<span class="feature-pill">99.99% Uptime</span>
<span class="feature-pill">Root Access</span>
</div>
</div>
</div>
<!-- Scroll indicator -->
<div style="text-align:center;padding:8px;font-size:11px;opacity:0.3;background:rgba(255,255,255,0.02);border-top:1px solid rgba(255,255,255,0.05);">
↑ Navbar becomes solid with blur backdrop on scroll ↑
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,143 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; box-sizing: border-box; }
.phone-frame {
width: 320px;
height: 580px;
border: 2px solid rgba(255,255,255,0.1);
border-radius: 28px;
overflow: hidden;
position: relative;
background: #0a0a0f;
}
.phone-notch {
width: 120px;
height: 24px;
background: #000;
border-radius: 0 0 16px 16px;
margin: 0 auto;
position: relative;
z-index: 10;
}
.phone-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(10,10,15,0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.phone-logo { font-weight: 800; font-size: 15px; color: #60a5fa; }
.hamburger { width: 22px; height: 16px; display: flex; flex-direction: column; justify-content: space-between; cursor: pointer; }
.hamburger span { display: block; height: 2px; background: rgba(255,255,255,0.7); border-radius: 2px; }
.phone-content { padding: 20px 16px; }
.phone-content h3 { font-size: 22px; font-weight: 800; letter-spacing: -0.3px; margin-bottom: 8px; }
.phone-content p { font-size: 13px; opacity: 0.5; line-height: 1.5; }
.nav-item { padding: 14px 16px; font-size: 16px; font-weight: 500; border-bottom: 1px solid rgba(255,255,255,0.05); }
.nav-item-active { color: #60a5fa; }
.nav-cta { margin: 16px; padding: 12px; background: #1d4ed8; border-radius: 999px; text-align: center; font-weight: 600; font-size: 14px; color: white; }
</style>
<h2>Mobile Navigation Styles</h2>
<p class="subtitle">Each shown in a phone frame mockup. Click to select your preference.</p>
<div style="display:flex;gap:32px;justify-content:center;margin-top:32px;flex-wrap:wrap;">
<!-- A: Hamburger Slide-in -->
<div class="card" data-choice="slide-in" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">A</span> <strong>Slide-in Panel</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<div class="phone-header">
<span class="phone-logo">EZSCALE</span>
<div class="hamburger"><span></span><span></span><span></span></div>
</div>
<!-- Slide-in panel from right -->
<div style="position:absolute;top:0;right:0;width:75%;height:100%;background:rgba(12,14,20,0.98);backdrop-filter:blur(20px);border-left:1px solid rgba(255,255,255,0.08);z-index:5;padding-top:70px;">
<div class="nav-item nav-item-active">Home</div>
<div class="nav-item">VPS Hosting</div>
<div class="nav-item">Dedicated Servers</div>
<div class="nav-item">Web Hosting</div>
<div class="nav-item">Game Servers</div>
<div class="nav-item">Pricing</div>
<div class="nav-item">About</div>
<div class="nav-item">Contact</div>
<div class="nav-cta">Get Started</div>
<div style="padding:16px;text-align:center;font-size:13px;opacity:0.4;">Log In</div>
</div>
<!-- Overlay -->
<div style="position:absolute;top:0;left:0;width:25%;height:100%;background:rgba(0,0,0,0.5);z-index:4;"></div>
<div class="phone-content" style="opacity:0.3;">
<h3>Cloud Infrastructure</h3>
<p>Deploy high-performance virtual servers in seconds.</p>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">Standard pattern — slides from right</p>
</div>
<!-- B: Bottom Sheet -->
<div class="card" data-choice="bottom-sheet" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">B</span> <strong>Bottom Sheet</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<div class="phone-header">
<span class="phone-logo">EZSCALE</span>
<div class="hamburger"><span></span><span></span><span></span></div>
</div>
<div class="phone-content" style="opacity:0.3;">
<h3>Cloud Infrastructure</h3>
<p>Deploy high-performance virtual servers in seconds.</p>
</div>
<!-- Overlay -->
<div style="position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:4;"></div>
<!-- Bottom sheet -->
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(15,17,25,0.98);backdrop-filter:blur(20px);border-top:1px solid rgba(255,255,255,0.08);border-radius:20px 20px 0 0;z-index:5;padding:8px 0 20px;">
<!-- Handle -->
<div style="width:40px;height:4px;background:rgba(255,255,255,0.2);border-radius:4px;margin:8px auto 16px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:0 12px;">
<div style="padding:12px;border-radius:10px;background:rgba(29,78,216,0.1);text-align:center;font-size:13px;font-weight:500;color:#60a5fa;">Home</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">VPS</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Dedicated</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Web</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Gaming</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Pricing</div>
</div>
<div style="padding:8px 12px 0;">
<div class="nav-cta" style="margin:12px 0 0;">Get Started</div>
</div>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">App-like — slides up from bottom</p>
</div>
<!-- C: Full-screen Overlay -->
<div class="card" data-choice="fullscreen" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">C</span> <strong>Full-screen Overlay</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<!-- Full screen nav -->
<div style="position:absolute;inset:0;background:linear-gradient(180deg, #0c1929 0%, #0a0f1a 100%);z-index:5;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:36px 16px 12px;">
<span class="phone-logo">EZSCALE</span>
<div style="font-size:20px;opacity:0.6;cursor:pointer;"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:0 24px;">
<div style="font-size:28px;font-weight:700;padding:12px 0;color:#60a5fa;">Home</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">VPS Hosting</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Dedicated</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Web Hosting</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Game Servers</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Pricing</div>
</div>
<div style="padding:20px 24px 32px;">
<div class="nav-cta" style="margin:0;">Get Started</div>
<div style="text-align:center;padding:12px;font-size:14px;opacity:0.4;">Log In</div>
</div>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">Bold — takes over entire screen</p>
</div>
</div>

View File

@@ -0,0 +1,174 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; box-sizing: border-box; }
.plan-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.plan-name { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.plan-price { font-size: 32px; font-weight: 800; letter-spacing: -1px; }
.plan-period { font-size: 13px; opacity: 0.4; font-weight: 400; }
.plan-desc { font-size: 13px; opacity: 0.5; margin: 8px 0 16px; line-height: 1.5; }
.plan-features { list-style: none; padding: 0; margin: 0 0 20px; }
.plan-features li { font-size: 13px; padding: 6px 0; display: flex; align-items: center; gap: 8px; opacity: 0.7; }
.plan-features li::before { content: '✓'; color: #3b82f6; font-weight: 700; font-size: 12px; }
.plan-btn { display: block; width: 100%; padding: 10px; border-radius: 8px; text-align: center; font-weight: 600; font-size: 14px; border: none; cursor: pointer; }
.plan-btn-primary { background: #3b82f6; color: white; }
.plan-btn-outline { background: transparent; color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
.badge-popular { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; margin-bottom: 12px; }
</style>
<h2>Pricing Card Styles</h2>
<p class="subtitle">Click the card style that fits EZSCALE best</p>
<div style="display:flex;flex-direction:column;gap:40px;margin-top:24px;">
<!-- Style A: Bordered with Hover -->
<div class="card" data-choice="bordered" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">A</span> <strong>Bordered Cards + Hover Lift</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Hetzner / Vultr style</span></div>
<div style="padding:20px;background:#0a0a0f;">
<div class="plan-grid">
<!-- Starter -->
<div style="border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:24px;transition:all 0.2s;">
<div class="plan-name">Starter</div>
<div class="plan-price">$4.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">Perfect for small projects and development environments.</div>
<ul class="plan-features">
<li>1 vCPU Core</li>
<li>2 GB RAM</li>
<li>40 GB NVMe SSD</li>
<li>2 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-outline">Get Started</button>
</div>
<!-- Pro (featured) -->
<div style="border:2px solid rgba(59,130,246,0.5);border-radius:12px;padding:24px;position:relative;box-shadow:0 0 30px rgba(59,130,246,0.1);">
<span class="badge-popular" style="background:rgba(59,130,246,0.15);color:#60a5fa;">Most Popular</span>
<div class="plan-name">Professional</div>
<div class="plan-price">$12.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">Ideal for growing applications and production workloads.</div>
<ul class="plan-features">
<li>4 vCPU Cores</li>
<li>8 GB RAM</li>
<li>160 GB NVMe SSD</li>
<li>5 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-primary">Get Started</button>
</div>
<!-- Enterprise -->
<div style="border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:24px;">
<div class="plan-name">Enterprise</div>
<div class="plan-price">$44.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">For high-traffic applications demanding maximum performance.</div>
<ul class="plan-features">
<li>8 vCPU Cores</li>
<li>32 GB RAM</li>
<li>400 GB NVMe SSD</li>
<li>10 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-outline">Get Started</button>
</div>
</div>
</div>
</div>
<!-- Style B: Glass Morphism -->
<div class="card" data-choice="glass" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">B</span> <strong>Glass Morphism Cards</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">Modern, premium feel</span></div>
<div style="padding:20px;background:#0a0a0f;position:relative;overflow:hidden;">
<!-- Background blobs -->
<div style="position:absolute;top:-40px;left:20%;width:200px;height:200px;background:radial-gradient(circle,rgba(59,130,246,0.12),transparent 70%);"></div>
<div style="position:absolute;bottom:-40px;right:20%;width:200px;height:200px;background:radial-gradient(circle,rgba(168,85,247,0.08),transparent 70%);"></div>
<div class="plan-grid" style="position:relative;z-index:1;">
<!-- Starter -->
<div style="background:rgba(255,255,255,0.03);backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:24px;">
<div class="plan-name">Starter</div>
<div class="plan-price">$4.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">Perfect for small projects and development environments.</div>
<ul class="plan-features">
<li>1 vCPU Core</li>
<li>2 GB RAM</li>
<li>40 GB NVMe SSD</li>
<li>2 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-outline" style="border-radius:12px;">Get Started</button>
</div>
<!-- Pro (featured) -->
<div style="background:rgba(59,130,246,0.06);backdrop-filter:blur(20px);border:1px solid rgba(59,130,246,0.15);border-radius:16px;padding:24px;box-shadow:0 8px 32px rgba(59,130,246,0.08);">
<span class="badge-popular" style="background:rgba(59,130,246,0.2);color:#60a5fa;backdrop-filter:blur(10px);">Most Popular</span>
<div class="plan-name">Professional</div>
<div class="plan-price">$12.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">Ideal for growing applications and production workloads.</div>
<ul class="plan-features">
<li>4 vCPU Cores</li>
<li>8 GB RAM</li>
<li>160 GB NVMe SSD</li>
<li>5 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-primary" style="border-radius:12px;">Get Started</button>
</div>
<!-- Enterprise -->
<div style="background:rgba(255,255,255,0.03);backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:24px;">
<div class="plan-name">Enterprise</div>
<div class="plan-price">$44.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">For high-traffic applications demanding maximum performance.</div>
<ul class="plan-features">
<li>8 vCPU Cores</li>
<li>32 GB RAM</li>
<li>400 GB NVMe SSD</li>
<li>10 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-outline" style="border-radius:12px;">Get Started</button>
</div>
</div>
</div>
</div>
<!-- Style C: Flat with Color Accents -->
<div class="card" data-choice="flat-accent" onclick="toggleSelect(this)" style="padding:0;cursor:pointer;">
<div style="padding:12px 16px;"><span class="label">C</span> <strong>Flat Cards + Color Accent Bar</strong> <span style="opacity:0.4;font-size:12px;margin-left:8px;">DigitalOcean style — data-focused</span></div>
<div style="padding:20px;background:#0a0a0f;">
<div class="plan-grid">
<!-- Starter -->
<div style="background:rgba(255,255,255,0.03);border-radius:12px;padding:24px;border-top:3px solid rgba(255,255,255,0.15);">
<div class="plan-name">Starter</div>
<div class="plan-price">$4.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">Perfect for small projects and development environments.</div>
<ul class="plan-features">
<li>1 vCPU Core</li>
<li>2 GB RAM</li>
<li>40 GB NVMe SSD</li>
<li>2 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-outline">Get Started</button>
</div>
<!-- Pro (featured) -->
<div style="background:rgba(59,130,246,0.04);border-radius:12px;padding:24px;border-top:3px solid #3b82f6;">
<span class="badge-popular" style="background:rgba(59,130,246,0.15);color:#60a5fa;">Most Popular</span>
<div class="plan-name">Professional</div>
<div class="plan-price">$12.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">Ideal for growing applications and production workloads.</div>
<ul class="plan-features">
<li>4 vCPU Cores</li>
<li>8 GB RAM</li>
<li>160 GB NVMe SSD</li>
<li>5 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-primary">Get Started</button>
</div>
<!-- Enterprise -->
<div style="background:rgba(255,255,255,0.03);border-radius:12px;padding:24px;border-top:3px solid rgba(255,255,255,0.15);">
<div class="plan-name">Enterprise</div>
<div class="plan-price">$44.99 <span class="plan-period">/mo</span></div>
<div class="plan-desc">For high-traffic applications demanding maximum performance.</div>
<ul class="plan-features">
<li>8 vCPU Cores</li>
<li>32 GB RAM</li>
<li>400 GB NVMe SSD</li>
<li>10 TB Bandwidth</li>
</ul>
<button class="plan-btn plan-btn-outline">Get Started</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,82 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<h2>Typography Direction</h2>
<p class="subtitle">Click the option that feels right for EZSCALE's technical, powerful identity</p>
<div class="cards" style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:24px;">
<div class="card" data-choice="inter" onclick="toggleSelect(this)" style="padding:32px;cursor:pointer;">
<div style="margin-bottom:8px;"><span class="label">A</span></div>
<div style="font-family:'Inter',sans-serif;">
<h3 style="font-family:'Inter',sans-serif;font-size:28px;font-weight:700;margin-bottom:4px;">Inter</h3>
<p style="font-size:13px;opacity:0.5;margin-bottom:16px;">The industry standard — Vercel, Linear, GitHub</p>
<p style="font-size:32px;font-weight:700;letter-spacing:-0.5px;margin-bottom:8px;">Cloud Infrastructure</p>
<p style="font-size:18px;font-weight:500;margin-bottom:8px;">Deploy VPS in seconds. Scale without limits.</p>
<p style="font-size:14px;opacity:0.7;line-height:1.6;margin-bottom:16px;">High-performance NVMe SSD storage, dedicated vCPUs, and 10Gbps network connectivity. Built for developers who demand reliability.</p>
<div style="display:flex;gap:16px;align-items:baseline;margin-bottom:12px;">
<span style="font-size:36px;font-weight:700;">$4.99</span>
<span style="font-size:14px;opacity:0.5;">/month</span>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;background:rgba(255,255,255,0.05);padding:12px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);">
<span style="opacity:0.5;">$</span> ssh root@vps-01.ezscale.cloud
</div>
</div>
</div>
<div class="card" data-choice="space-grotesk" onclick="toggleSelect(this)" style="padding:32px;cursor:pointer;">
<div style="margin-bottom:8px;"><span class="label">B</span></div>
<div style="font-family:'Space Grotesk',sans-serif;">
<h3 style="font-family:'Space Grotesk',sans-serif;font-size:28px;font-weight:700;margin-bottom:4px;">Space Grotesk</h3>
<p style="font-size:13px;opacity:0.5;margin-bottom:16px;">Geometric & distinctive — technical but warm</p>
<p style="font-size:32px;font-weight:700;letter-spacing:-0.5px;margin-bottom:8px;">Cloud Infrastructure</p>
<p style="font-size:18px;font-weight:500;margin-bottom:8px;">Deploy VPS in seconds. Scale without limits.</p>
<p style="font-size:14px;opacity:0.7;line-height:1.6;margin-bottom:16px;">High-performance NVMe SSD storage, dedicated vCPUs, and 10Gbps network connectivity. Built for developers who demand reliability.</p>
<div style="display:flex;gap:16px;align-items:baseline;margin-bottom:12px;">
<span style="font-size:36px;font-weight:700;">$4.99</span>
<span style="font-size:14px;opacity:0.5;">/month</span>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;background:rgba(255,255,255,0.05);padding:12px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);">
<span style="opacity:0.5;">$</span> ssh root@vps-01.ezscale.cloud
</div>
</div>
</div>
<div class="card" data-choice="dm-sans" onclick="toggleSelect(this)" style="padding:32px;cursor:pointer;">
<div style="margin-bottom:8px;"><span class="label">C</span></div>
<div style="font-family:'DM Sans',sans-serif;">
<h3 style="font-family:'DM Sans',sans-serif;font-size:28px;font-weight:700;margin-bottom:4px;">DM Sans</h3>
<p style="font-size:13px;opacity:0.5;margin-bottom:16px;">Clean geometric — DigitalOcean, Notion vibes</p>
<p style="font-size:32px;font-weight:700;letter-spacing:-0.5px;margin-bottom:8px;">Cloud Infrastructure</p>
<p style="font-size:18px;font-weight:500;margin-bottom:8px;">Deploy VPS in seconds. Scale without limits.</p>
<p style="font-size:14px;opacity:0.7;line-height:1.6;margin-bottom:16px;">High-performance NVMe SSD storage, dedicated vCPUs, and 10Gbps network connectivity. Built for developers who demand reliability.</p>
<div style="display:flex;gap:16px;align-items:baseline;margin-bottom:12px;">
<span style="font-size:36px;font-weight:700;">$4.99</span>
<span style="font-size:14px;opacity:0.5;">/month</span>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;background:rgba(255,255,255,0.05);padding:12px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);">
<span style="opacity:0.5;">$</span> ssh root@vps-01.ezscale.cloud
</div>
</div>
</div>
<div class="card" data-choice="plus-jakarta" onclick="toggleSelect(this)" style="padding:32px;cursor:pointer;">
<div style="margin-bottom:8px;"><span class="label">D</span></div>
<div style="font-family:'Plus Jakarta Sans',sans-serif;">
<h3 style="font-family:'Plus Jakarta Sans',sans-serif;font-size:28px;font-weight:700;margin-bottom:4px;">Plus Jakarta Sans</h3>
<p style="font-size:13px;opacity:0.5;margin-bottom:16px;">Modern & friendly — Stripe, Figma feel</p>
<p style="font-size:32px;font-weight:700;letter-spacing:-0.5px;margin-bottom:8px;">Cloud Infrastructure</p>
<p style="font-size:18px;font-weight:500;margin-bottom:8px;">Deploy VPS in seconds. Scale without limits.</p>
<p style="font-size:14px;opacity:0.7;line-height:1.6;margin-bottom:16px;">High-performance NVMe SSD storage, dedicated vCPUs, and 10Gbps network connectivity. Built for developers who demand reliability.</p>
<div style="display:flex;gap:16px;align-items:baseline;margin-bottom:12px;">
<span style="font-size:36px;font-weight:700;">$4.99</span>
<span style="font-size:14px;opacity:0.5;">/month</span>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;background:rgba(255,255,255,0.05);padding:12px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);">
<span style="opacity:0.5;">$</span> ssh root@vps-01.ezscale.cloud
</div>
</div>
</div>
</div>
<p style="margin-top:24px;opacity:0.5;font-size:13px;">All options pair with JetBrains Mono for code/terminal snippets. Click to select your preference.</p>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1 @@
{"type":"server-started","port":61168,"host":"0.0.0.0","url_host":"0.0.0.0","url":"http://0.0.0.0:61168","screen_dir":"/opt/projects/ezscale_site/.superpowers/brainstorm/755389-1773519617"}

View File

@@ -0,0 +1,143 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; box-sizing: border-box; }
.phone-frame {
width: 320px;
height: 580px;
border: 2px solid rgba(255,255,255,0.1);
border-radius: 28px;
overflow: hidden;
position: relative;
background: #0a0a0f;
}
.phone-notch {
width: 120px;
height: 24px;
background: #000;
border-radius: 0 0 16px 16px;
margin: 0 auto;
position: relative;
z-index: 10;
}
.phone-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(10,10,15,0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.phone-logo { font-weight: 800; font-size: 15px; color: #60a5fa; }
.hamburger { width: 22px; height: 16px; display: flex; flex-direction: column; justify-content: space-between; cursor: pointer; }
.hamburger span { display: block; height: 2px; background: rgba(255,255,255,0.7); border-radius: 2px; }
.phone-content { padding: 20px 16px; }
.phone-content h3 { font-size: 22px; font-weight: 800; letter-spacing: -0.3px; margin-bottom: 8px; }
.phone-content p { font-size: 13px; opacity: 0.5; line-height: 1.5; }
.nav-item { padding: 14px 16px; font-size: 16px; font-weight: 500; border-bottom: 1px solid rgba(255,255,255,0.05); }
.nav-item-active { color: #60a5fa; }
.nav-cta { margin: 16px; padding: 12px; background: #1d4ed8; border-radius: 999px; text-align: center; font-weight: 600; font-size: 14px; color: white; }
</style>
<h2>Mobile Navigation Styles</h2>
<p class="subtitle">Each shown in a phone frame mockup. Click to select your preference.</p>
<div style="display:flex;gap:32px;justify-content:center;margin-top:32px;flex-wrap:wrap;">
<!-- A: Hamburger Slide-in -->
<div class="card" data-choice="slide-in" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">A</span> <strong>Slide-in Panel</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<div class="phone-header">
<span class="phone-logo">EZSCALE</span>
<div class="hamburger"><span></span><span></span><span></span></div>
</div>
<!-- Slide-in panel from right -->
<div style="position:absolute;top:0;right:0;width:75%;height:100%;background:rgba(12,14,20,0.98);backdrop-filter:blur(20px);border-left:1px solid rgba(255,255,255,0.08);z-index:5;padding-top:70px;">
<div class="nav-item nav-item-active">Home</div>
<div class="nav-item">VPS Hosting</div>
<div class="nav-item">Dedicated Servers</div>
<div class="nav-item">Web Hosting</div>
<div class="nav-item">Game Servers</div>
<div class="nav-item">Pricing</div>
<div class="nav-item">About</div>
<div class="nav-item">Contact</div>
<div class="nav-cta">Get Started</div>
<div style="padding:16px;text-align:center;font-size:13px;opacity:0.4;">Log In</div>
</div>
<!-- Overlay -->
<div style="position:absolute;top:0;left:0;width:25%;height:100%;background:rgba(0,0,0,0.5);z-index:4;"></div>
<div class="phone-content" style="opacity:0.3;">
<h3>Cloud Infrastructure</h3>
<p>Deploy high-performance virtual servers in seconds.</p>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">Standard pattern — slides from right</p>
</div>
<!-- B: Bottom Sheet -->
<div class="card" data-choice="bottom-sheet" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">B</span> <strong>Bottom Sheet</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<div class="phone-header">
<span class="phone-logo">EZSCALE</span>
<div class="hamburger"><span></span><span></span><span></span></div>
</div>
<div class="phone-content" style="opacity:0.3;">
<h3>Cloud Infrastructure</h3>
<p>Deploy high-performance virtual servers in seconds.</p>
</div>
<!-- Overlay -->
<div style="position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:4;"></div>
<!-- Bottom sheet -->
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(15,17,25,0.98);backdrop-filter:blur(20px);border-top:1px solid rgba(255,255,255,0.08);border-radius:20px 20px 0 0;z-index:5;padding:8px 0 20px;">
<!-- Handle -->
<div style="width:40px;height:4px;background:rgba(255,255,255,0.2);border-radius:4px;margin:8px auto 16px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:0 12px;">
<div style="padding:12px;border-radius:10px;background:rgba(29,78,216,0.1);text-align:center;font-size:13px;font-weight:500;color:#60a5fa;">Home</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">VPS</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Dedicated</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Web</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Gaming</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Pricing</div>
</div>
<div style="padding:8px 12px 0;">
<div class="nav-cta" style="margin:12px 0 0;">Get Started</div>
</div>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">App-like — slides up from bottom</p>
</div>
<!-- C: Full-screen Overlay -->
<div class="card" data-choice="fullscreen" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">C</span> <strong>Full-screen Overlay</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<!-- Full screen nav -->
<div style="position:absolute;inset:0;background:linear-gradient(180deg, #0c1929 0%, #0a0f1a 100%);z-index:5;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:36px 16px 12px;">
<span class="phone-logo">EZSCALE</span>
<div style="font-size:20px;opacity:0.6;cursor:pointer;"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:0 24px;">
<div style="font-size:28px;font-weight:700;padding:12px 0;color:#60a5fa;">Home</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">VPS Hosting</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Dedicated</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Web Hosting</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Game Servers</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Pricing</div>
</div>
<div style="padding:20px 24px 32px;">
<div class="nav-cta" style="margin:0;">Get Started</div>
<div style="text-align:center;padding:12px;font-size:14px;opacity:0.4;">Log In</div>
</div>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">Bold — takes over entire screen</p>
</div>
</div>

View File

@@ -0,0 +1 @@
{"type":"server-started","port":63351,"host":"0.0.0.0","url_host":"0.0.0.0","url":"http://0.0.0.0:63351","screen_dir":"/opt/projects/ezscale_site/.superpowers/brainstorm/755514-1773519658"}

View File

@@ -0,0 +1,143 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; box-sizing: border-box; }
.phone-frame {
width: 320px;
height: 580px;
border: 2px solid rgba(255,255,255,0.1);
border-radius: 28px;
overflow: hidden;
position: relative;
background: #0a0a0f;
}
.phone-notch {
width: 120px;
height: 24px;
background: #000;
border-radius: 0 0 16px 16px;
margin: 0 auto;
position: relative;
z-index: 10;
}
.phone-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(10,10,15,0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.phone-logo { font-weight: 800; font-size: 15px; color: #60a5fa; }
.hamburger { width: 22px; height: 16px; display: flex; flex-direction: column; justify-content: space-between; cursor: pointer; }
.hamburger span { display: block; height: 2px; background: rgba(255,255,255,0.7); border-radius: 2px; }
.phone-content { padding: 20px 16px; }
.phone-content h3 { font-size: 22px; font-weight: 800; letter-spacing: -0.3px; margin-bottom: 8px; }
.phone-content p { font-size: 13px; opacity: 0.5; line-height: 1.5; }
.nav-item { padding: 14px 16px; font-size: 16px; font-weight: 500; border-bottom: 1px solid rgba(255,255,255,0.05); }
.nav-item-active { color: #60a5fa; }
.nav-cta { margin: 16px; padding: 12px; background: #1d4ed8; border-radius: 999px; text-align: center; font-weight: 600; font-size: 14px; color: white; }
</style>
<h2>Mobile Navigation Styles</h2>
<p class="subtitle">Each shown in a phone frame mockup. Click to select your preference.</p>
<div style="display:flex;gap:32px;justify-content:center;margin-top:32px;flex-wrap:wrap;">
<!-- A: Hamburger Slide-in -->
<div class="card" data-choice="slide-in" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">A</span> <strong>Slide-in Panel</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<div class="phone-header">
<span class="phone-logo">EZSCALE</span>
<div class="hamburger"><span></span><span></span><span></span></div>
</div>
<!-- Slide-in panel from right -->
<div style="position:absolute;top:0;right:0;width:75%;height:100%;background:rgba(12,14,20,0.98);backdrop-filter:blur(20px);border-left:1px solid rgba(255,255,255,0.08);z-index:5;padding-top:70px;">
<div class="nav-item nav-item-active">Home</div>
<div class="nav-item">VPS Hosting</div>
<div class="nav-item">Dedicated Servers</div>
<div class="nav-item">Web Hosting</div>
<div class="nav-item">Game Servers</div>
<div class="nav-item">Pricing</div>
<div class="nav-item">About</div>
<div class="nav-item">Contact</div>
<div class="nav-cta">Get Started</div>
<div style="padding:16px;text-align:center;font-size:13px;opacity:0.4;">Log In</div>
</div>
<!-- Overlay -->
<div style="position:absolute;top:0;left:0;width:25%;height:100%;background:rgba(0,0,0,0.5);z-index:4;"></div>
<div class="phone-content" style="opacity:0.3;">
<h3>Cloud Infrastructure</h3>
<p>Deploy high-performance virtual servers in seconds.</p>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">Standard pattern — slides from right</p>
</div>
<!-- B: Bottom Sheet -->
<div class="card" data-choice="bottom-sheet" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">B</span> <strong>Bottom Sheet</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<div class="phone-header">
<span class="phone-logo">EZSCALE</span>
<div class="hamburger"><span></span><span></span><span></span></div>
</div>
<div class="phone-content" style="opacity:0.3;">
<h3>Cloud Infrastructure</h3>
<p>Deploy high-performance virtual servers in seconds.</p>
</div>
<!-- Overlay -->
<div style="position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:4;"></div>
<!-- Bottom sheet -->
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(15,17,25,0.98);backdrop-filter:blur(20px);border-top:1px solid rgba(255,255,255,0.08);border-radius:20px 20px 0 0;z-index:5;padding:8px 0 20px;">
<!-- Handle -->
<div style="width:40px;height:4px;background:rgba(255,255,255,0.2);border-radius:4px;margin:8px auto 16px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:0 12px;">
<div style="padding:12px;border-radius:10px;background:rgba(29,78,216,0.1);text-align:center;font-size:13px;font-weight:500;color:#60a5fa;">Home</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">VPS</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Dedicated</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Web</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Gaming</div>
<div style="padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);text-align:center;font-size:13px;opacity:0.7;">Pricing</div>
</div>
<div style="padding:8px 12px 0;">
<div class="nav-cta" style="margin:12px 0 0;">Get Started</div>
</div>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">App-like — slides up from bottom</p>
</div>
<!-- C: Full-screen Overlay -->
<div class="card" data-choice="fullscreen" onclick="toggleSelect(this)" style="padding:24px;cursor:pointer;text-align:center;">
<div style="margin-bottom:12px;"><span class="label">C</span> <strong>Full-screen Overlay</strong></div>
<div class="phone-frame" style="margin:0 auto;">
<div class="phone-notch"></div>
<!-- Full screen nav -->
<div style="position:absolute;inset:0;background:linear-gradient(180deg, #0c1929 0%, #0a0f1a 100%);z-index:5;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:36px 16px 12px;">
<span class="phone-logo">EZSCALE</span>
<div style="font-size:20px;opacity:0.6;cursor:pointer;"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:0 24px;">
<div style="font-size:28px;font-weight:700;padding:12px 0;color:#60a5fa;">Home</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">VPS Hosting</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Dedicated</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Web Hosting</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Game Servers</div>
<div style="font-size:28px;font-weight:700;padding:12px 0;opacity:0.6;">Pricing</div>
</div>
<div style="padding:20px 24px 32px;">
<div class="nav-cta" style="margin:0;">Get Started</div>
<div style="text-align:center;padding:12px;font-size:14px;opacity:0.4;">Log In</div>
</div>
</div>
</div>
<p style="font-size:12px;opacity:0.4;margin-top:12px;">Bold — takes over entire screen</p>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -42,8 +42,10 @@ The Laravel application is in **`website/`**. All artisan, composer, and npm com
```
website/
├── app/
│ ├── Models/ # 14 Eloquent models (Service uses SoftDeletes)
│ ├── Http/Controllers/ # Account/ and Admin/ controllers
│ ├── Models/ # 16 Eloquent models (Service uses SoftDeletes, TaxRate, EmailTemplate)
│ ├── Http/Controllers/ # Account/, Admin/, and Api/V1/ controllers
│ │ └── Api/V1/ # RESTful API controllers (Customer + Admin/)
│ ├── Http/Resources/ # API Resources (Service, Invoice, Subscription, Ticket, Customer, etc.)
│ ├── Services/Billing/ # BillingServiceInterface, Stripe, PayPal, Dunning
│ ├── Events/ # PaymentSucceeded/Failed, SubscriptionCreated/Cancelled
│ ├── Listeners/ # HandlePaymentSucceeded/Failed
@@ -66,7 +68,7 @@ website/
│ │ ├── @layouts/ # Layout SCSS stubs for Vuexy compatibility
│ │ ├── Layouts/ # AccountLayout, AdminLayout, AuthLayout, MarketingLayout
│ │ ├── Components/ # FlashMessages, StatCard, StatusChip, ThemeSwitcher, app-form-elements/
│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (1), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Services/ (2), Tickets/ (3), Admin/ (25+), Marketing/ (13), Dashboard
│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (1), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Services/ (2), Tickets/ (3), Admin/ (30+), Marketing/ (14), Dashboard
│ ├── styles/ # SCSS with Vuexy @core overrides
│ │ ├── @core/ # Copied from Vuexy: base + template SCSS overrides
│ │ ├── variables/ # _vuetify.scss, _template.scss
@@ -84,7 +86,7 @@ website/
- **Framework:** Laravel 12 (PHP 8.3), Laravel 12 slim structure (no Kernel files)
- **Frontend:** Vue 3 + Inertia.js v2 + TypeScript (REQUIRED) + Vuetify 3 (Vuexy design system) + Vite 7
- **UI Theme:** Vuexy Vue + Laravel Admin Dashboard — SCSS overrides from @core integrated, AppTextField/AppSelect/AppTextarea wrapper components, purple primary (#7367F0)
- **Testing:** Pest 4 + PHPUnit 12 (252 tests, 1310 assertions)
- **Testing:** Pest 4 + PHPUnit 12 (347 tests, 1866 assertions)
- **Formatting:** Laravel Pint
- **Payments:** Laravel Cashier (Stripe) + srmklive/paypal (PayPal)
- **Database:** MySQL 8.x, Redis for cache/queue/sessions

View File

@@ -0,0 +1,102 @@
# Provisioning & Service Termination Fix - 2026-02-10
## Issues Fixed
### Issue #1: VPS Not Being Provisioned
**Root Cause:** All provisioning services (VirtFusion, Pterodactyl, SynergyCP, Enhance) were reading credentials from `config('services.*')` which pulls from `.env`, but the actual credentials are stored in the **database** via the `settings` table (configured in Admin → Settings → API).
**What Happened:**
- User configured VirtFusion API URL/token via admin panel settings (stored in DB)
- VirtFusionService was reading from `.env` (empty values)
- HTTP client tried to connect to relative hostnames like `sanctum`, `users`, `servers`
- cURL errors: "Could not resolve host: users"
**Files Updated:**
- `app/Services/Provisioning/VirtFusionService.php`
- `app/Services/Provisioning/PterodactylService.php`
- `app/Services/Provisioning/SynergyCPService.php`
- `app/Services/Provisioning/EnhanceService.php`
**Change:** All services now use `\App\Models\Setting::get('provider_api_url')` instead of `config('services.provider.url')`
### Issue #2: Services Not Terminated on Subscription Cancellation
**Root Cause:** `HandleSubscriptionCancelled` listener only sent a notification - it didn't call the provisioning service's `terminate()` method.
**What Happened:**
- User canceled subscription with "immediate" setting
- Subscription canceled in Stripe
- Service remained active on VirtFusion panel
- No cleanup of provisioned resources
**Files Updated:**
- `app/Listeners/HandleSubscriptionCancelled.php`
**Changes:**
- Made listener implement `ShouldQueue` (background job with retries)
- Added logic to find all active/suspended services for the subscription
- Call `terminate()` on each service via ProvisioningFactory
- Proper error handling and logging
- Service status updated to "terminated" in database
## Current State
### VirtFusion Configuration (Database)
```
virtfusion_api_url: https://cp.vps.ezscale.tech/api/v1
virtfusion_api_token: (encrypted in DB)
```
### How It Works Now
**Subscription Created:**
1. HandleSubscriptionCreated listener queued
2. Reads plan from database
3. Gets provisioning service (VirtFusionService for VPS)
4. **VirtFusionService reads URL/token from settings table**
5. Ensures user exists on VirtFusion panel
6. Creates server (package ID 43)
7. Changes package to match plan specs
8. Updates service record with IP, hostname, etc.
9. Sends credentials email to customer
**Subscription Canceled:**
1. HandleSubscriptionCancelled listener queued
2. Finds all active/suspended services for subscription
3. Gets provisioning service for each
4. Calls `terminate()` on VirtFusion (DELETE /servers/{id})
5. Updates service status to "terminated"
6. Sends cancellation email to customer
## Testing Needed
1. ✅ Horizon restarted to reload code changes
2. ⏳ Create a new VPS subscription and verify:
- Service is provisioned on VirtFusion
- Credentials email is sent
- Service appears in customer dashboard
3. ⏳ Cancel the subscription and verify:
- Service is terminated on VirtFusion
- Service status updates to "terminated"
- Cancellation email is sent
## Environment Variables (No Longer Used)
The following `.env` variables are **no longer used** for provisioning (database settings take precedence):
- ~~VIRTFUSION_API_URL~~
- ~~VIRTFUSION_API_TOKEN~~
- ~~PTERODACTYL_PANEL_URL~~
- ~~PTERODACTYL_API_KEY~~
- ~~SYNERGYCP_API_URL~~
- ~~SYNERGYCP_API_TOKEN~~
- ~~ENHANCE_API_URL~~
- ~~ENHANCE_API_TOKEN~~
**Configure all provisioning credentials in:** Admin Panel → Settings → API Integrations
## Migration Notes
If you have provisioning credentials in `.env`, migrate them to the database:
1. Go to Admin → Settings → API
2. Enter the URL and token for each provider
3. Click "Save" to encrypt and store in database
4. Remove from `.env` (optional, but recommended to avoid confusion)

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

View File

@@ -144,7 +144,7 @@
- [x] Tax ID
- [x] Password change
- [x] 2FA setup (TOTP, passkeys)
## Phase 5: Admin Panel (admin.ezscale.cloud)
## Phase 5: Admin Panel (admin.ezscale.cloud)
- [x] Analytics dashboard:
- [x] MRR (Monthly Recurring Revenue) graph
- [x] ARR (Annual Recurring Revenue)
@@ -163,10 +163,11 @@
- [x] View customer audit log
- [x] Service management:
- [x] All services list (filter by type, status, platform)
- [ ] Manually provision service
- [x] Manually provision service
- [x] Suspend/unsuspend service
- [x] Terminate service
- [ ] Modify service (change plan, extend expiry)
- [x] Modify service (change plan)
- [x] Extend service expiry (admin override subscription end date)
- [x] View provisioning logs
- [x] Archive/restore services (soft-delete with SoftDeletes trait)
- [x] Order management:
@@ -190,15 +191,15 @@
- [x] Archive/hide plan
- [x] Set stock quantity (for limited dedicated servers)
- [x] System configuration:
- [ ] Email template editor
- [ ] Tax rate configuration (by region)
- [x] Email template editor (7 templates with variable substitution, preview, reset to default)
- [x] Tax rate configuration (by country/region, inclusive/exclusive, priority-based)
- [x] Suspension policy settings (days before suspend/terminate)
- [ ] Bandwidth overage rates
- [ ] Discord webhook URLs
- [ ] API credentials (VirtFusion, Pterodactyl, etc.)
- [x] Bandwidth overage rates (in billing settings tab)
- [x] Discord webhook URLs (4 channels with test buttons)
- [x] API credentials (VirtFusion, Pterodactyl, SynergyCP, Enhance — with test connection)
- [x] Audit log viewer:
- [x] Filter by user, action, date
- [ ] View changes (before/after state)
- [x] View changes (before/after state with expandable rows and detail dialog)
- [x] Export logs
## Phase 6: Bandwidth Monitoring & Billing
@@ -255,31 +256,39 @@
- [x] Acceptable Use Policy
- [x] SLA (Service Level Agreement)
- [x] Footer links to legal pages
- [ ] Signup flow:
- [ ] Plan selection
- [ ] Account creation
- [ ] Payment information
- [ ] Order confirmation
- [ ] Redirect to account dashboard
- [x] Signup flow:
- [x] Plan selection (CTAs on pricing + product pages link to checkout)
- [x] Account creation (register/login pages show plan context)
- [x] Payment information (checkout page with Stripe/PayPal)
- [x] Order confirmation (success redirect with flash message)
- [x] Redirect to account dashboard (via Fortify intended URL)
## Phase 9: API Development
- [ ] Customer API (RESTful, Sanctum auth):
- [ ] GET /api/v1/services - List customer's services
- [ ] GET /api/v1/services/{id} - Service details
- [ ] POST /api/v1/services/{id}/reboot - Reboot server
- [ ] GET /api/v1/invoices - Invoice history
- [ ] GET /api/v1/invoices/{id}/pdf - Download invoice PDF
- [ ] GET /api/v1/bandwidth - Bandwidth usage
- [ ] POST /api/v1/subscriptions/{id}/cancel - Cancel subscription
- [ ] POST /api/v1/tickets - Create support ticket
- [ ] Admin API:
- [ ] GET /api/v1/admin/customers - List all customers
- [ ] GET /api/v1/admin/services - List all services
- [ ] POST /api/v1/admin/services/{id}/suspend - Suspend service
- [ ] GET /api/v1/admin/analytics - Analytics data
- [ ] API documentation (OpenAPI/Swagger)
- [ ] API rate limiting and throttling
- [ ] API versioning strategy
## Phase 9: API Development
- [x] Customer API (RESTful, Passport auth):
- [x] GET /api/v1/services - List customer's services (paginated)
- [x] GET /api/v1/services/{id} - Service details
- [x] POST /api/v1/services/{id}/reboot - Reboot server (VPS only)
- [x] GET /api/v1/invoices - Invoice history (filterable by status)
- [x] GET /api/v1/invoices/{id}/pdf - Download invoice PDF
- [ ] GET /api/v1/bandwidth - Bandwidth usage (depends on Phase 6)
- [x] POST /api/v1/subscriptions/{id}/cancel - Cancel subscription
- [x] POST /api/v1/tickets - Create support ticket
- [x] GET /api/v1/tickets/{id} - Ticket details with replies
- [x] POST /api/v1/tickets/{id}/reply - Reply to ticket
- [x] GET /api/v1/subscriptions - List subscriptions
- [x] Admin API:
- [x] GET /api/v1/admin/customers - List all customers (searchable, filterable)
- [x] GET /api/v1/admin/customers/{id} - Customer details
- [x] GET /api/v1/admin/services - List all services (filterable)
- [x] GET /api/v1/admin/services/{id} - Service details
- [x] POST /api/v1/admin/services/{id}/suspend - Suspend service
- [x] POST /api/v1/admin/services/{id}/unsuspend - Unsuspend service
- [x] GET /api/v1/admin/analytics - Analytics data (MRR, ARR, churn, growth)
- [x] API documentation (public marketing page at /api-docs with all endpoints, params, responses)
- [x] API rate limiting and throttling (60/min customer, 120/min admin)
- [x] API versioning strategy (v1 prefix)
- [x] API Resources (Service, Invoice, Subscription, Ticket, Customer, AdminService, Analytics)
- [x] 49 API tests (373 assertions)
## Phase 10: Testing, Migration & Launch
- [ ] Unit tests for all services and models

View File

@@ -0,0 +1,132 @@
# VirtFusion V6 API Integration
## Overview
EZSCALE uses VirtFusion V6 for VPS provisioning and management. This document outlines the correct API endpoints and parameters.
## API Reference
**OpenAPI Spec**: `virtfusion-api-spec.yaml` (8,309 lines)
**Base URL**: Configured in `.env` as `VIRTFUSION_API_URL`
**Authentication**: Bearer token (configured as `VIRTFUSION_API_TOKEN`)
## Correct API Endpoints (V6)
### Connection Test
```http
GET /connect
Authorization: Bearer {token}
```
**Response**: Returns VirtFusion version and connection status
### Create Server
```http
POST /servers
Content-Type: application/json
Authorization: Bearer {token}
{
"packageId": 1, // Required: VirtFusion package ID
"userId": 1, // Required: VirtFusion user ID (not email!)
"hypervisorId": 1 // Required: Hypervisor group ID
}
```
**Response**: Returns server object with ID, UUID, state, IP addresses, etc.
### Get Server Details
```http
GET /servers/{serverId}
Authorization: Bearer {token}
```
**Response**: Full server details including state, suspended status, traffic, etc.
### Suspend Server
```http
POST /servers/{serverId}/suspend
Authorization: Bearer {token}
```
**Response**: 204 No Content on success
### Unsuspend Server
```http
POST /servers/{serverId}/unsuspend
Authorization: Bearer {token}
```
**Response**: 204 No Content on success
### Delete Server
```http
DELETE /servers/{serverId}?delay=0
Authorization: Bearer {token}
```
**Query Parameters**:
- `delay`: Minutes to wait before deletion (0-43800), optional
**Response**: 204 No Content on success
## Plan Configuration
VPS plans in the database need these feature keys:
```json
{
"virtfusion_package_id": 1, // Required: VirtFusion package ID
"virtfusion_user_id": 1, // Required: VirtFusion user ID for server owner
"virtfusion_hypervisor_id": 1 // Required: Hypervisor group ID
}
```
## Key Differences from Other Versions
1. **No `/api/v1` prefix** - V6 endpoints start directly with resource names
2. **camelCase parameters** - Use `packageId` not `package_id`
3. **userId not email** - Must provide VirtFusion user ID, not email address
4. **Suspend returns 204** - No response body, check for 204 status code
## Implementation Files
- **Service**: `app/Services/Provisioning/VirtFusionService.php`
- **Settings Controller**: `app/Http/Controllers/Admin/SettingsController.php` (testVirtFusion method)
- **Config**: `config/services.php` (virtfusion section)
## Testing
Test connection via Admin Settings page:
1. Navigate to Admin → Settings → API Credentials
2. Enter VirtFusion API URL and Token
3. Click "Test Connection"
4. Should return: "VirtFusion connection successful. Version: X.X.X"
## API Restrictions
According to deployment notes, the VirtFusion API token is restricted to:
- ✅ Create server
- ✅ Create user
- ✅ Authenticate user
Ensure the token has appropriate permissions for these operations.
## Troubleshooting
### 404 Errors
- ❌ Using `/api/v1/servers` (old format)
- ✅ Use `/servers` (V6 format)
### 400 Bad Request
- Check parameter names are camelCase (`packageId` not `package_id`)
- Ensure `userId` is an integer, not an email
- Verify `packageId` and `hypervisorId` exist in VirtFusion
### 401 Unauthorized
- Verify `VIRTFUSION_API_TOKEN` is correct
- Check token has required permissions
- Ensure `Authorization: Bearer` header is included
### Server Creation Fails
- Verify plan features include `virtfusion_package_id`, `virtfusion_user_id`, and `virtfusion_hypervisor_id`
- Check VirtFusion has available resources (RAM, storage, IP addresses)
- Review `provisioning_logs` table for detailed error messages
## Related Documentation
- VirtFusion Official Docs: https://docs.virtfusion.com/api/
- OpenAPI Spec: `/virtfusion-api-spec.yaml`
- Project Integration: `CLAUDE.md` → Provisioning Services section

View File

@@ -0,0 +1,211 @@
# VPS Checkout Enhancement - 2026-02-10
## Overview
Enhanced the VPS checkout flow with OS selection, SSH key configuration, and automatic server building. Created a premium ordering experience matching modern cloud platforms.
## Frontend Changes
### Enhanced Checkout Page (`resources/ts/Pages/Checkout/Show.vue`)
**New Features:**
1. **Server Configuration Card** (VPS only)
- Operating System selection dropdown with 9 options:
- Ubuntu 24.04 LTS, 22.04 LTS, 20.04 LTS
- Debian 12, 11
- AlmaLinux 9
- Rocky Linux 9
- CentOS Stream 9
- Fedora 39
- Each OS has an icon (Ubuntu, Debian, RedHat, Fedora)
2. **Authentication Method Toggle**
- **Auto-Generated Password** (default): VirtFusion generates and emails password
- **SSH Key**: User pastes their SSH public key
- Smooth transitions between modes
- Helpful tooltips and documentation links
3. **Configuration Preview** (Sidebar)
- Shows selected OS and auth method
- Updates in real-time
- Compact, professional display
4. **Visual Enhancements**
- Premium card design with hover effects
- Purple theme gradient on primary button
- Smooth expand/collapse animations
- Professional icons throughout
- Enhanced spacing and typography
- Secure checkout badge at bottom
## Backend Changes
### 1. Checkout Controller (`app/Http/Controllers/Account/CheckoutController.php`)
```php
// Added validation for configuration
'configuration' => ['nullable', 'array'],
'configuration.os_template_id' => ['nullable', 'integer'],
'configuration.auth_method' => ['nullable', 'in:password,ssh'],
'configuration.ssh_key' => ['nullable', 'string', 'max:4096'],
// Store configuration in session for provisioning
if ($request->has('configuration')) {
session(['subscription_config_'.$subscription->id => $request->input('configuration')]);
}
```
### 2. VirtFusion Service (`app/Services/Provisioning/VirtFusionService.php`)
**New Build Step:**
After creating server and changing package, now automatically builds with:
- Selected OS template
- SSH key (if provided)
- Stores provisioning info in service record
```php
// Build server with OS template and SSH key
$config = session('subscription_config_'.$subscription->id, []);
$templateId = $config['os_template_id'] ?? 1; // Default to Ubuntu 24.04
$sshKey = $config['auth_method'] === 'ssh' && !empty($config['ssh_key']) ? $config['ssh_key'] : null;
$buildPayload = ['templateId' => $templateId];
if ($sshKey) {
$buildPayload['sshKeys'] = [$sshKey];
}
$buildResponse = $this->client()->post("/servers/{$serverId}/build", $buildPayload);
```
**Complete Provisioning Flow:**
1. Create server (package 43)
2. Change package to match plan specs
3. **Build server with selected OS + SSH key**
4. Send credentials email
### 3. Subscription Cancellation (`app/Listeners/HandleSubscriptionCancelled.php`)
**Added 5-Minute Grace Period:**
```php
// Wait 5 minutes before terminating services (grace period)
sleep(300);
```
This prevents accidental immediate termination and gives users time to reconsider.
## User Experience Flow
### Ordering VPS
1. **Select Plan** → Navigate to checkout
2. **Configure Server**:
- Choose operating system from dropdown
- Select authentication method (password or SSH key)
- If SSH: paste public key with helpful instructions
- See live preview in sidebar
3. **Choose Payment Method**
- Stripe (saved cards) or PayPal
- Apply coupon if available
4. **Complete Order**
- Premium gradient button with security badge
- Processing state with loading indicator
5. **Provisioning** (background):
- Create VirtFusion user (if needed)
- Create server
- Change to correct package specs
- **Build with selected OS and SSH key**
- Email credentials to customer
### Canceling Subscription
1. Cancel subscription via dashboard
2. **5-minute grace period** before termination
3. Service terminated on VirtFusion
4. Service status updated to "terminated"
5. Cancellation notification sent
## Configuration Storage
- **Frontend → Backend**: Passed in `configuration` object
- **Backend → Session**: Stored temporarily during checkout
- **Session → Provisioning**: Read during VirtFusion provisioning
- **Service Record**: Final config saved to `provisioning_info` JSON field
## VirtFusion API Calls
### Complete Provisioning Sequence:
```
POST /sanctum/csrf-cookie # Get CSRF token
GET /users?email={email} # Check if user exists
POST /users # Create user (if needed)
POST /servers # Create server (package 43)
POST /servers/{id}/changePackage # Change to plan specs
POST /servers/{id}/build # Build with OS + SSH key ← NEW
```
### Build Payload:
```json
{
"templateId": 1,
"sshKeys": ["ssh-rsa AAAAB3NzaC1yc2E..."] // optional
}
```
## Database Schema
### Services Table
- `provisioning_info` (JSON): Stores OS template ID and auth method
```json
{
"os_template_id": 1,
"auth_method": "ssh"
}
```
### Provisioning Logs
New entries for `build` action:
- `provision``change_package`**`build`** → complete
## Testing
1. ✅ Frontend builds without errors
2. ✅ Horizon restarted to reload code
3. ⏳ Create VPS subscription with:
- Ubuntu 24.04 + Password
- Debian 12 + SSH Key
4. ⏳ Verify VirtFusion server is actually built and installed
5. ⏳ Cancel subscription and verify 5-minute delay
## Default Values
- **Default OS**: Ubuntu 24.04 LTS (template ID 1)
- **Default Auth**: Auto-generated password
- **Grace Period**: 5 minutes (300 seconds)
## Visual Design
**Aesthetic**: Refined, professional cloud platform
- Clean card layout with subtle shadows
- Purple accent color (#7367F0)
- Smooth transitions and hover effects
- Professional icons (Material Design Icons)
- Monospace font for SSH key input
- Security badges and trust indicators
- Responsive layout (mobile-friendly)
**Key Design Elements:**
- Server config card has purple top border
- OS dropdown with icons for each option
- Toggle button for auth method selection
- Expandable SSH key textarea
- Live configuration preview in sidebar
- Gradient on checkout button
- Secure checkout badge
## Future Enhancements
1. **Fetch Templates from VirtFusion API** (currently hardcoded)
2. **Show available templates per hypervisor**
3. **SSH key validation** (format checking)
4. **Multiple SSH keys** support
5. **Save SSH keys** to user profile for reuse
6. **OS logos** instead of generic icons
7. **Template descriptions** with tooltips

2001
VPS_PLAN_REBUILD_2026.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
# EZSCALE VPS PLAN REBUILD - UPDATED WITH REAL INFRASTRUCTURE
**Based on Actual Discovery Data from 3 Production Nodes**
*Generated: February 9, 2026*
---
## CRITICAL UPDATE: YOUR INFRASTRUCTURE IS BETTER THAN ASSUMED
### What We Thought You Had:
- 6 servers with E5-2670 v2 CPUs
- ~150-200GB RAM per server
- SATA SSD storage only
- 25 VPS per server capacity
### What You ACTUALLY Have:
- **3 powerful servers with E5-2680 v2/v4 CPUs (faster)**
- **1,320GB total RAM** (503GB + 377GB + 440GB)
- **Mixed storage: 931GB NVMe + 13TB SATA SSD**
- **Only 60 VMs running** (massive capacity available)
**This changes EVERYTHING. You can be much more competitive.**
---
## YOUR REAL COMPETITIVE ADVANTAGES
### 1. RAM DENSITY - You're RAM-RICH
- Node 1: 503GB RAM (only using 272GB = 54% free)
- Node 2: 377GB RAM (only using 225GB = 40% free)
- Node 3: 440GB RAM (only using 112GB = 75% free)
**You can offer 2-4x more RAM than budget competitors at the same price.**
### 2. NVMe Storage Available
- Node 1 has 931GB NVMe RAID1 (currently using only 6.6GB!)
- **You can offer REAL NVMe VPS** (not just SATA like I assumed)
### 3. Massive Storage Capacity
- Node 2: 11.2TB SATA SSD RAID10 (64% used, 4.1TB free)
- Node 1: 7.3TB HDD RAID10 (1% used, 7.3TB free!)
**You can offer 500GB-1TB storage plans easily.**
### 4. Low Density = Better Performance
- Currently: ~20 VMs per server (not oversold)
- Competitors run 50-100 VMs per server
- **Your VMs will perform better with less noisy neighbors**
---
## NEW VPS PLAN LINEUP (Updated for Real Hardware)
### Strategy Shift: **RAM + NVMe as Primary Differentiators**
Since you have abundant RAM and NVMe, position yourself as:
> **"Premium Specs at Budget Prices - We're not oversold like Hetzner"**
| Plan | vCPU | RAM | Storage | Type | BW | Price/Mo | Target Margin | Note |
|------|------|-----|---------|------|----|---------| --------------|------|
| **Nano** | 1 | 1GB | 15GB | NVMe | 2TB | **$3.50** | 25% | Entry (compete with Vultr) |
| **Micro** | 1 | 2GB | 30GB | NVMe | 3TB | **$5.95** | 35% | ⭐ Budget hero |
| **Mini** | 2 | 4GB | 50GB | NVMe | 4TB | **$8.95** | 40% | ⭐⭐ MAIN HERO (RAM advantage) |
| **Standard** | 2 | 8GB | 80GB | NVMe | 6TB | **$14.95** | 45% | RAM beast (8GB @ $15!) |
| **Plus** | 4 | 12GB | 120GB | NVMe | 8TB | **$22.95** | 48% | High-RAM power user |
| **Pro** | 4 | 16GB | 160GB | NVMe | 10TB | **$29.95** | 50% | Ultimate RAM |
| **Storage-500** | 2 | 4GB | 500GB | SSD | 8TB | **$24.95** | 45% | Storage-focused |
| **Storage-1TB** | 4 | 8GB | 1TB | SSD | 12TB | **$44.95** | 50% | Mass storage |
### Why This Lineup Wins
**vs. Hetzner CX22 (€3.79 for 2vCPU/4GB/40GB SSD):**
- Our **Micro** ($5.95): 1vCPU/**2GB**/30GB **NVMe** - same RAM density, NVMe storage, US-based
- Our **Mini** ($8.95): 2vCPU/**4GB**/50GB **NVMe** - match their specs + NVMe + US-based for just $5 more
- Our **Standard** ($14.95): 2vCPU/**8GB**/80GB **NVMe** - **DOUBLE their RAM** for $11 more
**vs. Contabo ($4.95 for 4 cores/8GB/50GB NVMe):**
- We can't beat them on core count, BUT:
- Our **Mini** ($8.95): 2vCPU/4GB/50GB NVMe - same storage, half RAM, but US-based + support
- Our **Standard** ($14.95): 2vCPU/8GB/80GB NVMe - match RAM, more storage, US-based + support
**vs. DigitalOcean ($12 for 1vCPU/2GB/50GB):**
- Our **Mini** ($8.95): 2vCPU/**4GB**/50GB NVMe - **2x CPU, 2x RAM** for $3 less
- Our **Standard** ($14.95): 2vCPU/**8GB**/80GB NVMe - **4x RAM** for just $3 more
---
## REVISED POSITIONING: "RAM + NVMe BEAST"
### Marketing Angle
> **"Why settle for 2GB when you can have 4GB? Or 8GB for $15? We're not oversold."**
### Hero Plans
**Primary Hero: MINI ($8.95/mo)**
- 2 vCPU, **4GB RAM**, 50GB **NVMe**, 4TB bandwidth
- **Beats Hetzner CX22 specs** (same CPU/RAM, +10GB storage, NVMe vs SSD)
- **Beats DigitalOcean $12 plan** (2x CPU, 2x RAM, same storage, $3 less)
- US-based, <2hr support, VirtFusion
**Secondary Hero: STANDARD ($14.95/mo)**
- 2 vCPU, **8GB RAM**, 80GB **NVMe**, 6TB bandwidth
- **8GB for $15** - this is INSANE value (Hetzner charges €6.80 for 8GB)
- Target: Developers who need RAM (databases, caching, Docker)
**Entry Hero: MICRO ($5.95/mo)**
- 1 vCPU, **2GB RAM**, 30GB **NVMe**, 3TB bandwidth
- Beats Vultr $5 plan (1vCPU/1GB) - we give **2x RAM**
- Competes with Hetzner CX11 (€4.15 for 2GB) - we're $2 more but NVMe + US
---
## CAPACITY PLANNING (Real Numbers)
### Current State
- **60 VMs running** across 3 nodes
- **609GB RAM allocated** out of 1,320GB (only 46% utilization!)
- **Tons of room to grow**
### Realistic Capacity Per Node
**Node 1 (503GB RAM, 931GB NVMe):**
- Can host: **40-50 VMs** on NVMe storage
- Current: 28 VMs (28% of capacity)
- **Room for 12-22 more NVMe VMs**
**Node 2 (377GB RAM, 11.2TB SSD):**
- Can host: **30-40 VMs** (RAM-limited)
- Current: 22 VMs (55% capacity)
- **Room for 8-18 more VMs**
**Node 3 (440GB RAM, 1.8TB SSD):**
- Can host: **35-45 VMs** (storage-limited)
- Current: 10 VMs (22% capacity!)
- **Room for 25-35 more VMs**
**Total Capacity:** 105-135 VMs across 3 nodes (you're at 60 now)
**Growth Headroom:** 45-75 more VPS before needing new hardware
### If LowEndBox Launch Brings 200 Signups
- You'd hit ~260 total VMs
- You'd need to add 1-2 more servers (~$6-12k)
- This is MUCH better than my original projection (which assumed 6 servers already)
---
## COST ANALYSIS (Real Hardware)
### Per-Server Operating Cost
**Node 1 (atl-01):**
- Power: ~180W avg × 24h × 30d = 129.6 kWh/mo × $0.12 = $15.55/mo
- Cooling: $15.55 × 30% = $4.67/mo
- Datacenter: $50/mo (rack space)
- Network: $20/mo (1Gbps port)
- **Total: $90.22/month**
**Node 2 (atl-02):**
- Power: ~200W avg (more drives) × 24h × 30d = 144 kWh/mo × $0.12 = $17.28/mo
- Cooling: $17.28 × 30% = $5.18/mo
- Datacenter: $50/mo
- Network: $20/mo (1Gbps port)
- **Total: $92.46/month**
**Node 3 (atl-03):**
- Power: ~150W avg × 24h × 30d = 108 kWh/mo × $0.12 = $12.96/mo
- Cooling: $12.96 × 30% = $3.89/mo
- Datacenter: $50/mo
- Network: $20/mo (1Gbps port)
- **Total: $86.85/month**
**Total Operating Cost: $269.53/month** for all 3 nodes
### Per-VPS Economics (60 VMs currently)
**Current State:**
- Total cost: $269.53/month
- 60 VMs running
- **Cost per VPS: $4.49/month** (infrastructure only)
**At Full Capacity (120 VMs):**
- Total cost: $269.53/month (same hardware)
- 120 VMs running
- **Cost per VPS: $2.25/month** (infrastructure only)
### Margin Analysis by Plan (at 120 VPS density)
| Plan | Price | Infra Cost | BW Cost | Support | Total Cost | Margin | Margin % |
|------|-------|------------|---------|---------|------------|--------|----------|
| Nano | $3.50 | $2.25 | $0.50 | $0.50 | $3.25 | $0.25 | 7% (loss leader) |
| Micro | $5.95 | $2.25 | $0.75 | $0.75 | $3.75 | $2.20 | 37% |
| **Mini** | **$8.95** | **$2.25** | **$1.00** | **$1.00** | **$4.25** | **$4.70** | **53%** |
| **Standard** | **$14.95** | **$2.25** | **$1.50** | **$1.25** | **$5.00** | **$9.95** | **67%** |
| Plus | $22.95 | $2.25 | $2.00 | $1.50 | $5.75 | $17.20 | 75% |
| Pro | $29.95 | $2.25 | $2.50 | $1.75 | $6.50 | $23.45 | 78% |
**Key Insight:** Your margins are MUCH HIGHER than I originally calculated because:
1. You have bigger servers (fewer servers needed = lower cost per VPS)
2. You have tons of RAM (can run more VPS per server)
3. Hardware is paid off (no depreciation cost)
---
## GRANDFATHERING STRATEGY (Same as Before)
The grandfathering strategy from the original plan still applies, but you have MORE flexibility now because your margins are better.
**You can afford to be generous:**
- Give existing customers free RAM upgrades (you have 711GB free RAM!)
- Grandfather ALL old plans at old pricing
- Offer free NVMe migrations (move customers from HDD to NVMe)
---
## LAUNCH STRATEGY (Updated)
### Phase 1: NVMe Migration for Existing Customers (Week 1)
**Offer:** Free migration to NVMe for all existing customers on Node 1
- "We just added 931GB of NVMe storage - want faster disks for free?"
- This will fill up Node 1's NVMe quickly and show goodwill
### Phase 2: Soft Launch with RAM Focus (Week 2)
**Hero Plan Promo:**
- Mini: $6.95 for first 3 months (vs $8.95 regular) - "4GB for $7"
- Standard: $11.95 for first 3 months (vs $14.95 regular) - "8GB for $12"
**Marketing Message:**
> "Tired of 1GB VPS? Get 4GB for $7. Or 8GB for $12. NVMe included."
### Phase 3: LowEndBox Launch (Week 3)
**Post Title:**
> [EZSCALE] NVMe VPS with DOUBLE the RAM - Starting $5.95/mo | US-Based with Real Support
**Promo Code: `LEB2026RAM`**
- Micro: $4.95 for first 3 months (2GB NVMe for $5)
- Mini: $6.95 for first 3 months (4GB NVMe for $7)
- Standard: $11.95 for first 3 months (8GB NVMe for $12)
**Positioning:**
- Lead with RAM (you have tons of it)
- Highlight NVMe (real competitive advantage)
- Show Hetzner/DO comparison table (you win on RAM)
---
## UPDATED COMPETITIVE COMPARISON
### Head-to-Head: EZSCALE vs Market Leaders
| Provider | Price | vCPU | RAM | Storage | BW | Notes |
|----------|-------|------|-----|---------|----|-EOFPLAN

View File

@@ -0,0 +1,278 @@
# EZSCALE VPS PLAN UPDATE - Real Infrastructure Analysis
**Critical Update Based on Discovery Data (Feb 9, 2026)**
---
## YOUR ACTUAL INFRASTRUCTURE IS MUCH BETTER
### What the Original Plan Assumed:
- 6 budget servers with E5-2670 v2 CPUs
- ~150GB RAM per server
- SATA SSD storage only
- Conservative capacity
### What You ACTUALLY Have:
**3 powerful nodes** with E5-2680 v2/v4 CPUs (28-40 cores each)
**1,320GB total RAM** (503GB + 377GB + 440GB) - **You're RAM-RICH!**
**931GB NVMe** + **13TB SATA SSD** storage
**Only 60 VMs running** (massive growth capacity)
**Low density** (20 VMs/server vs competitors' 50-100)
---
## MAJOR STRATEGY CHANGES
### 1. Position on RAM + NVMe (Not Just Support)
**OLD Strategy:** "We can't beat Hetzner on specs, compete on support"
**NEW Strategy:** "We CAN beat them - we have NVMe + tons of RAM + support"
### 2. NEW Competitive Advantages
**RAM Abundance:**
- 711GB RAM currently unused (54% free!)
- Can offer 2-4x more RAM than competitors at same price
- **8GB for $15** beats Hetzner's €6.80 pricing
**NVMe Storage:**
- Node 1: 931GB NVMe (only 6.6GB used - 99% free!)
- Can offer real NVMe VPS (not just marketing)
- Competitors charge premium for NVMe, you have it built-in
**Low Density = Performance:**
- 60 VMs on 1,320GB RAM = very comfortable
- Competitors oversell 50-100 VMs per server
- Your customers get better performance (less noisy neighbors)
---
## REVISED VPS LINEUP
### New 8-Tier Plan (RAM-Focused)
| Plan | vCPU | RAM | Storage | Type | BW | Price/Mo | vs Competitors |
|------|------|-----|---------|------|----|---------| ---------------|
| **Nano** | 1 | 1GB | 15GB | NVMe | 2TB | **$3.50** | Entry price point |
| **Micro** | 1 | **2GB** | 30GB | NVMe | 3TB | **$5.95** | 2x RAM of Vultr $5 |
| **Mini** | 2 | **4GB** | 50GB | NVMe | 4TB | **$8.95** | ⭐ Hero (matches Hetzner CX22 + NVMe) |
| **Standard** | 2 | **8GB** | 80GB | NVMe | 6TB | **$14.95** | ⭐⭐ Main Hero (8GB for $15!) |
| **Plus** | 4 | **12GB** | 120GB | NVMe | 8TB | **$22.95** | High-RAM option |
| **Pro** | 4 | **16GB** | 160GB | NVMe | 10TB | **$29.95** | Ultimate RAM |
| **Storage-500** | 2 | 4GB | **500GB** | SSD | 8TB | **$24.95** | Storage-focused |
| **Storage-1TB** | 4 | 8GB | **1TB** | SSD | 12TB | **$44.95** | Mass storage |
### Hero Plan Analysis
**MINI ($8.95/mo) - Primary Hero:**
- 2 vCPU, 4GB RAM, 50GB NVMe, 4TB BW
- **vs Hetzner CX22** (€3.79/$4): Same specs + NVMe + US location for +$5
- **vs DigitalOcean** ($12): 2x CPU, 2x RAM for -$3
- **Target:** 40% of new signups
**STANDARD ($14.95/mo) - Secondary Hero:**
- 2 vCPU, **8GB RAM**, 80GB NVMe, 6TB BW
- **8GB for $15** - Hetzner charges €6.80 (~$7.20) for just 4GB
- **vs DigitalOcean** ($24): Same specs for -$9
- **Target:** 25% of new signups
---
## CAPACITY ANALYSIS
### Current Utilization
| Node | RAM | VMs | RAM Allocated | RAM Free | % Used | Room to Grow |
|------|-----|-----|---------------|----------|--------|--------------|
| Node 1 | 503GB | 28 | 272GB | 231GB | 54% | +20-25 VMs |
| Node 2 | 377GB | 22 | 225GB | 152GB | 60% | +12-15 VMs |
| Node 3 | 440GB | 10 | 112GB | 328GB | 25% | +30-35 VMs |
| **Total** | **1,320GB** | **60** | **609GB** | **711GB** | **46%** | **+62-75 VMs** |
**Current Capacity:** 60 VMs running
**Realistic Max:** 120-135 VMs (before needing new hardware)
**Growth Headroom:** 62-75 more VPS
### LowEndBox Launch Scenario
If 200 signups in month 1:
- Total VMs: 260 (60 existing + 200 new)
- You'd need: 1-2 additional servers (~$6-12k investment)
- This is MUCH better than original projection (which assumed 6 servers already)
---
## UPDATED MARGINS (Much Better!)
### Cost Structure (at 120 VPS density)
**Per-Server Operating Cost:**
- All 3 nodes: $269.53/month total
- At 120 VPS: **$2.25/VPS infrastructure cost** (vs $5/VPS in original plan)
**Margin by Plan:**
| Plan | Price | Cost | Margin | Margin % |
|------|-------|------|--------|----------|
| Nano | $3.50 | $3.25 | $0.25 | 7% (loss leader) |
| **Micro** | **$5.95** | **$3.75** | **$2.20** | **37%** |
| **Mini** | **$8.95** | **$4.25** | **$4.70** | **53%** |
| **Standard** | **$14.95** | **$5.00** | **$9.95** | **67%** |
| Plus | $22.95 | $5.75 | $17.20 | 75% |
| Pro | $29.95 | $6.50 | $23.45 | 78% |
**Key Insight:** Margins are 2-3x better than original plan because:
1. Fewer servers needed (3 vs 6 assumed)
2. More RAM per server (can run more VPS)
3. Better hardware = better efficiency
---
## GRANDFATHERING UPDATES
With better margins, you can be MORE generous:
1. **Free NVMe migrations** - Move existing customers from Node 1 HDD to NVMe (you have 924GB free!)
2. **Free RAM upgrades** - Bump 1GB customers to 2GB, 2GB to 4GB (you have 711GB unused RAM!)
3. **All old plans grandfathered** at old pricing forever
4. **No forced migrations** - let customers stay on legacy plans indefinitely
---
## UPDATED MARKETING MESSAGING
### OLD Positioning (Based on Wrong Assumptions):
> "We can't beat Hetzner on specs, but we have better support and are US-based"
### NEW Positioning (Based on Real Hardware):
> **"Premium Specs at Budget Prices - NVMe + Double RAM + US Support"**
### Comparison Table for Website
| Feature | EZSCALE Mini | Hetzner CX22 | DigitalOcean | Winner |
|---------|--------------|--------------|--------------|--------|
| **Price** | **$8.95/mo** | €3.79 (~$4) | $12/mo | Hetzner (price) |
| **vCPU** | 2 | 2 | 1 | EZSCALE/Hetzner |
| **RAM** | **4GB** | 4GB | 2GB | **EZSCALE**/Hetzner |
| **Storage** | **50GB NVMe** | 40GB SSD | 50GB SSD | **EZSCALE** (NVMe) |
| **Bandwidth** | 4TB | 20TB (EU) / 1TB (US) | 2TB | Hetzner (EU) |
| **Support** | **<2hr response** | 24-48hr email | 4-12hr | **EZSCALE** |
| **Location** | **US East** | Germany | US | **EZSCALE** (latency) |
| **Control Panel** | **VirtFusion** | Basic custom | Good custom | **EZSCALE** |
**Overall:** EZSCALE wins on 4/8 factors (storage type, support, location, control panel)
---
## LOWEND BOX POST (Updated)
```markdown
[EZSCALE] NVMe VPS with Double the RAM | Starting $3.50/mo | US-Based | Real Support
─────────────────────────────────────────────────────────────────────────────────
Tired of waiting 48 hours for support? Want NVMe without paying a premium?
EZSCALE delivers budget VPS with generous RAM, real NVMe, and <2hr support.
🎯 LAUNCH SPECIAL (Code: LEB2026RAM)
├─ Nano: $3.50/mo (1vCPU, 1GB RAM, 15GB NVMe, 2TB BW)
├─ Micro: $4.95/mo first 3mo (1vCPU, 2GB RAM, 30GB NVMe, 3TB BW) - reg $5.95
├─ Mini: $6.95/mo first 3mo (2vCPU, 4GB RAM, 50GB NVMe, 4TB BW) - reg $8.95 ⭐
└─ Standard: $11.95/mo first 3mo (2vCPU, 8GB RAM, 80GB NVMe, 6TB BW) - reg $14.95
After promo ends: $3.50, $5.95, $8.95, $14.95 respectively
✅ Real NVMe storage (not marketing, actual PCIe NVMe drives)
✅ <2 hour average ticket response (we track this publicly)
✅ Low-density servers (20 VPS/server vs competitors' 50-100)
✅ US-based infrastructure (Virginia datacenter, <15ms from NYC)
✅ VirtFusion control panel (modern UI, one-click reinstalls, API)
✅ 30-day money-back guarantee
📊 FULL LINEUP:
┌──────────┬──────┬─────┬──────────┬──────────┬──────────┐
│ Plan │ vCPU │ RAM │ Storage │ Bandwidth│ Price/Mo │
├──────────┼──────┼─────┼──────────┼──────────┼──────────┤
│ Nano │ 1 │ 1GB │ 15GB NVMe│ 2TB │ $3.50 │
│ Micro │ 1 │ 2GB │ 30GB NVMe│ 3TB │ $5.95 │
│ Mini │ 2 │ 4GB │ 50GB NVMe│ 4TB │ $8.95 │
│ Standard │ 2 │ 8GB │ 80GB NVMe│ 6TB │ $14.95 │
│ Plus │ 4 │12GB │120GB NVMe│ 8TB │ $22.95 │
│ Pro │ 4 │16GB │160GB NVMe│ 10TB │ $29.95 │
└──────────┴──────┴─────┴──────────┴──────────┴──────────┘
📍 Location: Ashburn, Virginia (US East)
🔧 Network: 1Gbps ports, Premium Tier 1 bandwidth
💳 Payment: PayPal, Stripe, Bitcoin
📊 Uptime: 99.9% SLA with public status page
🆚 WHY EZSCALE?
• Hetzner CX22 is €3.79 for 4GB BUT: 120ms latency from US, 24-48hr support, only 40GB storage
• DigitalOcean is $12 for 2GB BUT: 50% more expensive, half the RAM, same storage
• We're US-based with NVMe + generous RAM + support that actually responds
🎁 FREE: Migration assistance from any competitor (we handle everything)
[ORDER NOW] → https://ezscale.cloud/vps?promo=LEB2026RAM
──────────────────────────────────────────────────────────
ABOUT US:
We run 3 high-spec servers (1.3TB total RAM!) with low VPS density, which means
your VPS gets real resources. When you open a ticket at 2am, we respond in <2 hours.
AMA below - I'll answer questions about our infrastructure, NVMe setup, network, etc.
Offer valid through [2 weeks from post]. Limited to first 150 signups.
```
---
## ACTION ITEMS (Updated Priority)
### Week 1: Leverage Your NVMe
- [ ] **Offer free NVMe migrations** to existing customers on Node 1
- Shows goodwill, fills up NVMe capacity
- "We just added 931GB NVMe - want faster disks for free?"
- [ ] **Update VirtFusion** with new 8-tier plan lineup
- Nano through Pro on Node 1/3 (NVMe)
- Storage-500/1TB on Node 2 (11.2TB SATA SSD)
### Week 2: Update Marketing
- [ ] **Rewrite pricing page** - lead with RAM + NVMe
- "4GB for $9, 8GB for $15 - with NVMe included"
- Comparison table vs Hetzner/DO/Vultr
- [ ] **Create comparison tool** - interactive RAM/storage calculator
- "How much RAM do you need? We probably have more for less"
### Week 3: LowEndBox Launch
- [ ] Post with code `LEB2026RAM`
- [ ] Focus messaging on RAM + NVMe (not just support)
- [ ] Target 100-150 signups (you have capacity!)
---
## CONCLUSION
**Your infrastructure is 2-3x better than I assumed.** This completely changes your competitive positioning:
1. **You CAN compete on specs** (not just support)
2. **You have NVMe** (real competitive advantage)
3. **You have tons of RAM** (offer 2-4x more than competitors)
4. **Your margins are excellent** (67% on Standard plan!)
5. **You have room to grow** (60-75 more VPS before new hardware)
**Bottom Line:** You're not a "budget specs + good support" provider.
You're a **"premium specs at budget prices"** provider.
Position accordingly.
---
## Files to Reference
1. Original full strategy: `VPS_PLAN_REBUILD_2026.md`
2. This update: `VPS_PLAN_UPDATE_REAL_INFRASTRUCTURE.md`
3. Infrastructure discovery: `ezscale-discovery-20260208-163247/`
**Next:** Update Laravel seeders with new 8-tier plan lineup?

354
discover.sh Executable file
View File

@@ -0,0 +1,354 @@
#!/bin/bash
# ============================================================
# EzScale Hypervisor Discovery Script
# ============================================================
# Run from your JumpHost. Connects to each node via SSH as root
# and collects hardware, storage, network, and VM info.
#
# Usage:
# chmod +x ezscale-discover.sh
# ./ezscale-discover.sh
#
# Output: Creates a report file per node + combined summary
# ============================================================
set -euo pipefail
NODES=("vf-node-01" "vf-node-02" "vf-node-03")
SSH_USER="root"
SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
OUTPUT_DIR="./ezscale-discovery-$(date +%Y%m%d-%H%M%S)"
SUMMARY_FILE="$OUTPUT_DIR/00-SUMMARY.txt"
mkdir -p "$OUTPUT_DIR"
# Colors for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
echo -e "${CYAN}=========================================${NC}"
echo -e "${CYAN} EzScale Hypervisor Discovery${NC}"
echo -e "${CYAN}=========================================${NC}"
echo ""
# Remote command block — runs on each node
read -r -d '' REMOTE_SCRIPT << 'REMOTECMD' || true
#!/bin/bash
echo "===== SYSTEM INFO ====="
echo "--- Hostname ---"
hostname -f 2>/dev/null || hostname
echo ""
echo "--- OS ---"
cat /etc/os-release 2>/dev/null | grep -E "^(NAME|VERSION|PRETTY)" || echo "Unknown OS"
echo ""
echo "--- Kernel ---"
uname -r
echo ""
echo "--- Uptime ---"
uptime
echo ""
echo "===== CPU ====="
echo "--- Model ---"
grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs
echo ""
echo "--- Physical CPUs ---"
grep "physical id" /proc/cpuinfo | sort -u | wc -l
echo ""
echo "--- Cores per CPU ---"
grep "cpu cores" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs
echo ""
echo "--- Total Threads ---"
nproc
echo ""
echo "--- CPU Flags (virt) ---"
grep -oE '(vmx|svm|ept|npt)' /proc/cpuinfo | sort -u | tr '\n' ' '
echo ""
echo ""
echo "--- lscpu summary ---"
lscpu | grep -E "(Model name|Socket|Core|Thread|CPU\(s\)|MHz|cache)" 2>/dev/null
echo ""
echo "===== MEMORY ====="
echo "--- Total / Used / Free ---"
free -h
echo ""
echo "--- DIMM Details ---"
dmidecode -t memory 2>/dev/null | grep -E "(Size|Speed|Type|Locator)" | grep -v "No Module" | head -40 || echo "dmidecode not available or no permission"
echo ""
echo "===== STORAGE - BLOCK DEVICES ====="
echo "--- lsblk ---"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,ROTA,MODEL 2>/dev/null || lsblk
echo ""
echo "--- Disk Models ---"
lsblk -d -o NAME,SIZE,MODEL,ROTA,TRAN 2>/dev/null || echo "N/A"
echo ""
echo "===== STORAGE - FILESYSTEM ====="
echo "--- df -h ---"
df -h | grep -v tmpfs | grep -v udev
echo ""
echo "===== STORAGE - ZFS ====="
if command -v zpool &>/dev/null; then
echo "--- ZFS Pools ---"
zpool list -v 2>/dev/null || echo "No ZFS pools"
echo ""
echo "--- ZFS Pool Status ---"
zpool status 2>/dev/null || echo "N/A"
echo ""
echo "--- ZFS Datasets ---"
zfs list -o name,used,avail,refer,mountpoint 2>/dev/null || echo "N/A"
echo ""
echo "--- ZFS Properties (compression, ashift, recordsize) ---"
zfs get compression,ashift,recordsize -t filesystem 2>/dev/null | head -30 || echo "N/A"
echo ""
else
echo "ZFS not installed"
echo ""
fi
echo "===== STORAGE - LVM ====="
if command -v pvs &>/dev/null; then
echo "--- Physical Volumes ---"
pvs 2>/dev/null || echo "No LVM PVs"
echo ""
echo "--- Volume Groups ---"
vgs 2>/dev/null || echo "No LVM VGs"
echo ""
echo "--- Logical Volumes ---"
lvs -o lv_name,vg_name,lv_size,lv_attr 2>/dev/null || echo "No LVM LVs"
echo ""
else
echo "LVM not installed"
echo ""
fi
echo "===== STORAGE - MDADM (Software RAID) ====="
if [ -f /proc/mdstat ]; then
cat /proc/mdstat
else
echo "No mdadm RAID"
fi
echo ""
echo "===== NETWORK ====="
echo "--- Interfaces ---"
ip -br addr show 2>/dev/null || ifconfig -a 2>/dev/null
echo ""
echo "--- Interface Speeds ---"
for iface in $(ls /sys/class/net/ | grep -v lo); do
speed=$(cat /sys/class/net/$iface/speed 2>/dev/null || echo "N/A")
driver=$(ethtool -i $iface 2>/dev/null | grep driver | awk '{print $2}' || echo "N/A")
echo " $iface: ${speed}Mbps (driver: $driver)"
done
echo ""
echo "--- Default Route ---"
ip route | grep default
echo ""
echo "--- Bridge / Bond Config ---"
if command -v brctl &>/dev/null; then
brctl show 2>/dev/null || echo "No bridges"
fi
ip link show type bridge 2>/dev/null | grep -E "^[0-9]" || echo ""
ip link show type bond 2>/dev/null | grep -E "^[0-9]" || echo ""
echo ""
echo "===== LIBVIRT / KVM ====="
if command -v virsh &>/dev/null; then
echo "--- Libvirt Version ---"
virsh version --daemon 2>/dev/null || virsh version 2>/dev/null
echo ""
echo "--- All VMs (running + stopped) ---"
virsh list --all
echo ""
echo "--- Running VM Count ---"
echo "$(virsh list --state-running --name | grep -c .)"
echo ""
echo "--- VM Resource Usage ---"
echo "VM_NAME | vCPUs | RAM_MAX | STATE"
echo "--------|-------|---------|------"
for vm in $(virsh list --name --state-running 2>/dev/null); do
vcpus=$(virsh dominfo "$vm" 2>/dev/null | grep "CPU(s)" | awk '{print $2}')
maxmem=$(virsh dominfo "$vm" 2>/dev/null | grep "Max memory" | awk '{print $3, $4}')
state=$(virsh dominfo "$vm" 2>/dev/null | grep "State" | cut -d: -f2 | xargs)
echo "$vm | $vcpus | $maxmem | $state"
done
echo ""
echo "--- Total Allocated vCPUs (running VMs) ---"
total_vcpus=0
for vm in $(virsh list --name --state-running 2>/dev/null); do
v=$(virsh dominfo "$vm" 2>/dev/null | grep "CPU(s)" | awk '{print $2}')
total_vcpus=$((total_vcpus + v))
done
echo "$total_vcpus"
echo ""
echo "--- Total Allocated RAM (running VMs) ---"
total_ram=0
for vm in $(virsh list --name --state-running 2>/dev/null); do
r=$(virsh dominfo "$vm" 2>/dev/null | grep "Max memory" | awk '{print $3}')
total_ram=$((total_ram + r))
done
echo "$((total_ram / 1024)) MB ($((total_ram / 1024 / 1024)) GB)"
echo ""
echo "--- VM Disk Locations ---"
for vm in $(virsh list --name --all 2>/dev/null); do
echo "[$vm]"
virsh domblklist "$vm" --details 2>/dev/null | grep -E "file.*disk" || echo " (no disks found)"
done
echo ""
echo "--- Storage Pools ---"
virsh pool-list --all 2>/dev/null || echo "No storage pools"
echo ""
for pool in $(virsh pool-list --name 2>/dev/null); do
echo "--- Pool: $pool ---"
virsh pool-info "$pool" 2>/dev/null
echo ""
done
else
echo "libvirt/virsh not installed"
echo ""
fi
echo "===== QEMU ====="
if command -v qemu-system-x86_64 &>/dev/null; then
echo "--- QEMU Version ---"
qemu-system-x86_64 --version 2>/dev/null | head -1
elif command -v kvm &>/dev/null; then
echo "--- KVM Version ---"
kvm --version 2>/dev/null | head -1
else
echo "QEMU binary not found in PATH"
fi
echo ""
echo "===== DISK USAGE BY VM IMAGES ====="
echo "--- qcow2 files ---"
find / -name "*.qcow2" -type f 2>/dev/null | while read f; do
size=$(du -h "$f" 2>/dev/null | awk '{print $1}')
virtual=$(qemu-img info "$f" 2>/dev/null | grep "virtual size" | awk '{print $3, $4}' || echo "N/A")
echo " $f (actual: $size, virtual: $virtual)"
done
echo ""
echo "--- raw disk files ---"
find / -name "*.raw" -type f 2>/dev/null | while read f; do
size=$(du -h "$f" 2>/dev/null | awk '{print $1}')
echo " $f (actual: $size)"
done
echo ""
echo "===== SERVICES ====="
echo "--- Key Services Status ---"
for svc in libvirtd qemu-guest-agent virtfusion virtfusion-agent zfs-zed zfs-import-cache zfs-mount; do
status=$(systemctl is-active "$svc" 2>/dev/null || echo "not-found")
echo " $svc: $status"
done
echo ""
echo "===== RESOURCE SUMMARY ====="
echo "--- CPU ---"
total_threads=$(nproc)
allocated_vcpus=$total_vcpus
echo " Total threads: $total_threads"
echo " Allocated vCPUs: $allocated_vcpus"
echo " Overcommit ratio: $(echo "scale=2; $allocated_vcpus / $total_threads" | bc 2>/dev/null || echo 'N/A')"
echo ""
echo "--- RAM ---"
total_ram_mb=$(free -m | awk '/Mem:/ {print $2}')
echo " Total RAM: ${total_ram_mb} MB ($((total_ram_mb / 1024)) GB)"
echo " Allocated to VMs: $((total_ram / 1024)) MB ($((total_ram / 1024 / 1024)) GB)"
echo " Free for host/new VMs: $((total_ram_mb - total_ram / 1024)) MB"
echo " Utilization: $(echo "scale=1; $total_ram / 1024 / $total_ram_mb * 100" | bc 2>/dev/null || echo 'N/A')%"
echo ""
echo "===== END ====="
REMOTECMD
# Run on each node
for node in "${NODES[@]}"; do
report_file="$OUTPUT_DIR/${node}.txt"
echo -e "${YELLOW}Connecting to ${node}...${NC}"
if ssh $SSH_OPTS ${SSH_USER}@${node} "echo ok" &>/dev/null; then
echo -e "${GREEN} Connected. Gathering data...${NC}"
ssh $SSH_OPTS ${SSH_USER}@${node} "$REMOTE_SCRIPT" > "$report_file" 2>&1
echo -e "${GREEN} Done → ${report_file}${NC}"
else
echo -e "${RED} FAILED to connect to ${node}${NC}"
echo "CONNECTION FAILED" > "$report_file"
fi
echo ""
done
# Build combined summary
echo -e "${CYAN}Building summary...${NC}"
{
echo "============================================"
echo " EzScale Infrastructure Discovery Summary"
echo " Generated: $(date)"
echo "============================================"
echo ""
for node in "${NODES[@]}"; do
report_file="$OUTPUT_DIR/${node}.txt"
if [ -f "$report_file" ] && ! grep -q "CONNECTION FAILED" "$report_file"; then
echo "============================================"
echo " NODE: ${node}"
echo "============================================"
# Extract key metrics — use "-- PATTERN" so grep doesn't
# interpret leading dashes as options
echo ""
echo " OS: $(grep -A1 -- '--- OS ---' "$report_file" | grep 'PRETTY' | cut -d= -f2 | tr -d '"')"
echo " Kernel: $(grep -A1 -- '--- Kernel ---' "$report_file" | tail -1)"
echo " CPU: $(grep -A1 -- '--- Model ---' "$report_file" | tail -1)"
echo " Sockets: $(grep -A1 -- '--- Physical CPUs ---' "$report_file" | tail -1)"
echo " Cores: $(grep -A1 -- '--- Cores per CPU ---' "$report_file" | tail -1)"
echo " Threads: $(grep -A1 -- '--- Total Threads ---' "$report_file" | tail -1)"
# RAM
total_ram=$(grep -A2 -- '--- Total / Used / Free ---' "$report_file" | grep 'Mem:' | awk '{print $2}')
used_ram=$(grep -A2 -- '--- Total / Used / Free ---' "$report_file" | grep 'Mem:' | awk '{print $3}')
echo " RAM: ${total_ram} total, ${used_ram} used"
# VM counts
running=$(grep -A1 -- '--- Running VM Count ---' "$report_file" | tail -1)
echo " VMs: ${running} running"
# Allocated resources
alloc_vcpus=$(grep -A1 -- '--- Total Allocated vCPUs' "$report_file" | tail -1)
alloc_ram=$(grep -A1 -- '--- Total Allocated RAM' "$report_file" | tail -1)
echo " Alloc: ${alloc_vcpus} vCPUs, ${alloc_ram} RAM"
# Overcommit
overcommit=$(grep -A5 -- '--- CPU ---' "$report_file" | grep 'Overcommit' | awk '{print $NF}')
ram_util=$(grep -A8 -- '--- RAM ---' "$report_file" | grep 'Utilization' | awk '{print $NF}')
echo " CPU overcommit: ${overcommit}x"
echo " RAM utilization: ${ram_util}"
echo ""
else
echo "============================================"
echo " NODE: ${node} — CONNECTION FAILED"
echo "============================================"
echo ""
fi
done
} > "$SUMMARY_FILE"
echo -e "${GREEN}Summary → ${SUMMARY_FILE}${NC}"
echo ""
echo -e "${CYAN}=========================================${NC}"
echo -e "${CYAN} Discovery Complete${NC}"
echo -e "${CYAN}=========================================${NC}"
echo ""
echo "Full reports: ${OUTPUT_DIR}/"
echo "Summary: ${SUMMARY_FILE}"
echo ""
echo "Next step: Share the contents of ${OUTPUT_DIR}/ and I can"
echo "analyze your capacity and finalize the product line."

View File

@@ -0,0 +1,53 @@
============================================
EzScale Infrastructure Discovery Summary
Generated: Sun Feb 8 16:33:12 UTC 2026
============================================
============================================
NODE: vf-node-01
============================================
OS:
Kernel: 5.14.0-611.11.1.el9_7.x86_64
CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
Sockets: 2
Cores: 14
Threads: 56
RAM: 503Gi total, 212Gi used
VMs: 28 running
Alloc: 108 vCPUs, 278528 MB (272 GB) RAM
CPU overcommit: 1.92x
RAM utilization: 50.0%
============================================
NODE: vf-node-02
============================================
OS:
Kernel: 5.14.0-611.11.1.el9_7.x86_64
CPU: Intel(R) Xeon(R) CPU E5-2680 v2 @ 2.80GHz
Sockets: 2
Cores: 10
Threads: 40
RAM: 377Gi total, 189Gi used
VMs: 22 running
Alloc: 93 vCPUs, 230400 MB (225 GB) RAM
CPU overcommit: N/Ax
RAM utilization: N/A%
============================================
NODE: vf-node-03
============================================
OS:
Kernel: 5.14.0-570.49.1.el9_6.x86_64
CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
Sockets: 2
Cores: 14
Threads: 56
RAM: 440Gi total, 180Gi used
VMs: 10 running
Alloc: 46 vCPUs, 114688 MB (112 GB) RAM
CPU overcommit: .82x
RAM utilization: 20.0%

View File

@@ -0,0 +1,525 @@
===== SYSTEM INFO =====
--- Hostname ---
atl-01.node.vps.ezscale.tech
--- OS ---
NAME="AlmaLinux"
VERSION="9.7 (Moss Jungle Cat)"
VERSION_ID="9.7"
PRETTY_NAME="AlmaLinux 9.7 (Moss Jungle Cat)"
--- Kernel ---
5.14.0-611.11.1.el9_7.x86_64
--- Uptime ---
11:32:47 up 59 days, 13:24, 0 users, load average: 5.82, 6.17, 6.34
===== CPU =====
--- Model ---
Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
--- Physical CPUs ---
2
--- Cores per CPU ---
14
--- Total Threads ---
56
--- CPU Flags (virt) ---
ept vmx
--- lscpu summary ---
CPU(s): 56
On-line CPU(s) list: 0-55
Model name: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
BIOS Model name: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
Thread(s) per core: 2
Core(s) per socket: 14
Socket(s): 2
CPU(s) scaling MHz: 97%
CPU max MHz: 2900.0000
CPU min MHz: 1200.0000
L1d cache: 896 KiB (28 instances)
L1i cache: 896 KiB (28 instances)
L2 cache: 7 MiB (28 instances)
L3 cache: 70 MiB (2 instances)
NUMA node0 CPU(s): 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54
NUMA node1 CPU(s): 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49,51,53,55
Vulnerability L1tf: Mitigation; PTE Inversion; VMX conditional cache flushes, SMT vulnerable
===== MEMORY =====
--- Total / Used / Free ---
total used free shared buff/cache available
Mem: 503Gi 212Gi 2.9Gi 627Mi 291Gi 290Gi
Swap: 4.0Gi 4.0Gi 0.0Ki
--- DIMM Details ---
Error Correction Type: Multi-bit ECC
Size: 64 GB
Locator: A1
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered) LRDIMM
Speed: 2400 MT/s
Configured Memory Speed: 2400 MT/s
Size: 64 GB
Locator: A2
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered) LRDIMM
Speed: 2400 MT/s
Configured Memory Speed: 2400 MT/s
Size: 64 GB
Locator: A3
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered) LRDIMM
Speed: 2400 MT/s
Configured Memory Speed: 2400 MT/s
Locator: A4
Bank Locator: Not Specified
Type: Unknown
Type Detail: None
Size: 64 GB
Locator: A5
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered) LRDIMM
Speed: 2400 MT/s
Configured Memory Speed: 2400 MT/s
Locator: A6
Bank Locator: Not Specified
Type: Unknown
Type Detail: None
Locator: A7
Bank Locator: Not Specified
Type: Unknown
===== STORAGE - BLOCK DEVICES =====
--- lsblk ---
NAME SIZE TYPE FSTYPE MOUNTPOINT ROTA MODEL
sda 223.6G disk 0 MTFDDAK240MBP 01EJ260 01EJ260IBM
├─sda1 1G part xfs /boot 0
└─sda2 222.6G part LVM2_member 0
├─almalinux-root 70G lvm xfs / 0
├─almalinux-swap 4G lvm swap [SWAP] 0
└─almalinux-home 148.6G lvm xfs /home 0
sdb 3.6T disk linux_raid_member 1 MG04SCA40EN
└─md1 7.3T raid10 xfs /mnt/bulk_vms 1
sdc 3.6T disk linux_raid_member 1 MG04SCA40EN
└─md1 7.3T raid10 xfs /mnt/bulk_vms 1
sdd 3.6T disk linux_raid_member 1 MG04SCA40EN
└─md1 7.3T raid10 xfs /mnt/bulk_vms 1
sde 3.6T disk linux_raid_member 1 MG04SCA40EN
└─md1 7.3T raid10 xfs /mnt/bulk_vms 1
nvme1n1 931.5G disk linux_raid_member 0 Sabrent Rocket 4.0 1TB
└─md0 931.4G raid1 xfs /mnt/nvme_vms 0
nvme2n1 931.5G disk linux_raid_member 0 Sabrent Rocket 4.0 1TB
└─md0 931.4G raid1 xfs /mnt/nvme_vms 0
nvme0n1 465.8G disk 0 Sabrent Rocket 4.0 500GB
--- Disk Models ---
NAME SIZE MODEL ROTA TRAN
sda 223.6G MTFDDAK240MBP 01EJ260 01EJ260IBM 0 sas
sdb 3.6T MG04SCA40EN 1 sas
sdc 3.6T MG04SCA40EN 1 sas
sdd 3.6T MG04SCA40EN 1 sas
sde 3.6T MG04SCA40EN 1 sas
nvme1n1 931.5G Sabrent Rocket 4.0 1TB 0 nvme
nvme2n1 931.5G Sabrent Rocket 4.0 1TB 0 nvme
nvme0n1 465.8G Sabrent Rocket 4.0 500GB 0 nvme
===== STORAGE - FILESYSTEM =====
--- df -h ---
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/almalinux-root 70G 5.4G 65G 8% /
/dev/sda1 960M 463M 498M 49% /boot
/dev/mapper/almalinux-home 149G 57G 92G 39% /home
/dev/md0 931G 6.6G 925G 1% /mnt/nvme_vms
/dev/md1 7.3T 52G 7.3T 1% /mnt/bulk_vms
192.168.10.3:/mnt/data/vms 12T 7.2T 4.1T 64% /mnt/vms
===== STORAGE - ZFS =====
ZFS not installed
===== STORAGE - LVM =====
--- Physical Volumes ---
PV VG Fmt Attr PSize PFree
/dev/sda2 almalinux lvm2 a-- <222.57g 0
--- Volume Groups ---
VG #PV #LV #SN Attr VSize VFree
almalinux 1 3 0 wz--n- <222.57g 0
--- Logical Volumes ---
LV VG LSize Attr
home almalinux <148.57g -wi-ao----
root almalinux 70.00g -wi-ao----
swap almalinux 4.00g -wi-ao----
===== STORAGE - MDADM (Software RAID) =====
Personalities : [raid1] [raid10]
md1 : active raid10 sdd[0] sde[3] sdc[2] sdb[1]
7813772288 blocks super 1.2 512K chunks 2 near-copies [4/4] [UUUU]
bitmap: 0/59 pages [0KB], 65536KB chunk
md0 : active raid1 nvme1n1[1] nvme2n1[0]
976630464 blocks super 1.2 [2/2] [UU]
bitmap: 0/8 pages [0KB], 65536KB chunk
unused devices: <none>
===== NETWORK =====
--- Interfaces ---
lo UNKNOWN 127.0.0.1/8 ::1/128
eno3 DOWN
eno4 DOWN
eno1 UP
eno2 UP 192.168.10.2/29
br0 UP 66.186.37.253/25 2602:2f3:ff:105::b/64 fe80::6240:606f:8445:594f/64
5668246001 UNKNOWN fe80::fc5a:41ff:fe05:483/64
7952655313 UNKNOWN fe80::fcd9:1fff:fef5:7fdc/64
9669788654 UNKNOWN fe80::fc6e:f4ff:fe67:347a/64
1585574411 UNKNOWN fe80::fc67:96ff:feea:ce07/64
6538424133 UNKNOWN fe80::fc95:9eff:fe0c:7ae0/64
7575455714 UNKNOWN fe80::fcce:70ff:fed5:3823/64
1900074498 UNKNOWN fe80::fc2f:2dff:fe1c:6f20/64
6868627519 UNKNOWN fe80::fc2c:a0ff:fed3:f24b/64
8290678221 UNKNOWN fe80::fcd2:6aff:fea2:c868/64
3144884207 UNKNOWN fe80::fcd2:c1ff:fe4f:da96/64
9864921145 UNKNOWN fe80::fc90:ceff:febd:8a83/64
3854082875 UNKNOWN fe80::fc08:cbff:fe96:16e/64
2847939492 UNKNOWN fe80::fc9f:1cff:fe83:6e3f/64
2270446172 UNKNOWN fe80::fced:2cff:feab:d352/64
5375909748 UNKNOWN fe80::fc57:c6ff:fe73:57ae/64
9709633390 UNKNOWN fe80::fcd1:aff:fead:7805/64
6686699813 UNKNOWN fe80::fc65:61ff:fea3:754/64
2577660031 UNKNOWN fe80::fc8b:12ff:fe20:fc7d/64
1873685436 UNKNOWN fe80::fc94:5cff:fe67:d27a/64
8339041496 UNKNOWN fe80::fc75:bdff:fee4:e7b0/64
4635498757 UNKNOWN fe80::fcd6:e1ff:fe7f:d368/64
2739236756 UNKNOWN fe80::fcdb:1cff:fefe:6d6/64
5274046373 UNKNOWN fe80::fc85:cbff:fe49:5b14/64
2672200667 UNKNOWN fe80::fcfb:32ff:fe12:73ae/64
1473682335 UNKNOWN fe80::fcf6:edff:fe0a:4fc2/64
1197738280 UNKNOWN fe80::fcd0:98ff:fe65:c3d3/64
7928453883 UNKNOWN fe80::fc26:13ff:fed4:b9eb/64
4723927902 UNKNOWN fe80::fc7a:57ff:fea1:32b6/64
6775358137 UNKNOWN fe80::fcb4:d2ff:fed9:2709/64
--- Interface Speeds ---
1197738280: 10Mbps (driver: tun)
1473682335: 10Mbps (driver: tun)
1585574411: 10Mbps (driver: tun)
1873685436: 10Mbps (driver: tun)
1900074498: 10Mbps (driver: tun)
2270446172: 10Mbps (driver: tun)
2577660031: 10Mbps (driver: tun)
2672200667: 10Mbps (driver: tun)
2739236756: 10Mbps (driver: tun)
2847939492: 10Mbps (driver: tun)
3144884207: 10Mbps (driver: tun)
3854082875: 10Mbps (driver: tun)
4635498757: 10Mbps (driver: tun)
4723927902: 10Mbps (driver: tun)
5274046373: 10Mbps (driver: tun)
5375909748: 10Mbps (driver: tun)
5668246001: 10Mbps (driver: tun)
6538424133: 10Mbps (driver: tun)
6686699813: 10Mbps (driver: tun)
6775358137: 10Mbps (driver: tun)
6868627519: 10Mbps (driver: tun)
7575455714: 10Mbps (driver: tun)
7928453883: 10Mbps (driver: tun)
7952655313: 10Mbps (driver: tun)
8290678221: 10Mbps (driver: tun)
8339041496: 10Mbps (driver: tun)
9669788654: 10Mbps (driver: tun)
9709633390: 10Mbps (driver: tun)
9864921145: 10Mbps (driver: tun)
br0: 10000Mbps (driver: bridge)
eno1: 10000Mbps (driver: ixgbe)
eno2: 10000Mbps (driver: ixgbe)
eno3: -1Mbps (driver: igb)
eno4: -1Mbps (driver: igb)
--- Default Route ---
default via 66.186.37.129 dev br0 proto static metric 10
--- Bridge / Bond Config ---
bridge name bridge id STP enabled interfaces
br0 8000.246e96a63f40 yes 1197738280
1473682335
1585574411
1873685436
1900074498
2270446172
2577660031
2672200667
2739236756
2847939492
3144884207
3854082875
4635498757
4723927902
5274046373
5375909748
5668246001
6538424133
6686699813
6775358137
6868627519
7575455714
7928453883
7952655313
8290678221
8339041496
9669788654
9709633390
9864921145
eno1
6: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
===== LIBVIRT / KVM =====
--- Libvirt Version ---
Compiled against library: libvirt 10.10.0
Using library: libvirt 10.10.0
Using API: QEMU 10.10.0
Running hypervisor: QEMU 9.1.0
Running against daemon: 10.10.0
--- All VMs (running + stopped) ---
Id Name State
------------------------------------------------------
6 b5d24674-c0e6-487f-94b6-d3c378108244 running
9 b6a57a3b-d4f9-4804-aa2a-fd5c40514e86 running
10 203239a0-3222-42bc-aace-2f9633ae91b3 running
11 47bb28c5-cedb-425d-97fa-c71321481b19 running
14 4a951d30-4aea-4f13-b591-8d6ef3d15bac running
18 85a274bd-a625-4301-8975-1040ebb0111e running
19 33c942cf-f311-428b-bbdc-6808bc0c4de0 running
21 409c693b-d165-4bdb-81af-d7a7ce23eaa7 running
25 b7dc324d-9b3b-41c7-a572-57bef6874c84 running
27 c434d488-44a6-4d76-8530-818a086b0b3c running
29 8babf155-7b38-490b-815b-faf507cd0cdc running
30 4b16b482-d871-4404-b641-b33d4103fa00 running
31 415c3951-acf3-411f-ae3f-a8a4456b8e92 running
34 4a56b2e2-1655-4893-8f2a-e909be9f5d5a running
35 95e44451-1bce-4270-82d1-2f29a404d9dd running
36 6e50481d-ac2d-4492-a44e-19fa1da29bd6 running
48 2d704a2a-3a0a-432e-89b4-2f04516b771e paused
50 41b20d00-f4c4-4064-8e1d-7bd284bcb773 running
53 3c0b6c1a-a6b5-4522-b458-31e3773b3d0d running
54 83deb44e-acf3-4631-b757-44c00b88b256 running
56 88fef797-3629-4e09-85a7-500ff6d7b05c running
57 dddeb4bf-149b-4271-af3e-9125bcad38a8 running
60 0df53bc1-d5bd-4843-87c0-32a81bb9afcc running
61 44caf596-e1ca-43cc-a44f-576441be8c48 running
62 2fbf4caf-e7c0-4874-9f41-71555b593de0 running
64 1e6bc3e6-9044-489a-92dc-243f929d2d05 running
67 068bafe5-f180-4763-b1c0-a1a75028929f running
68 be0cfe25-04af-4865-afd3-eb5796f7e018 running
69 fbc7f30d-df58-4f86-b36b-608bfbf2c483 running
--- Running VM Count ---
28
--- VM Resource Usage ---
VM_NAME | vCPUs | RAM_MAX | STATE
--------|-------|---------|------
b5d24674-c0e6-487f-94b6-d3c378108244 | 4 | 8388608 KiB | running
b6a57a3b-d4f9-4804-aa2a-fd5c40514e86 | 1 | 1048576 KiB | running
203239a0-3222-42bc-aace-2f9633ae91b3 | 4 | 8388608 KiB | running
47bb28c5-cedb-425d-97fa-c71321481b19 | 6 | 16777216 KiB | running
4a951d30-4aea-4f13-b591-8d6ef3d15bac | 4 | 8388608 KiB | running
85a274bd-a625-4301-8975-1040ebb0111e | 6 | 16777216 KiB | running
33c942cf-f311-428b-bbdc-6808bc0c4de0 | 8 | 33554432 KiB | running
409c693b-d165-4bdb-81af-d7a7ce23eaa7 | 1 | 2097152 KiB | running
b7dc324d-9b3b-41c7-a572-57bef6874c84 | 4 | 8388608 KiB | running
c434d488-44a6-4d76-8530-818a086b0b3c | 4 | 8388608 KiB | running
8babf155-7b38-490b-815b-faf507cd0cdc | 1 | 1048576 KiB | running
4b16b482-d871-4404-b641-b33d4103fa00 | 8 | 33554432 KiB | running
415c3951-acf3-411f-ae3f-a8a4456b8e92 | 2 | 2097152 KiB | running
4a56b2e2-1655-4893-8f2a-e909be9f5d5a | 2 | 2097152 KiB | running
95e44451-1bce-4270-82d1-2f29a404d9dd | 2 | 8388608 KiB | running
6e50481d-ac2d-4492-a44e-19fa1da29bd6 | 2 | 4194304 KiB | running
41b20d00-f4c4-4064-8e1d-7bd284bcb773 | 1 | 1048576 KiB | running
3c0b6c1a-a6b5-4522-b458-31e3773b3d0d | 4 | 8388608 KiB | running
83deb44e-acf3-4631-b757-44c00b88b256 | 4 | 8388608 KiB | running
88fef797-3629-4e09-85a7-500ff6d7b05c | 8 | 33554432 KiB | running
dddeb4bf-149b-4271-af3e-9125bcad38a8 | 4 | 8388608 KiB | running
0df53bc1-d5bd-4843-87c0-32a81bb9afcc | 8 | 16777216 KiB | running
44caf596-e1ca-43cc-a44f-576441be8c48 | 4 | 8388608 KiB | running
2fbf4caf-e7c0-4874-9f41-71555b593de0 | 6 | 16777216 KiB | running
1e6bc3e6-9044-489a-92dc-243f929d2d05 | 1 | 2097152 KiB | running
068bafe5-f180-4763-b1c0-a1a75028929f | 1 | 1048576 KiB | running
be0cfe25-04af-4865-afd3-eb5796f7e018 | 4 | 8388608 KiB | running
fbc7f30d-df58-4f86-b36b-608bfbf2c483 | 4 | 8388608 KiB | running
--- Total Allocated vCPUs (running VMs) ---
108
--- Total Allocated RAM (running VMs) ---
278528 MB (272 GB)
--- VM Disk Locations ---
[b5d24674-c0e6-487f-94b6-d3c378108244]
file disk vda /mnt/vms/b5d24674-c0e6-487f-94b6-d3c378108244_1.img
file disk sdx /home/vf-data/server/b5d24674-c0e6-487f-94b6-d3c378108244/cloud-drive.img
[b6a57a3b-d4f9-4804-aa2a-fd5c40514e86]
file disk vda /mnt/vms/b6a57a3b-d4f9-4804-aa2a-fd5c40514e86_1.img
file disk sdx /home/vf-data/server/b6a57a3b-d4f9-4804-aa2a-fd5c40514e86/cloud-drive.img
[203239a0-3222-42bc-aace-2f9633ae91b3]
file disk vda /mnt/vms/203239a0-3222-42bc-aace-2f9633ae91b3_1.img
file disk sdx /home/vf-data/server/203239a0-3222-42bc-aace-2f9633ae91b3/cloud-drive.img
[47bb28c5-cedb-425d-97fa-c71321481b19]
file disk vda /mnt/vms/47bb28c5-cedb-425d-97fa-c71321481b19_1.img
file disk sdx /home/vf-data/server/47bb28c5-cedb-425d-97fa-c71321481b19/cloud-drive.img
[4a951d30-4aea-4f13-b591-8d6ef3d15bac]
file disk vda /mnt/vms/4a951d30-4aea-4f13-b591-8d6ef3d15bac_1.img
file disk sdx /home/vf-data/server/4a951d30-4aea-4f13-b591-8d6ef3d15bac/cloud-drive.img
[85a274bd-a625-4301-8975-1040ebb0111e]
file disk vda /mnt/vms/85a274bd-a625-4301-8975-1040ebb0111e_1.img
file disk sdx /home/vf-data/server/85a274bd-a625-4301-8975-1040ebb0111e/cloud-drive.img
[33c942cf-f311-428b-bbdc-6808bc0c4de0]
file disk vda /mnt/vms/33c942cf-f311-428b-bbdc-6808bc0c4de0_1.img
file disk sdx /home/vf-data/server/33c942cf-f311-428b-bbdc-6808bc0c4de0/cloud-drive.img
[409c693b-d165-4bdb-81af-d7a7ce23eaa7]
file disk vda /mnt/vms/409c693b-d165-4bdb-81af-d7a7ce23eaa7_1.img
file disk sdx /home/vf-data/server/409c693b-d165-4bdb-81af-d7a7ce23eaa7/cloud-drive.img
[b7dc324d-9b3b-41c7-a572-57bef6874c84]
file disk vda /mnt/vms/b7dc324d-9b3b-41c7-a572-57bef6874c84_1.img
file disk sdx /home/vf-data/server/b7dc324d-9b3b-41c7-a572-57bef6874c84/cloud-drive.img
[c434d488-44a6-4d76-8530-818a086b0b3c]
file disk vda /mnt/vms/c434d488-44a6-4d76-8530-818a086b0b3c_1.img
file disk sdx /home/vf-data/server/c434d488-44a6-4d76-8530-818a086b0b3c/cloud-drive.img
[8babf155-7b38-490b-815b-faf507cd0cdc]
file disk vda /mnt/vms/8babf155-7b38-490b-815b-faf507cd0cdc_1.img
file disk sdx /home/vf-data/server/8babf155-7b38-490b-815b-faf507cd0cdc/cloud-drive.img
[4b16b482-d871-4404-b641-b33d4103fa00]
file disk vda /mnt/vms/4b16b482-d871-4404-b641-b33d4103fa00_1.img
file disk sdx /home/vf-data/server/4b16b482-d871-4404-b641-b33d4103fa00/cloud-drive.img
[415c3951-acf3-411f-ae3f-a8a4456b8e92]
file disk vda /mnt/vms/415c3951-acf3-411f-ae3f-a8a4456b8e92_1.img
file disk sdx /home/vf-data/server/415c3951-acf3-411f-ae3f-a8a4456b8e92/cloud-drive.img
[4a56b2e2-1655-4893-8f2a-e909be9f5d5a]
file disk vda /mnt/vms/4a56b2e2-1655-4893-8f2a-e909be9f5d5a_1.img
file disk sdx /home/vf-data/server/4a56b2e2-1655-4893-8f2a-e909be9f5d5a/cloud-drive.img
[95e44451-1bce-4270-82d1-2f29a404d9dd]
file disk vda /mnt/vms/95e44451-1bce-4270-82d1-2f29a404d9dd_1.img
file disk sdx /home/vf-data/server/95e44451-1bce-4270-82d1-2f29a404d9dd/cloud-drive.img
[6e50481d-ac2d-4492-a44e-19fa1da29bd6]
file disk vda /mnt/vms/6e50481d-ac2d-4492-a44e-19fa1da29bd6_1.img
file disk sdx /home/vf-data/server/6e50481d-ac2d-4492-a44e-19fa1da29bd6/cloud-drive.img
[2d704a2a-3a0a-432e-89b4-2f04516b771e]
file disk vda /mnt/vms/2d704a2a-3a0a-432e-89b4-2f04516b771e_1.img
file disk sdx /home/vf-data/server/2d704a2a-3a0a-432e-89b4-2f04516b771e/cloud-drive.img
[41b20d00-f4c4-4064-8e1d-7bd284bcb773]
file disk vda /mnt/vms/41b20d00-f4c4-4064-8e1d-7bd284bcb773_1.img
file disk sdx /home/vf-data/server/41b20d00-f4c4-4064-8e1d-7bd284bcb773/cloud-drive.img
[3c0b6c1a-a6b5-4522-b458-31e3773b3d0d]
file disk vda /mnt/vms/3c0b6c1a-a6b5-4522-b458-31e3773b3d0d_1.img
file disk sdx /home/vf-data/server/3c0b6c1a-a6b5-4522-b458-31e3773b3d0d/cloud-drive.img
[83deb44e-acf3-4631-b757-44c00b88b256]
file disk vda /mnt/vms/83deb44e-acf3-4631-b757-44c00b88b256_1.img
file disk sdx /home/vf-data/server/83deb44e-acf3-4631-b757-44c00b88b256/cloud-drive.img
[88fef797-3629-4e09-85a7-500ff6d7b05c]
file disk vda /mnt/vms/88fef797-3629-4e09-85a7-500ff6d7b05c_1.img
file disk sdx /home/vf-data/server/88fef797-3629-4e09-85a7-500ff6d7b05c/cloud-drive.img
[dddeb4bf-149b-4271-af3e-9125bcad38a8]
file disk vda /mnt/vms/dddeb4bf-149b-4271-af3e-9125bcad38a8_1.img
file disk sdx /home/vf-data/server/dddeb4bf-149b-4271-af3e-9125bcad38a8/cloud-drive.img
[0df53bc1-d5bd-4843-87c0-32a81bb9afcc]
file disk vda /mnt/vms/0df53bc1-d5bd-4843-87c0-32a81bb9afcc_1.img
file disk sdx /home/vf-data/server/0df53bc1-d5bd-4843-87c0-32a81bb9afcc/cloud-drive.img
[44caf596-e1ca-43cc-a44f-576441be8c48]
file disk vda /mnt/vms/44caf596-e1ca-43cc-a44f-576441be8c48_1.img
file disk sdx /home/vf-data/server/44caf596-e1ca-43cc-a44f-576441be8c48/cloud-drive.img
[2fbf4caf-e7c0-4874-9f41-71555b593de0]
file disk vda /mnt/vms/2fbf4caf-e7c0-4874-9f41-71555b593de0_1.img
file disk sdx /home/vf-data/server/2fbf4caf-e7c0-4874-9f41-71555b593de0/cloud-drive.img
[1e6bc3e6-9044-489a-92dc-243f929d2d05]
file disk vda /mnt/vms/1e6bc3e6-9044-489a-92dc-243f929d2d05_1.img
file disk sdx /home/vf-data/server/1e6bc3e6-9044-489a-92dc-243f929d2d05/cloud-drive.img
[068bafe5-f180-4763-b1c0-a1a75028929f]
file disk vda /mnt/vms/068bafe5-f180-4763-b1c0-a1a75028929f_1.img
file disk sdx /home/vf-data/server/068bafe5-f180-4763-b1c0-a1a75028929f/cloud-drive.img
[be0cfe25-04af-4865-afd3-eb5796f7e018]
file disk vda /mnt/vms/be0cfe25-04af-4865-afd3-eb5796f7e018_1.img
file disk sdx /home/vf-data/server/be0cfe25-04af-4865-afd3-eb5796f7e018/cloud-drive.img
[fbc7f30d-df58-4f86-b36b-608bfbf2c483]
file disk vda /mnt/vms/fbc7f30d-df58-4f86-b36b-608bfbf2c483_1.img
file disk sdx /home/vf-data/server/fbc7f30d-df58-4f86-b36b-608bfbf2c483/cloud-drive.img
--- Storage Pools ---
Name State Autostart
---------------------------
===== QEMU =====
--- QEMU Version ---
QEMU emulator version 9.1.0 (qemu-kvm-9.1.0-29.el9_7.alma.1)
===== DISK USAGE BY VM IMAGES =====
--- qcow2 files ---
/home/vf-data/os/template/almalinux-9-gnome-x86_64-2023-04-25.qcow2 (actual: 1.1G, virtual: 4.88 GiB)
/home/vf-data/os/template/cloudlinux-9-latest-x86_64.qcow2 (actual: 1.1G, virtual: 10 GiB)
/home/vf-data/os/template/ubuntu-jammy-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 643M, virtual: 2.2 GiB)
/home/vf-data/os/template/ubuntu-lunar-server-cloudimg-amd64.qcow2 (actual: 722M, virtual: 3.5 GiB)
/home/vf-data/os/template/ubuntu-bionic-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 387M, virtual: 2.2 GiB)
/home/vf-data/os/template/ubuntu-focal-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 613M, virtual: 2.2 GiB)
/home/vf-data/os/template/debian-12-x86_64-2023-06-11.qcow2 (actual: 441M, virtual: 1.95 GiB)
/home/vf-data/os/template/alma-linux-8-minimal-x86_64-2024-01-27.qcow2 (actual: 666M, virtual: 10 GiB)
/home/vf-data/os/template/windows_server_2012_r2_standard.qcow2 (actual: 12G, virtual: 12.2 GiB)
/home/vf-data/os/template/ubuntu-noble-server-cloudimg-amd64-2024-04-25.qcow2 (actual: 558M, virtual: 3.5 GiB)
/home/vf-data/os/template/centos-7-minimal-x86-64.qcow2 (actual: 446M, virtual: 1.95 GiB)
/home/vf-data/os/template/debian-11-xfce-x86_64-2023-04-25.qcow2 (actual: 1.3G, virtual: 4.88 GiB)
/home/vf-data/os/template/fedora-40-x86-64.qcow2 (actual: 380M, virtual: 5 GiB)
/home/vf-data/os/template/debian-11-x86_64-2023-04-25.qcow2 (actual: 373M, virtual: 1.95 GiB)
/home/vf-data/os/template/debian-12-ext4-x86_64-2025-03-13.qcow2 (actual: 403M, virtual: 1.95 GiB)
/home/vf-data/os/template/windows-server-2025-standard-2024-11-06.qcow2 (actual: 5.9G, virtual: 12.2 GiB)
/home/vf-data/os/template/centos-8-3-x86-64.qcow2 (actual: 1.3G, virtual: 10 GiB)
/home/vf-data/os/template/windows-server-2019-standard-2024-03-06.qcow2 (actual: 5.8G, virtual: 13.7 GiB)
/home/vf-data/os/template/almalinux-9-x86_64-2024-11-20.qcow2 (actual: 507M, virtual: 10 GiB)
/home/vf-data/os/template/centos-stream-8-minimal-x86_64.qcow2 (actual: 486M, virtual: 3.61 GiB)
/home/vf-data/os/template/almalinux-10-x86-64.qcow2 (actual: 439M, virtual: 10 GiB)
/home/vf-data/os/template/fedora-42-x86-64.qcow2 (actual: 508M, virtual: 5 GiB)
/home/vf-data/os/template/windows-server-2022-standard-2024-03-06.qcow2 (actual: 5.5G, virtual: 12.2 GiB)
--- raw disk files ---
===== SERVICES =====
--- Key Services Status ---
libvirtd: inactive
not-found
qemu-guest-agent: inactive
not-found
virtfusion: inactive
not-found
virtfusion-agent: inactive
not-found
zfs-zed: inactive
not-found
zfs-import-cache: inactive
not-found
zfs-mount: inactive
not-found
===== RESOURCE SUMMARY =====
--- CPU ---
Total threads: 56
Allocated vCPUs: 108
Overcommit ratio: 1.92
--- RAM ---
Total RAM: 515361 MB (503 GB)
Allocated to VMs: 278528 MB (272 GB)
Free for host/new VMs: 236833 MB
Utilization: 50.0%
===== END =====

View File

@@ -0,0 +1,514 @@
===== SYSTEM INFO =====
--- Hostname ---
atl-02.node.vps.ezscale.tech
--- OS ---
NAME="AlmaLinux"
VERSION="9.7 (Moss Jungle Cat)"
VERSION_ID="9.7"
PRETTY_NAME="AlmaLinux 9.7 (Moss Jungle Cat)"
--- Kernel ---
5.14.0-611.11.1.el9_7.x86_64
--- Uptime ---
11:32:57 up 59 days, 13:42, 0 users, load average: 5.48, 6.85, 6.74
===== CPU =====
--- Model ---
Intel(R) Xeon(R) CPU E5-2680 v2 @ 2.80GHz
--- Physical CPUs ---
2
--- Cores per CPU ---
10
--- Total Threads ---
40
--- CPU Flags (virt) ---
ept vmx
--- lscpu summary ---
CPU(s): 40
On-line CPU(s) list: 0-39
Model name: Intel(R) Xeon(R) CPU E5-2680 v2 @ 2.80GHz
BIOS Model name: Intel(R) Xeon(R) CPU E5-2680 v2 @ 2.80GHz
Thread(s) per core: 2
Core(s) per socket: 10
Socket(s): 2
CPU(s) scaling MHz: 86%
CPU max MHz: 3600.0000
CPU min MHz: 1200.0000
L1d cache: 640 KiB (20 instances)
L1i cache: 640 KiB (20 instances)
L2 cache: 5 MiB (20 instances)
L3 cache: 50 MiB (2 instances)
NUMA node0 CPU(s): 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38
NUMA node1 CPU(s): 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39
Vulnerability L1tf: Mitigation; PTE Inversion; VMX conditional cache flushes, SMT vulnerable
===== MEMORY =====
--- Total / Used / Free ---
total used free shared buff/cache available
Mem: 377Gi 189Gi 2.7Gi 1.6Gi 189Gi 188Gi
Swap: 4.0Gi 4.0Gi 5.0Mi
--- DIMM Details ---
Error Correction Type: Multi-bit ECC
Size: 16 GB
Locator: DIMM_A1
Bank Locator: Not Specified
Type: DDR3
Type Detail: Synchronous Registered (Buffered)
Speed: 1600 MT/s
Configured Memory Speed: 1333 MT/s
Size: 16 GB
Locator: DIMM_A2
Bank Locator: Not Specified
Type: DDR3
Type Detail: Synchronous Registered (Buffered)
Speed: 1600 MT/s
Configured Memory Speed: 1333 MT/s
Size: 16 GB
Locator: DIMM_A3
Bank Locator: Not Specified
Type: DDR3
Type Detail: Synchronous Registered (Buffered)
Speed: 1600 MT/s
Configured Memory Speed: 1333 MT/s
Size: 16 GB
Locator: DIMM_A4
Bank Locator: Not Specified
Type: DDR3
Type Detail: Synchronous Registered (Buffered)
Speed: 1600 MT/s
Configured Memory Speed: 1333 MT/s
Size: 16 GB
Locator: DIMM_A5
Bank Locator: Not Specified
Type: DDR3
Type Detail: Synchronous Registered (Buffered)
Speed: 1600 MT/s
Configured Memory Speed: 1333 MT/s
Size: 16 GB
Locator: DIMM_A6
Bank Locator: Not Specified
Type: DDR3
===== STORAGE - BLOCK DEVICES =====
--- lsblk ---
NAME SIZE TYPE FSTYPE MOUNTPOINT ROTA MODEL
sda 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdb 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdc 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdd 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sde 279.4G disk ddf_raid_member 1 AL13SEB300
├─md126 278.9G raid1 1
│ ├─md126p1 1G part xfs /boot 1
│ └─md126p2 277.9G part LVM2_member 1
│ ├─almalinux-root 70G lvm xfs / 1
│ ├─almalinux-swap 4G lvm swap [SWAP] 1
│ └─almalinux-home 203.9G lvm xfs /home 1
└─md127 0B md 0
sdf 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdg 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdh 279.4G disk ddf_raid_member 1 AL13SEB300
├─md126 278.9G raid1 1
│ ├─md126p1 1G part xfs /boot 1
│ └─md126p2 277.9G part LVM2_member 1
│ ├─almalinux-root 70G lvm xfs / 1
│ ├─almalinux-swap 4G lvm swap [SWAP] 1
│ └─almalinux-home 203.9G lvm xfs /home 1
└─md127 0B md 0
sdi 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdj 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdk 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdl 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdm 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
sdn 1.9T disk linux_raid_member 0 Micron_1100_MTFDDAK2T0TBN
└─md0 11.2T raid10 LVM2_member 0
└─vg_vm-lv_vm 11.2T lvm xfs /mnt/data 0
--- Disk Models ---
NAME SIZE MODEL ROTA TRAN
sda 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdb 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdc 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdd 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sde 279.4G AL13SEB300 1 sas
sdf 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdg 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdh 279.4G AL13SEB300 1 sas
sdi 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdj 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdk 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdl 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdm 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
sdn 1.9T Micron_1100_MTFDDAK2T0TBN 0 sas
===== STORAGE - FILESYSTEM =====
--- df -h ---
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/almalinux-root 70G 9.8G 61G 15% /
/dev/md126p1 960M 397M 564M 42% /boot
/dev/mapper/almalinux-home 204G 45G 160G 22% /home
/dev/mapper/vg_vm-lv_vm 12T 7.2T 4.1T 64% /mnt/data
localhost:/mnt/data/vms 12T 7.2T 4.1T 64% /mnt/vms
===== STORAGE - ZFS =====
ZFS not installed
===== STORAGE - LVM =====
--- Physical Volumes ---
PV VG Fmt Attr PSize PFree
/dev/md0 vg_vm lvm2 a-- <11.18t 0
/dev/md126p2 almalinux lvm2 a-- 277.87g 0
--- Volume Groups ---
VG #PV #LV #SN Attr VSize VFree
almalinux 1 3 0 wz--n- 277.87g 0
vg_vm 1 1 0 wz--n- <11.18t 0
--- Logical Volumes ---
LV VG LSize Attr
home almalinux 203.87g -wi-ao----
root almalinux 70.00g -wi-ao----
swap almalinux 4.00g -wi-ao----
lv_vm vg_vm <11.18t -wi-ao----
===== STORAGE - MDADM (Software RAID) =====
Personalities : [raid1] [raid10]
md0 : active raid10 sdk[1] sdn[0] sdg[2] sdd[5] sdj[11] sdi[8] sdf[6] sdc[4] sdm[3] sdb[9] sda[10] sdl[7]
12001597440 blocks super 1.2 512K chunks 2 near-copies [12/12] [UUUUUUUUUUUU]
bitmap: 36/90 pages [144KB], 65536KB chunk
md126 : active raid1 sdh[1] sde[0]
292421632 blocks super external:/md127/0 [2/2] [UU]
md127 : inactive sdh[1](S) sde[0](S)
1094236 blocks super external:ddf
unused devices: <none>
===== NETWORK =====
--- Interfaces ---
lo UNKNOWN 127.0.0.1/8 ::1/128
eno1 UP
eno2 DOWN
eno3 UP 172.20.254.253/16
enp68s0f0 UP 192.168.10.3/29 fe80::a236:9fff:fe42:febc/64
eno4 DOWN
enp68s0f1 DOWN
br0 UP 66.186.37.254/25 2605:bb00:c010:4::3/64 fe80::dbb6:6f64:abf5:c9a4/64
4142776250 UNKNOWN fe80::fcae:98ff:fe9b:9169/64
3687292804@eno3 UP fe80::2c0:21ff:fe51:49c7/64
9865730239 UNKNOWN fe80::fcc8:51ff:fe9e:f946/64
4480397161 UNKNOWN fe80::fc41:37ff:fef7:544a/64
6226104096 UNKNOWN fe80::fc4c:25ff:fede:57d6/64
1512514452 UNKNOWN fe80::fc37:dfff:fec2:e703/64
5691952363 UNKNOWN fe80::fc94:5cff:fe53:23b/64
4793215883 UNKNOWN fe80::fc3e:3aff:fe53:690c/64
9065882520 UNKNOWN fe80::fc25:f3ff:fe86:256c/64
8232538989 UNKNOWN fe80::fc5d:f2ff:feba:3b6d/64
1876239691 UNKNOWN fe80::fc89:cdff:feb1:f4ea/64
5579955486 UNKNOWN fe80::fc20:26ff:feab:ace1/64
1384622875 UNKNOWN fe80::fc37:94ff:fee9:6f46/64
3473746128 UNKNOWN fe80::fc62:fcff:fe5b:656a/64
6844105562 UNKNOWN fe80::fc69:e3ff:fe51:837c/64
2285253971 UNKNOWN fe80::fc43:26ff:feea:ec4f/64
4965374655 UNKNOWN fe80::fce8:fff:fe38:5de7/64
3975024440 UNKNOWN fe80::fcf9:f2ff:fe93:87b6/64
8318991295 UNKNOWN fe80::fc16:faff:feeb:fc18/64
6899839531 UNKNOWN fe80::fcbf:b3ff:fe7f:faa6/64
5136141790 UNKNOWN fe80::fc7a:b2ff:fee9:f0fa/64
6945298055 UNKNOWN fe80::fce2:d3ff:fed8:fc28/64
6641190120 UNKNOWN fe80::fc4f:73ff:fea1:71c/64
--- Interface Speeds ---
1384622875: 10Mbps (driver: tun)
1512514452: 10Mbps (driver: tun)
1876239691: 10Mbps (driver: tun)
2285253971: 10Mbps (driver: tun)
3473746128: 10Mbps (driver: tun)
3687292804: 1000Mbps (driver: macvlan)
3975024440: 10Mbps (driver: tun)
4142776250: 10Mbps (driver: tun)
4480397161: 10Mbps (driver: tun)
4793215883: 10Mbps (driver: tun)
4965374655: 10Mbps (driver: tun)
5136141790: 10Mbps (driver: tun)
5579955486: 10Mbps (driver: tun)
5691952363: 10Mbps (driver: tun)
6226104096: 10Mbps (driver: tun)
6641190120: 10Mbps (driver: tun)
6844105562: 10Mbps (driver: tun)
6899839531: 10Mbps (driver: tun)
6945298055: 10Mbps (driver: tun)
8232538989: 10Mbps (driver: tun)
8318991295: 10Mbps (driver: tun)
9065882520: 10Mbps (driver: tun)
9865730239: 10Mbps (driver: tun)
br0: 1000Mbps (driver: bridge)
eno1: 1000Mbps (driver: igb)
eno2: -1Mbps (driver: igb)
eno3: 1000Mbps (driver: igb)
eno4: -1Mbps (driver: igb)
enp68s0f0: 10000Mbps (driver: ixgbe)
enp68s0f1: -1Mbps (driver: ixgbe)
--- Default Route ---
default via 66.186.37.129 dev br0 proto static metric 10
--- Bridge / Bond Config ---
bridge name bridge id STP enabled interfaces
br0 8000.b8ca3a6ec5cc yes 1384622875
1512514452
1876239691
2285253971
3473746128
3975024440
4142776250
4480397161
4793215883
4965374655
5136141790
5579955486
5691952363
6226104096
6641190120
6844105562
6899839531
6945298055
8232538989
8318991295
9065882520
9865730239
eno1
8: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
===== LIBVIRT / KVM =====
--- Libvirt Version ---
Compiled against library: libvirt 10.10.0
Using library: libvirt 10.10.0
Using API: QEMU 10.10.0
Running hypervisor: QEMU 9.1.0
Running against daemon: 10.10.0
--- All VMs (running + stopped) ---
Id Name State
------------------------------------------------------
1 82e3ea29-a7eb-49eb-ad66-2a7523359408 running
2 0ad0f8f8-c784-4cd7-a885-064fd5dec42b running
5 c163a847-d2cf-489a-b2ae-402daa0fb880 running
6 571fa5af-f75c-4f6e-95b7-c0e68200a9ae running
10 9469dd33-0d1e-460e-9674-94508798497c running
11 0ce20a29-bf89-4095-9abc-1e148613d2a7 running
12 40631bb2-34fc-42ea-b58c-60506d1adb09 running
13 98268e4a-7812-43a7-bbd1-b1cc4e366368 running
14 99a23167-132c-49b8-8203-7529077b135e running
15 2dd34cb1-8faa-4448-a5b8-21bbd1104459 running
18 4d4689a2-543c-4543-b501-31828c9c6564 running
22 bbb290fa-2a94-408a-904c-b96140034807 running
25 8e3d0c3a-2a70-4c6b-9030-057300cb5e47 running
26 e028b84c-5a0d-427d-bd98-efb178826072 running
27 3579b300-eac8-4e7f-b6fa-d3c11b63c062 running
28 5cf90f68-657c-41af-a96d-ddc7b2c3e0b8 running
34 594ffdd9-06dd-4183-b956-21a601edfef1 running
35 721de6e8-0e3c-490b-aab6-4daa7aed7f35 running
36 680f78b5-d774-4aa9-a12b-3c6aaa9f4aae running
38 95b968e8-1975-4de1-85bb-9c521486d431 running
39 e8fac828-9665-42b1-bde0-a46d63e67e60 running
40 716d7378-9108-4b12-afef-cd1af4448c25 running
--- Running VM Count ---
22
--- VM Resource Usage ---
VM_NAME | vCPUs | RAM_MAX | STATE
--------|-------|---------|------
82e3ea29-a7eb-49eb-ad66-2a7523359408 | 1 | 1048576 KiB | running
0ad0f8f8-c784-4cd7-a885-064fd5dec42b | 6 | 16777216 KiB | running
c163a847-d2cf-489a-b2ae-402daa0fb880 | 4 | 8388608 KiB | running
571fa5af-f75c-4f6e-95b7-c0e68200a9ae | 4 | 8388608 KiB | running
9469dd33-0d1e-460e-9674-94508798497c | 6 | 16777216 KiB | running
0ce20a29-bf89-4095-9abc-1e148613d2a7 | 6 | 16777216 KiB | running
40631bb2-34fc-42ea-b58c-60506d1adb09 | 6 | 16777216 KiB | running
98268e4a-7812-43a7-bbd1-b1cc4e366368 | 6 | 16777216 KiB | running
99a23167-132c-49b8-8203-7529077b135e | 4 | 8388608 KiB | running
2dd34cb1-8faa-4448-a5b8-21bbd1104459 | 4 | 8388608 KiB | running
4d4689a2-543c-4543-b501-31828c9c6564 | 4 | 8388608 KiB | running
bbb290fa-2a94-408a-904c-b96140034807 | 1 | 1048576 KiB | running
8e3d0c3a-2a70-4c6b-9030-057300cb5e47 | 4 | 2097152 KiB | running
e028b84c-5a0d-427d-bd98-efb178826072 | 6 | 16777216 KiB | running
3579b300-eac8-4e7f-b6fa-d3c11b63c062 | 1 | 2097152 KiB | running
5cf90f68-657c-41af-a96d-ddc7b2c3e0b8 | 6 | 16777216 KiB | running
594ffdd9-06dd-4183-b956-21a601edfef1 | 1 | 2097152 KiB | running
721de6e8-0e3c-490b-aab6-4daa7aed7f35 | 1 | 1048576 KiB | running
680f78b5-d774-4aa9-a12b-3c6aaa9f4aae | 6 | 16777216 KiB | running
95b968e8-1975-4de1-85bb-9c521486d431 | 4 | 8388608 KiB | running
e8fac828-9665-42b1-bde0-a46d63e67e60 | 8 | 33554432 KiB | running
716d7378-9108-4b12-afef-cd1af4448c25 | 4 | 8388608 KiB | running
--- Total Allocated vCPUs (running VMs) ---
93
--- Total Allocated RAM (running VMs) ---
230400 MB (225 GB)
--- VM Disk Locations ---
[82e3ea29-a7eb-49eb-ad66-2a7523359408]
file disk vda /mnt/vms/82e3ea29-a7eb-49eb-ad66-2a7523359408_1.img
file disk sdx /home/vf-data/server/82e3ea29-a7eb-49eb-ad66-2a7523359408/cloud-drive.img
[0ad0f8f8-c784-4cd7-a885-064fd5dec42b]
file disk vda /mnt/vms/0ad0f8f8-c784-4cd7-a885-064fd5dec42b_1.img
file disk sdx /home/vf-data/server/0ad0f8f8-c784-4cd7-a885-064fd5dec42b/cloud-drive.img
[c163a847-d2cf-489a-b2ae-402daa0fb880]
file disk vda /mnt/vms/c163a847-d2cf-489a-b2ae-402daa0fb880_1.img
file disk sdx /home/vf-data/server/c163a847-d2cf-489a-b2ae-402daa0fb880/cloud-drive.img
[571fa5af-f75c-4f6e-95b7-c0e68200a9ae]
file disk vda /mnt/vms/571fa5af-f75c-4f6e-95b7-c0e68200a9ae_1.img
file disk sdx /home/vf-data/server/571fa5af-f75c-4f6e-95b7-c0e68200a9ae/cloud-drive.img
[9469dd33-0d1e-460e-9674-94508798497c]
file disk vda /mnt/vms/9469dd33-0d1e-460e-9674-94508798497c_1.img
file disk sdx /home/vf-data/server/9469dd33-0d1e-460e-9674-94508798497c/cloud-drive.img
[0ce20a29-bf89-4095-9abc-1e148613d2a7]
file disk vda /mnt/vms/0ce20a29-bf89-4095-9abc-1e148613d2a7_1.img
file disk sdx /home/vf-data/server/0ce20a29-bf89-4095-9abc-1e148613d2a7/cloud-drive.img
[40631bb2-34fc-42ea-b58c-60506d1adb09]
file disk vda /mnt/vms/40631bb2-34fc-42ea-b58c-60506d1adb09_1.img
file disk sdx /home/vf-data/server/40631bb2-34fc-42ea-b58c-60506d1adb09/cloud-drive.img
[98268e4a-7812-43a7-bbd1-b1cc4e366368]
file disk vda /mnt/vms/98268e4a-7812-43a7-bbd1-b1cc4e366368_1.img
file disk sdx /home/vf-data/server/98268e4a-7812-43a7-bbd1-b1cc4e366368/cloud-drive.img
[99a23167-132c-49b8-8203-7529077b135e]
file disk vda /mnt/vms/99a23167-132c-49b8-8203-7529077b135e_1.img
file disk sdx /home/vf-data/server/99a23167-132c-49b8-8203-7529077b135e/cloud-drive.img
[2dd34cb1-8faa-4448-a5b8-21bbd1104459]
file disk vda /mnt/vms/2dd34cb1-8faa-4448-a5b8-21bbd1104459_1.img
file disk sdx /home/vf-data/server/2dd34cb1-8faa-4448-a5b8-21bbd1104459/cloud-drive.img
[4d4689a2-543c-4543-b501-31828c9c6564]
file disk vda /mnt/vms/4d4689a2-543c-4543-b501-31828c9c6564_1.img
file disk sdx /home/vf-data/server/4d4689a2-543c-4543-b501-31828c9c6564/cloud-drive.img
[bbb290fa-2a94-408a-904c-b96140034807]
file disk vda /mnt/vms/bbb290fa-2a94-408a-904c-b96140034807_1.img
file disk sdx /home/vf-data/server/bbb290fa-2a94-408a-904c-b96140034807/cloud-drive.img
[8e3d0c3a-2a70-4c6b-9030-057300cb5e47]
file disk vda /mnt/vms/8e3d0c3a-2a70-4c6b-9030-057300cb5e47_1.img
file disk vdb /mnt/vms/8e3d0c3a-2a70-4c6b-9030-057300cb5e47_2.img
file disk sdx /home/vf-data/server/8e3d0c3a-2a70-4c6b-9030-057300cb5e47/cloud-drive.img
[e028b84c-5a0d-427d-bd98-efb178826072]
file disk vda /mnt/vms/e028b84c-5a0d-427d-bd98-efb178826072_1.img
file disk sdx /home/vf-data/server/e028b84c-5a0d-427d-bd98-efb178826072/cloud-drive.img
[3579b300-eac8-4e7f-b6fa-d3c11b63c062]
file disk vda /mnt/vms/3579b300-eac8-4e7f-b6fa-d3c11b63c062_1.img
file disk sdx /home/vf-data/server/3579b300-eac8-4e7f-b6fa-d3c11b63c062/cloud-drive.img
[5cf90f68-657c-41af-a96d-ddc7b2c3e0b8]
file disk vda /mnt/vms/5cf90f68-657c-41af-a96d-ddc7b2c3e0b8_1.img
file disk vdd /mnt/vms/5cf90f68-657c-41af-a96d-ddc7b2c3e0b8_4.img
file disk sdx /home/vf-data/server/5cf90f68-657c-41af-a96d-ddc7b2c3e0b8/cloud-drive.img
[594ffdd9-06dd-4183-b956-21a601edfef1]
file disk vda /mnt/vms/594ffdd9-06dd-4183-b956-21a601edfef1_1.img
file disk sdx /home/vf-data/server/594ffdd9-06dd-4183-b956-21a601edfef1/cloud-drive.img
[721de6e8-0e3c-490b-aab6-4daa7aed7f35]
file disk vda /mnt/vms/721de6e8-0e3c-490b-aab6-4daa7aed7f35_1.img
file disk sdx /home/vf-data/server/721de6e8-0e3c-490b-aab6-4daa7aed7f35/cloud-drive.img
[680f78b5-d774-4aa9-a12b-3c6aaa9f4aae]
file disk vda /mnt/vms/680f78b5-d774-4aa9-a12b-3c6aaa9f4aae_1.img
file disk sdx /home/vf-data/server/680f78b5-d774-4aa9-a12b-3c6aaa9f4aae/cloud-drive.img
[95b968e8-1975-4de1-85bb-9c521486d431]
file disk vda /mnt/vms/95b968e8-1975-4de1-85bb-9c521486d431_1.img
file disk sdx /home/vf-data/server/95b968e8-1975-4de1-85bb-9c521486d431/cloud-drive.img
[e8fac828-9665-42b1-bde0-a46d63e67e60]
file disk vda /mnt/vms/e8fac828-9665-42b1-bde0-a46d63e67e60_1.img
file disk sdx /home/vf-data/server/e8fac828-9665-42b1-bde0-a46d63e67e60/cloud-drive.img
[716d7378-9108-4b12-afef-cd1af4448c25]
file disk vda /mnt/vms/716d7378-9108-4b12-afef-cd1af4448c25_1.img
file disk sdx /home/vf-data/server/716d7378-9108-4b12-afef-cd1af4448c25/cloud-drive.img
--- Storage Pools ---
Name State Autostart
---------------------------
===== QEMU =====
--- QEMU Version ---
QEMU emulator version 9.1.0 (qemu-kvm-9.1.0-29.el9_7.alma.1)
===== DISK USAGE BY VM IMAGES =====
--- qcow2 files ---
/home/vf-data/os/template/ubuntu-lunar-server-cloudimg-amd64.qcow2 (actual: 722M, virtual: 3.5 GiB)
/home/vf-data/os/template/ubuntu-focal-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 613M, virtual: 2.2 GiB)
/home/vf-data/os/template/ubuntu-noble-server-cloudimg-amd64-2024-04-25.qcow2 (actual: 557M, virtual: 3.5 GiB)
/home/vf-data/os/template/windows_server_2012_r2_standard.qcow2 (actual: 12G, virtual: 12.2 GiB)
/home/vf-data/os/template/centos-7-minimal-x86-64.qcow2 (actual: 446M, virtual: 1.95 GiB)
/home/vf-data/os/template/alma-linux-8-minimal-x86_64-2024-01-27.qcow2 (actual: 679M, virtual: 10 GiB)
/home/vf-data/os/template/centos-stream-8-minimal-x86_64.qcow2 (actual: 486M, virtual: 3.61 GiB)
/home/vf-data/os/template/debian-12-x86_64-2023-06-11.qcow2 (actual: 441M, virtual: 1.95 GiB)
/home/vf-data/os/template/ubuntu-jammy-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 646M, virtual: 2.2 GiB)
/home/vf-data/os/template/almalinux-9-x86_64-2024-11-20.qcow2 (actual: 507M, virtual: 10 GiB)
/home/vf-data/os/template/windows-server-2025-standard-2024-11-06.qcow2 (actual: 5.9G, virtual: 12.2 GiB)
/home/vf-data/os/template/windows-server-2019-standard-2024-03-06.qcow2 (actual: 5.8G, virtual: 13.7 GiB)
/home/vf-data/os/template/windows-server-2022-standard-2024-03-06.qcow2 (actual: 5.5G, virtual: 12.2 GiB)
/home/vf-data/os/template/centos-8-3-x86-64.qcow2 (actual: 1.3G, virtual: 10 GiB)
/home/vf-data/os/template/almalinux-10-x86-64.qcow2 (actual: 439M, virtual: 10 GiB)
/home/vf-data/os/template/fedora-42-x86-64.qcow2 (actual: 508M, virtual: 5 GiB)
/mnt/data/windows_10_template_v1.qcow2 (actual: 11G, virtual: 25 GiB)
--- raw disk files ---
===== SERVICES =====
--- Key Services Status ---
libvirtd: active
qemu-guest-agent: inactive
not-found
virtfusion: inactive
not-found
virtfusion-agent: inactive
not-found
zfs-zed: inactive
not-found
zfs-import-cache: inactive
not-found
zfs-mount: inactive
not-found
===== RESOURCE SUMMARY =====
--- CPU ---
Total threads: 40
Allocated vCPUs: 93
Overcommit ratio: N/A
--- RAM ---
Total RAM: 386388 MB (377 GB)
Allocated to VMs: 230400 MB (225 GB)
Free for host/new VMs: 155988 MB
Utilization: N/A%
===== END =====

View File

@@ -0,0 +1,374 @@
===== SYSTEM INFO =====
--- Hostname ---
atl-03.node.vps.ezscale.tech
--- OS ---
NAME="AlmaLinux"
VERSION="9.6 (Sage Margay)"
VERSION_ID="9.6"
PRETTY_NAME="AlmaLinux 9.6 (Sage Margay)"
--- Kernel ---
5.14.0-570.49.1.el9_6.x86_64
--- Uptime ---
11:33:06 up 99 days, 4:43, 0 users, load average: 1.41, 1.92, 1.97
===== CPU =====
--- Model ---
Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
--- Physical CPUs ---
2
--- Cores per CPU ---
14
--- Total Threads ---
56
--- CPU Flags (virt) ---
ept vmx
--- lscpu summary ---
CPU(s): 56
On-line CPU(s) list: 0-55
Model name: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
BIOS Model name: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz
Thread(s) per core: 2
Core(s) per socket: 14
Socket(s): 2
CPU(s) scaling MHz: 100%
CPU max MHz: 2900.0000
CPU min MHz: 1200.0000
L1d cache: 896 KiB (28 instances)
L1i cache: 896 KiB (28 instances)
L2 cache: 7 MiB (28 instances)
L3 cache: 70 MiB (2 instances)
NUMA node0 CPU(s): 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54
NUMA node1 CPU(s): 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49,51,53,55
Vulnerability L1tf: Mitigation; PTE Inversion; VMX conditional cache flushes, SMT vulnerable
===== MEMORY =====
--- Total / Used / Free ---
total used free shared buff/cache available
Mem: 440Gi 180Gi 238Gi 4.0Gi 27Gi 259Gi
Swap: 15Gi 0B 15Gi
--- DIMM Details ---
Error Correction Type: Multi-bit ECC
Locator: A1
Bank Locator: Not Specified
Type: Unknown
Type Detail: None
Size: 32 GB
Locator: A2
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Configured Memory Speed: 1866 MT/s
Size: 32 GB
Locator: A3
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Configured Memory Speed: 1866 MT/s
Size: 32 GB
Locator: A4
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Configured Memory Speed: 1866 MT/s
Locator: A5
Bank Locator: Not Specified
Type: Unknown
Type Detail: None
Size: 32 GB
Locator: A6
Bank Locator: Not Specified
Type: DDR4
Type Detail: Synchronous Registered (Buffered)
Speed: 2400 MT/s
Configured Memory Speed: 1866 MT/s
Size: 32 GB
Locator: A7
Bank Locator: Not Specified
===== STORAGE - BLOCK DEVICES =====
--- lsblk ---
NAME SIZE TYPE FSTYPE MOUNTPOINT ROTA MODEL
sda 223.6G disk 0 KINGSTON SA400S37240G
├─sda1 1G part xfs /boot 0
└─sda2 222.6G part linux_raid_member 0
└─md127 222.4G raid1 ext4 / 0
sdb 0B disk 0 STORAGE DEVICE
sdc 1.9T disk 0 Micron_1100_MTFDDAK2T0TBN
├─sdc1 1.9T part zfs_member 0
└─sdc9 8M part 0
sdd 1.9T disk 0 Micron_1100_MTFDDAK2T0TBN
├─sdd1 1.9T part zfs_member 0
└─sdd9 8M part 0
sde 223.6G disk 0 KINGSTON SA400S37240G
└─sde1 222.6G part linux_raid_member 0
└─md127 222.4G raid1 ext4 / 0
--- Disk Models ---
NAME SIZE MODEL ROTA TRAN
sda 223.6G KINGSTON SA400S37240G 0 sata
sdb 0B STORAGE DEVICE 0 usb
sdc 1.9T Micron_1100_MTFDDAK2T0TBN 0 sata
sdd 1.9T Micron_1100_MTFDDAK2T0TBN 0 sata
sde 223.6G KINGSTON SA400S37240G 0 sata
===== STORAGE - FILESYSTEM =====
--- df -h ---
Filesystem Size Used Avail Use% Mounted on
/dev/md127 218G 113G 95G 55% /
/dev/sda1 960M 330M 631M 35% /boot
tank 1.7T 128K 1.7T 1% /tank
tank/vms 1.8T 165G 1.7T 10% /tank/vms
===== STORAGE - ZFS =====
--- ZFS Pools ---
NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT
tank 1.86T 197G 1.67T - - 35% 10% 1.00x ONLINE -
mirror-0 1.86T 197G 1.67T - - 35% 10.3% - ONLINE
ata-Micron_1100_MTFDDAK2T0TBN_17021569179A 1.86T - - - - - - - ONLINE
ata-Micron_1100_MTFDDAK2T0TBN_1711166E8924 1.86T - - - - - - - ONLINE
--- ZFS Pool Status ---
pool: tank
state: ONLINE
config:
NAME STATE READ WRITE CKSUM
tank ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
ata-Micron_1100_MTFDDAK2T0TBN_17021569179A ONLINE 0 0 0
ata-Micron_1100_MTFDDAK2T0TBN_1711166E8924 ONLINE 0 0 0
errors: No known data errors
--- ZFS Datasets ---
NAME USED AVAIL REFER MOUNTPOINT
tank 197G 1.61T 96K /tank
tank/vms 196G 1.61T 165G /tank/vms
--- ZFS Properties (compression, ashift, recordsize) ---
===== STORAGE - LVM =====
--- Physical Volumes ---
--- Volume Groups ---
--- Logical Volumes ---
===== STORAGE - MDADM (Software RAID) =====
Personalities : [raid1]
md127 : active raid1 sde1[0] sda2[1]
233248768 blocks super 1.2 [2/2] [UU]
bitmap: 2/2 pages [8KB], 65536KB chunk
unused devices: <none>
===== NETWORK =====
--- Interfaces ---
lo UNKNOWN 127.0.0.1/8 ::1/128
enp3s0f0 DOWN
eno1 DOWN
enp3s0f1 DOWN
eno2 DOWN
eno3 UP
eno4 UP
enp129s0f0 DOWN
enp129s0f1 DOWN
br0 UP 66.186.37.252/25 fe80::1c0c:69a9:ca9b:f163/64
6676408128 UNKNOWN fe80::fc30:1cff:fe10:3959/64
4794906989 UNKNOWN fe80::fc98:3ff:fe85:6a2e/64
8901578154 UNKNOWN fe80::fcf0:2cff:fe63:ba26/64
1787749754 UNKNOWN fe80::fce5:fcff:fe53:513f/64
3327536383 UNKNOWN fe80::fc0d:1aff:fe42:94c0/64
5326490493 UNKNOWN fe80::fce3:fbff:fec0:f4cd/64
2313996727 UNKNOWN fe80::fc19:21ff:fe56:f471/64
9265995741 UNKNOWN fe80::fc02:3dff:fe83:2d3f/64
7640912805 UNKNOWN fe80::fc9b:fff:fe23:46be/64
4655817081 UNKNOWN fe80::fc0d:d7ff:fea6:e089/64
7771553970 UNKNOWN fe80::fcb8:dfff:fe7b:492e/64
--- Interface Speeds ---
1787749754: 10Mbps (driver: tun)
2313996727: 10Mbps (driver: tun)
3327536383: 10Mbps (driver: tun)
4655817081: 10Mbps (driver: tun)
4794906989: 10Mbps (driver: tun)
5326490493: 10Mbps (driver: tun)
6676408128: 10Mbps (driver: tun)
7640912805: 10Mbps (driver: tun)
7771553970: 10Mbps (driver: tun)
8901578154: 10Mbps (driver: tun)
9265995741: 10Mbps (driver: tun)
br0: 1000Mbps (driver: bridge)
eno1: -1Mbps (driver: bnx2x)
eno2: -1Mbps (driver: bnx2x)
eno3: 1000Mbps (driver: bnx2x)
eno4: 1000Mbps (driver: bnx2x)
enp129s0f0: -1Mbps (driver: bnx2x)
enp129s0f1: -1Mbps (driver: bnx2x)
enp3s0f0: -1Mbps (driver: ixgbe)
enp3s0f1: -1Mbps (driver: ixgbe)
--- Default Route ---
default via 66.186.37.129 dev br0 proto static metric 425
--- Bridge / Bond Config ---
bridge name bridge id STP enabled interfaces
br0 8000.1866da8ba8ee yes 1787749754
2313996727
3327536383
4655817081
4794906989
5326490493
6676408128
7640912805
7771553970
8901578154
9265995741
eno4
10: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
===== LIBVIRT / KVM =====
--- Libvirt Version ---
Compiled against library: libvirt 10.10.0
Using library: libvirt 10.10.0
Using API: QEMU 10.10.0
Running hypervisor: QEMU 9.1.0
Running against daemon: 10.10.0
--- All VMs (running + stopped) ---
Id Name State
-------------------------------------------------------
4 2ce09938-9aa6-44cc-9a58-a52a9f717c5e running
8 5e134a7a-728e-4fec-bf37-7e6e4649904c running
28 b69ff6b7-5145-4254-9013-6ce723a68c40 running
44 1f192a2c-0ef2-4b2d-9722-db4914b4efaa running
81 9b8b3835-192e-4f81-ada6-ba3c4b6b6bfc paused
85 192e4df7-d1ce-40e9-b4f5-ad8c69ce97dd running
86 5cf0eb8f-dd1a-43cf-a3ea-c618484d6aa6 running
97 d779c625-55e4-4e3e-8d37-a374d8715c37 running
100 cb1e6977-93ef-4fd7-a48e-850eb6dc08e7 running
101 7fe23def-a545-4f65-ac5a-9ff570efaabe running
102 fff80538-33cd-46a2-a29c-bafbe08eb507 running
--- Running VM Count ---
10
--- VM Resource Usage ---
VM_NAME | vCPUs | RAM_MAX | STATE
--------|-------|---------|------
2ce09938-9aa6-44cc-9a58-a52a9f717c5e | 8 | 33554432 KiB | running
5e134a7a-728e-4fec-bf37-7e6e4649904c | 6 | 16777216 KiB | running
b69ff6b7-5145-4254-9013-6ce723a68c40 | 4 | 8388608 KiB | running
1f192a2c-0ef2-4b2d-9722-db4914b4efaa | 4 | 8388608 KiB | running
192e4df7-d1ce-40e9-b4f5-ad8c69ce97dd | 4 | 8388608 KiB | running
5cf0eb8f-dd1a-43cf-a3ea-c618484d6aa6 | 4 | 8388608 KiB | running
d779c625-55e4-4e3e-8d37-a374d8715c37 | 4 | 8388608 KiB | running
cb1e6977-93ef-4fd7-a48e-850eb6dc08e7 | 4 | 8388608 KiB | running
7fe23def-a545-4f65-ac5a-9ff570efaabe | 4 | 8388608 KiB | running
fff80538-33cd-46a2-a29c-bafbe08eb507 | 4 | 8388608 KiB | running
--- Total Allocated vCPUs (running VMs) ---
46
--- Total Allocated RAM (running VMs) ---
114688 MB (112 GB)
--- VM Disk Locations ---
[2ce09938-9aa6-44cc-9a58-a52a9f717c5e]
file disk vda /tank/vms/2ce09938-9aa6-44cc-9a58-a52a9f717c5e_1.img
[5e134a7a-728e-4fec-bf37-7e6e4649904c]
file disk vda /tank/vms/5e134a7a-728e-4fec-bf37-7e6e4649904c_1.img
[b69ff6b7-5145-4254-9013-6ce723a68c40]
file disk vda /tank/vms/b69ff6b7-5145-4254-9013-6ce723a68c40_1.img
file disk sdx /home/vf-data/server/b69ff6b7-5145-4254-9013-6ce723a68c40/cloud-drive.img
[1f192a2c-0ef2-4b2d-9722-db4914b4efaa]
file disk vda /tank/vms/1f192a2c-0ef2-4b2d-9722-db4914b4efaa_1.img
file disk sdx /home/vf-data/server/1f192a2c-0ef2-4b2d-9722-db4914b4efaa/cloud-drive.img
[9b8b3835-192e-4f81-ada6-ba3c4b6b6bfc]
file disk vda /tank/vms/9b8b3835-192e-4f81-ada6-ba3c4b6b6bfc_1.img
file disk sdx /home/vf-data/server/9b8b3835-192e-4f81-ada6-ba3c4b6b6bfc/cloud-drive.img
[192e4df7-d1ce-40e9-b4f5-ad8c69ce97dd]
file disk vda /tank/vms/192e4df7-d1ce-40e9-b4f5-ad8c69ce97dd_1.img
file disk sdx /home/vf-data/server/192e4df7-d1ce-40e9-b4f5-ad8c69ce97dd/cloud-drive.img
[5cf0eb8f-dd1a-43cf-a3ea-c618484d6aa6]
file disk vda /tank/vms/5cf0eb8f-dd1a-43cf-a3ea-c618484d6aa6_1.img
file disk sdx /home/vf-data/server/5cf0eb8f-dd1a-43cf-a3ea-c618484d6aa6/cloud-drive.img
[d779c625-55e4-4e3e-8d37-a374d8715c37]
file disk vda /tank/vms/d779c625-55e4-4e3e-8d37-a374d8715c37_1.img
file disk sdx /home/vf-data/server/d779c625-55e4-4e3e-8d37-a374d8715c37/cloud-drive.img
[cb1e6977-93ef-4fd7-a48e-850eb6dc08e7]
file disk vda /tank/vms/cb1e6977-93ef-4fd7-a48e-850eb6dc08e7_1.img
file disk sdx /home/vf-data/server/cb1e6977-93ef-4fd7-a48e-850eb6dc08e7/cloud-drive.img
[7fe23def-a545-4f65-ac5a-9ff570efaabe]
file disk vda /tank/vms/7fe23def-a545-4f65-ac5a-9ff570efaabe_1.img
file disk sdx /home/vf-data/server/7fe23def-a545-4f65-ac5a-9ff570efaabe/cloud-drive.img
[fff80538-33cd-46a2-a29c-bafbe08eb507]
file disk vda /tank/vms/fff80538-33cd-46a2-a29c-bafbe08eb507_1.img
file disk sdx /home/vf-data/server/fff80538-33cd-46a2-a29c-bafbe08eb507/cloud-drive.img
--- Storage Pools ---
Name State Autostart
---------------------------
===== QEMU =====
--- QEMU Version ---
QEMU emulator version 9.1.0 (qemu-kvm-9.1.0-15.el9_6.9.alma.1)
===== DISK USAGE BY VM IMAGES =====
--- qcow2 files ---
/home/vf-data/os/template/windows-server-2025-standard-2024-11-06.qcow2 (actual: 5.9G, virtual: 12.2 GiB)
/home/vf-data/os/template/ubuntu-noble-server-cloudimg-amd64-2024-04-25.qcow2 (actual: 592M, virtual: 3.5 GiB)
/home/vf-data/os/template/ubuntu-focal-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 618M, virtual: 2.2 GiB)
/home/vf-data/os/template/almalinux-10-x86-64.qcow2 (actual: 439M, virtual: 10 GiB)
/home/vf-data/os/template/ubuntu-jammy-server-cloudimg-amd64-2023-04-25.qcow2 (actual: 655M, virtual: 2.2 GiB)
/home/vf-data/os/template/windows-server-2019-standard-2024-03-06.qcow2 (actual: 5.8G, virtual: 13.7 GiB)
/home/vf-data/os/template/windows-server-2022-standard-2024-03-06.qcow2 (actual: 5.5G, virtual: 12.2 GiB)
/home/vf-data/os/template/centos-8-3-x86-64.qcow2 (actual: 1.3G, virtual: 10 GiB)
/home/vf-data/os/template/almalinux-9-x86_64-2024-11-20.qcow2 (actual: 507M, virtual: 10 GiB)
/home/vf-data/os/template/alma-linux-8-minimal-x86_64-2024-01-27.qcow2 (actual: 679M, virtual: 10 GiB)
--- raw disk files ---
===== SERVICES =====
--- Key Services Status ---
libvirtd: inactive
not-found
qemu-guest-agent: inactive
not-found
virtfusion: inactive
not-found
virtfusion-agent: inactive
not-found
zfs-zed: active
zfs-import-cache: active
zfs-mount: active
===== RESOURCE SUMMARY =====
--- CPU ---
Total threads: 56
Allocated vCPUs: 46
Overcommit ratio: .82
--- RAM ---
Total RAM: 450850 MB (440 GB)
Allocated to VMs: 114688 MB (112 GB)
Free for host/new VMs: 336162 MB
Utilization: 20.0%
===== END =====

10
ezscale-horizon.conf Normal file
View File

@@ -0,0 +1,10 @@
[program:ezscale-horizon]
process_name=%(program_name)s
command=php /opt/projects/ezscale_site/website/artisan horizon
autostart=true
autorestart=true
user=ezscale
redirect_stderr=true
stdout_logfile=/opt/projects/ezscale_site/website/storage/logs/horizon.log
stopwaitsecs=3600
startsecs=0

38
install-horizon-supervisor.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Install Horizon Supervisor Script
# Run this script with sudo to install and configure Supervisor for Laravel Horizon
set -e
echo "Installing Supervisor..."
sudo apt update
sudo apt install -y supervisor
echo "Copying Horizon supervisor config..."
sudo cp /opt/projects/ezscale_site/ezscale-horizon.conf /etc/supervisor/conf.d/
echo "Creating log directory..."
sudo mkdir -p /opt/projects/ezscale_site/website/storage/logs
sudo chown -R ezscale:ezscale /opt/projects/ezscale_site/website/storage/logs
echo "Reloading supervisor configuration..."
sudo supervisorctl reread
sudo supervisorctl update
echo "Starting Horizon..."
sudo supervisorctl start ezscale-horizon
echo "Checking status..."
sudo supervisorctl status ezscale-horizon
echo ""
echo "✓ Horizon supervisor installed successfully!"
echo ""
echo "Useful commands:"
echo " sudo supervisorctl status ezscale-horizon # Check status"
echo " sudo supervisorctl stop ezscale-horizon # Stop Horizon"
echo " sudo supervisorctl start ezscale-horizon # Start Horizon"
echo " sudo supervisorctl restart ezscale-horizon # Restart Horizon"
echo " sudo supervisorctl tail ezscale-horizon # View logs"
echo ""

8309
virtfusion-api-spec.yaml Normal file

File diff suppressed because one or more lines are too long

View File

@@ -162,7 +162,45 @@ class AuditLogController extends Controller
return '-';
}
// If it has before/after structure
// Check for per-field old/new format: {"plan": {"old": "Basic", "new": "Pro"}, ...}
$hasPerFieldOldNew = false;
foreach ($changes as $value) {
if (is_array($value) && (array_key_exists('old', $value) || array_key_exists('new', $value))) {
$hasPerFieldOldNew = true;
break;
}
}
if ($hasPerFieldOldNew) {
$changedFields = [];
foreach ($changes as $field => $value) {
if (is_array($value) && array_key_exists('old', $value) && array_key_exists('new', $value)) {
if ($value['old'] !== $value['new']) {
$changedFields[] = $field;
}
}
}
return $changedFields ? 'Changed: '.implode(', ', $changedFields) : 'No changes';
}
// Top-level old/new structure: {"old": {...}, "new": {...}}
if (isset($changes['old']) || isset($changes['new'])) {
$fields = [];
if (isset($changes['new']) && is_array($changes['new'])) {
$fields = array_keys($changes['new']);
} elseif (isset($changes['old']) && is_array($changes['old'])) {
$fields = array_keys($changes['old']);
}
return 'Changed: '.implode(', ', $fields);
}
// Top-level before/after structure
if (isset($changes['before']) || isset($changes['after'])) {
$fields = [];
@@ -175,7 +213,7 @@ class AuditLogController extends Controller
return 'Changed: '.implode(', ', $fields);
}
// Otherwise list the top-level keys
// Flat key-value pairs
return 'Fields: '.implode(', ', array_keys($changes));
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateEmailTemplateRequest;
use App\Models\EmailTemplate;
use Database\Seeders\EmailTemplateSeeder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailTemplateController extends Controller
{
public function index(): Response
{
$templates = EmailTemplate::query()
->orderBy('name')
->get();
return Inertia::render('Admin/EmailTemplates/Index', [
'templates' => $templates,
]);
}
public function edit(EmailTemplate $emailTemplate): Response
{
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => $emailTemplate,
]);
}
public function update(UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate): RedirectResponse
{
$emailTemplate->update([
'subject' => $request->validated('subject'),
'body' => $request->validated('body'),
'is_active' => $request->boolean('is_active'),
]);
return redirect()
->route('admin.email-templates.index')
->with('success', 'Email template updated successfully.');
}
public function preview(EmailTemplate $emailTemplate, Request $request): JsonResponse
{
$sampleData = $this->getSampleData($emailTemplate);
$subject = $emailTemplate->subject;
$body = $emailTemplate->body;
foreach ($sampleData as $key => $value) {
$placeholder = '{{'.$key.'}}';
$subject = str_replace($placeholder, $value, $subject);
$body = str_replace($placeholder, $value, $body);
}
return response()->json([
'subject' => $subject,
'body' => $body,
]);
}
public function resetToDefault(EmailTemplate $emailTemplate): RedirectResponse
{
$defaults = EmailTemplateSeeder::getDefaultTemplates();
$default = collect($defaults)->firstWhere('slug', $emailTemplate->slug);
if ($default) {
$emailTemplate->update([
'subject' => $default['subject'],
'body' => $default['body'],
'is_active' => true,
]);
}
return redirect()
->route('admin.email-templates.edit', $emailTemplate)
->with('success', 'Email template reset to default.');
}
/**
* Generate sample data for template preview.
*
* @return array<string, string>
*/
private function getSampleData(EmailTemplate $emailTemplate): array
{
$sampleValues = [
'customer_name' => 'John Doe',
'amount' => '49.99',
'currency' => 'USD',
'invoice_number' => 'INV-2026-0001',
'date' => now()->format('M j, Y'),
'payment_method' => 'Visa ending in 4242',
'error_message' => 'Card was declined',
'plan_name' => 'VPS Pro',
'billing_cycle' => 'Monthly',
'cancellation_date' => now()->format('M j, Y'),
'end_date' => now()->addMonth()->format('M j, Y'),
'service_type' => 'VPS',
'hostname' => 'vps-prod-01.ezscale.cloud',
'ip_address' => '192.168.1.100',
'username' => 'root',
'password' => '********',
'due_date' => now()->addDays(14)->format('M j, Y'),
];
$variables = $emailTemplate->available_variables ?? [];
$data = [];
foreach ($variables as $variable) {
$data[$variable] = $sampleValues[$variable] ?? "{{$variable}}";
}
return $data;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ExtendServiceExpiryRequest;
use App\Http\Requests\Admin\UpdateServiceRequest;
use App\Models\AuditLog;
use App\Models\Plan;
@@ -12,6 +13,7 @@ use App\Models\Service;
use App\Services\Provisioning\ProvisioningFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
@@ -66,6 +68,7 @@ class ServiceController extends Controller
$service->load([
'user:id,name,email,status',
'plan:id,name,service_type,price,billing_cycle',
'subscription:id,ends_at,current_period_end,stripe_status,type',
'provisioningLogs' => function ($query): void {
$query->latest()->limit(50);
},
@@ -282,4 +285,47 @@ class ServiceController extends Controller
return redirect()->back()->with('success', 'Service has been updated successfully.');
}
public function extendExpiry(Service $service, ExtendServiceExpiryRequest $request): RedirectResponse
{
$validated = $request->validated();
$newExpiryDate = Carbon::parse($validated['new_expiry_date'])->endOfDay();
$subscription = $service->subscription;
if (! $subscription) {
return redirect()->back()->with('error', 'This service does not have an associated subscription.');
}
$oldEndsAt = $subscription->ends_at;
$subscription->update([
'ends_at' => $newExpiryDate,
]);
AuditLog::create([
'user_id' => $service->user_id,
'admin_id' => auth()->id(),
'action' => 'extend_service_expiry',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'changes' => [
'old_ends_at' => $oldEndsAt?->toIso8601String(),
'new_ends_at' => $newExpiryDate->toIso8601String(),
'reason' => $validated['reason'] ?? null,
],
]);
Log::info('Admin extended service expiry', [
'admin_id' => auth()->id(),
'service_id' => $service->id,
'subscription_id' => $subscription->id,
'old_ends_at' => $oldEndsAt?->toIso8601String(),
'new_ends_at' => $newExpiryDate->toIso8601String(),
]);
return redirect()->back()->with('success', 'Service expiry date has been extended successfully.');
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreTaxRateRequest;
use App\Http\Requests\Admin\UpdateTaxRateRequest;
use App\Models\TaxRate;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class TaxRateController extends Controller
{
public function index(Request $request): Response
{
$query = TaxRate::query();
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', '%'.$search.'%')
->orWhere('country_code', 'like', '%'.$search.'%')
->orWhere('region_code', 'like', '%'.$search.'%');
});
}
if ($request->filled('country') && $request->input('country') !== 'all') {
$query->where('country_code', $request->input('country'));
}
if ($request->filled('status') && $request->input('status') !== 'all') {
$query->where('is_active', $request->input('status') === 'active');
}
$taxRates = $query
->orderBy('country_code')
->orderBy('region_code')
->orderBy('priority')
->paginate(25);
$countries = TaxRate::query()
->select('country_code')
->distinct()
->orderBy('country_code')
->pluck('country_code');
return Inertia::render('Admin/TaxRates/Index', [
'taxRates' => $taxRates,
'countries' => $countries,
'filters' => [
'search' => $request->input('search', ''),
'country' => $request->input('country', 'all'),
'status' => $request->input('status', 'all'),
],
]);
}
public function create(): Response
{
return Inertia::render('Admin/TaxRates/Create');
}
public function store(StoreTaxRateRequest $request): RedirectResponse
{
TaxRate::query()->create([
'name' => $request->validated('name'),
'country_code' => strtoupper($request->validated('country_code')),
'region_code' => $request->validated('region_code'),
'rate' => $request->validated('rate'),
'type' => $request->validated('type'),
'priority' => $request->validated('priority', 0),
'is_active' => $request->boolean('is_active', true),
]);
return redirect()
->route('admin.tax-rates.index')
->with('success', 'Tax rate created successfully.');
}
public function edit(TaxRate $taxRate): Response
{
return Inertia::render('Admin/TaxRates/Edit', [
'taxRate' => $taxRate,
]);
}
public function update(UpdateTaxRateRequest $request, TaxRate $taxRate): RedirectResponse
{
$taxRate->update([
'name' => $request->validated('name'),
'country_code' => strtoupper($request->validated('country_code')),
'region_code' => $request->validated('region_code'),
'rate' => $request->validated('rate'),
'type' => $request->validated('type'),
'priority' => $request->validated('priority', 0),
'is_active' => $request->boolean('is_active', true),
]);
return redirect()
->route('admin.tax-rates.index')
->with('success', 'Tax rate updated successfully.');
}
public function destroy(TaxRate $taxRate): RedirectResponse
{
$taxRate->delete();
return redirect()
->route('admin.tax-rates.index')
->with('success', 'Tax rate deleted successfully.');
}
public function toggleActive(TaxRate $taxRate): RedirectResponse
{
$taxRate->update(['is_active' => ! $taxRate->is_active]);
$status = $taxRate->is_active ? 'activated' : 'deactivated';
return redirect()
->back()
->with('success', "Tax rate {$status} successfully.");
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use App\Models\Service;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Subscription;
class AdminAnalyticsController extends Controller
{
/**
* Dashboard analytics data (MRR, ARR, churn, customer count, etc.).
*/
public function index(Request $request): JsonResponse
{
$totalCustomers = User::role('customer')->count();
// MRR: sum of plan prices for active subscriptions
$mrr = (float) Subscription::query()
->where('stripe_status', 'active')
->whereNotNull('plan_id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
// ARR (Annual Recurring Revenue)
$arr = $mrr * 12;
// Total revenue: sum of paid invoice totals
$totalRevenue = (float) Invoice::query()
->where('status', 'paid')
->sum('total');
$activeServices = Service::query()
->where('status', 'active')
->count();
// Pending invoices
$pendingInvoicesCount = Invoice::query()
->where('status', 'pending')
->count();
$pendingInvoicesAmount = (float) Invoice::query()
->where('status', 'pending')
->sum('total');
// Overdue invoices
$overdueCount = Invoice::query()
->where('status', 'overdue')
->count();
$overdueAmount = (float) Invoice::query()
->where('status', 'overdue')
->sum('total');
// New customers this month
$newCustomersThisMonth = User::role('customer')
->where('created_at', '>=', now()->startOfMonth())
->count();
// Revenue this month
$revenueThisMonth = (float) Invoice::query()
->where('status', 'paid')
->where('paid_at', '>=', now()->startOfMonth())
->sum('total');
// Monthly Revenue Trend (last 12 months)
$revenueByMonth = PaymentTransaction::query()
->where('status', 'completed')
->where('created_at', '>=', now()->subMonths(12))
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total")
->groupBy('month')
->orderBy('month')
->get()
->map(fn ($row) => ['month' => $row->month, 'total' => (float) $row->total]);
// Customer Growth (last 12 months - new signups per month)
$customerGrowth = User::role('customer')
->where('created_at', '>=', now()->subMonths(12))
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count")
->groupBy('month')
->orderBy('month')
->get()
->map(fn ($row) => ['month' => $row->month, 'count' => (int) $row->count]);
// Churn Rate (subscriptions cancelled vs total in last 6 months)
$churnData = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = now()->subMonths($i)->startOfMonth();
$monthEnd = now()->subMonths($i)->endOfMonth();
$totalAtStart = Subscription::query()
->where('created_at', '<', $monthStart)
->where(function ($query) use ($monthStart): void {
$query->whereNull('cancelled_at')
->orWhere('cancelled_at', '>', $monthStart);
})
->count();
$cancelled = Subscription::query()
->whereBetween('cancelled_at', [$monthStart, $monthEnd])
->count();
$churnData[] = [
'month' => $monthStart->format('Y-m'),
'rate' => $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0,
'cancelled' => $cancelled,
];
}
// Revenue by service type
$revenueByServiceType = Invoice::query()
->where('invoices.status', 'paid')
->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->select('plans.service_type', DB::raw('SUM(invoices.total) as revenue'), DB::raw('COUNT(invoices.id) as invoice_count'))
->groupBy('plans.service_type')
->orderByDesc('revenue')
->get();
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'api_view_analytics',
'resource_type' => 'analytics',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json([
'data' => [
'total_customers' => $totalCustomers,
'new_customers_this_month' => $newCustomersThisMonth,
'mrr' => $mrr,
'arr' => $arr,
'total_revenue' => $totalRevenue,
'revenue_this_month' => $revenueThisMonth,
'active_services' => $activeServices,
'pending_invoices' => [
'count' => $pendingInvoicesCount,
'amount' => $pendingInvoicesAmount,
],
'overdue_invoices' => [
'count' => $overdueCount,
'amount' => $overdueAmount,
],
'revenue_by_month' => $revenueByMonth,
'customer_growth' => $customerGrowth,
'churn_data' => $churnData,
'revenue_by_service_type' => $revenueByServiceType,
],
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\CustomerResource;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class AdminCustomerController extends Controller
{
/**
* List all customers (paginated, searchable).
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = User::role('customer')
->withCount(['services', 'subscriptions']);
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$sortBy = $request->input('sort', 'created_at');
$sortDir = $request->input('direction', 'desc');
$allowedSorts = ['name', 'email', 'created_at', 'status'];
if (in_array($sortBy, $allowedSorts, true)) {
$query->orderBy($sortBy, $sortDir === 'asc' ? 'asc' : 'desc');
}
$perPage = min((int) $request->input('per_page', 15), 100);
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'api_list_customers',
'resource_type' => 'user',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return CustomerResource::collection($query->paginate($perPage));
}
/**
* Show customer details with service/subscription counts.
*/
public function show(Request $request, User $user): CustomerResource
{
$user->loadCount(['services', 'subscriptions']);
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'api_view_customer',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return new CustomerResource($user);
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\AdminServiceResource;
use App\Models\AuditLog;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class AdminServiceController extends Controller
{
/**
* List all services (paginated, filterable).
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = Service::query()
->with(['user:id,name,email', 'plan:id,name,price,billing_cycle']);
if ($request->boolean('show_archived')) {
$query->withTrashed();
}
if ($search = $request->input('search')) {
$query->whereHas('user', function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($serviceType = $request->input('service_type')) {
$query->where('service_type', $serviceType);
}
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$perPage = min((int) $request->input('per_page', 15), 100);
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'api_list_services',
'resource_type' => 'service',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return AdminServiceResource::collection($query->latest()->paginate($perPage));
}
/**
* Show service details.
*/
public function show(Request $request, Service $service): AdminServiceResource
{
$service->load(['user:id,name,email', 'plan:id,name,price,billing_cycle']);
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'api_view_service',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return new AdminServiceResource($service);
}
/**
* Suspend a service.
*/
public function suspend(Request $request, Service $service): JsonResponse
{
if ($service->status === 'suspended') {
return response()->json([
'message' => 'Service is already suspended.',
], 422);
}
$service->update([
'status' => 'suspended',
'suspended_at' => now(),
]);
AuditLog::create([
'user_id' => $service->user_id,
'admin_id' => $request->user()->id,
'action' => 'api_suspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
$service->load(['user:id,name,email', 'plan:id,name,price,billing_cycle']);
return response()->json([
'message' => 'Service has been suspended.',
'data' => new AdminServiceResource($service),
]);
}
/**
* Unsuspend a service.
*/
public function unsuspend(Request $request, Service $service): JsonResponse
{
if ($service->status !== 'suspended') {
return response()->json([
'message' => 'Service is not suspended.',
], 422);
}
$service->update([
'status' => 'active',
'suspended_at' => null,
]);
AuditLog::create([
'user_id' => $service->user_id,
'admin_id' => $request->user()->id,
'action' => 'api_unsuspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
$service->load(['user:id,name,email', 'plan:id,name,price,billing_cycle']);
return response()->json([
'message' => 'Service has been unsuspended.',
'data' => new AdminServiceResource($service),
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\InvoiceResource;
use App\Models\Invoice;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class CustomerInvoiceController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$query = $request->user()
->invoices()
->latest();
if ($request->has('status')) {
$query->where('status', $request->input('status'));
}
return InvoiceResource::collection($query->paginate(15));
}
public function downloadPdf(Request $request, Invoice $invoice): JsonResponse|Response
{
if ($invoice->user_id !== $request->user()->id) {
return response()->json(['message' => 'Forbidden.'], 403);
}
$invoice->load(['user', 'items']);
$pdf = Pdf::loadView('pdf.invoice', ['invoice' => $invoice]);
return $pdf->download("invoice-{$invoice->number}.pdf");
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\ServiceResource;
use App\Models\Service;
use App\Services\Provisioning\VirtFusionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
class CustomerServiceController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$services = $request->user()
->services()
->with('plan:id,name,price,billing_cycle')
->latest()
->paginate(15);
return ServiceResource::collection($services);
}
public function show(Request $request, Service $service): ServiceResource|JsonResponse
{
if ($service->user_id !== $request->user()->id) {
return response()->json(['message' => 'Forbidden.'], 403);
}
$service->load('plan:id,name,price,billing_cycle');
return new ServiceResource($service);
}
public function reboot(Request $request, Service $service): JsonResponse
{
if ($service->user_id !== $request->user()->id) {
return response()->json(['message' => 'Forbidden.'], 403);
}
if ($service->service_type !== 'vps') {
return response()->json([
'message' => 'Reboot is only available for VPS services.',
], 422);
}
if ($service->status !== 'active') {
return response()->json([
'message' => 'Service must be active to reboot.',
], 422);
}
try {
$virtfusion = app(VirtFusionService::class);
$success = $virtfusion->restart($service);
if ($success) {
return response()->json([
'message' => 'VPS reboot initiated successfully.',
]);
}
return response()->json([
'message' => 'Failed to reboot VPS. Please try again.',
], 500);
} catch (\Exception $e) {
Log::error('API VPS reboot error', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'An error occurred while rebooting the VPS.',
], 500);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Events\SubscriptionCancelled;
use App\Http\Controllers\Controller;
use App\Http\Resources\SubscriptionResource;
use App\Services\Billing\BillingServiceFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CustomerSubscriptionController extends Controller
{
public function __construct(
private readonly BillingServiceFactory $billingFactory,
) {}
public function index(Request $request): AnonymousResourceCollection
{
$subscriptions = $request->user()
->subscriptions()
->select([
'subscriptions.*',
'plans.name as plan_name',
'plans.price as plan_price',
'plans.billing_cycle as plan_billing_cycle',
])
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
->latest('subscriptions.created_at')
->get();
return SubscriptionResource::collection($subscriptions);
}
public function cancel(Request $request, int $subscription): JsonResponse
{
$sub = $request->user()
->subscriptions()
->find($subscription);
if (! $sub) {
return response()->json(['message' => 'Subscription not found.'], 404);
}
$gateway = $sub->gateway ?? 'stripe';
$service = $this->billingFactory->make($gateway);
$gatewayId = $gateway === 'stripe'
? $sub->stripe_id
: $sub->gateway_subscription_id;
$success = $service->cancelSubscription(
$request->user(),
$gatewayId,
$request->boolean('immediately'),
);
if (! $success) {
return response()->json([
'message' => 'Failed to cancel subscription. Please try again.',
], 500);
}
SubscriptionCancelled::dispatch($request->user(), $sub);
return response()->json([
'message' => 'Subscription has been cancelled.',
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\TicketResource;
use App\Models\SupportTicket;
use App\Models\TicketReply;
use App\Models\User;
use App\Notifications\TicketCreatedNotification;
use App\Notifications\TicketCustomerReplyNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Notification;
class CustomerTicketController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$tickets = SupportTicket::query()
->where('user_id', $request->user()->id)
->latest('updated_at')
->paginate(15);
return TicketResource::collection($tickets);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'subject' => ['required', 'string', 'max:255'],
'message' => ['required', 'string', 'min:10', 'max:5000'],
'priority' => ['required', 'in:low,medium,high,urgent'],
'department' => ['required', 'in:billing,technical,sales,general'],
]);
$ticket = SupportTicket::query()->create([
'user_id' => $request->user()->id,
'subject' => $validated['subject'],
'status' => 'open',
'priority' => $validated['priority'],
'department' => $validated['department'],
'last_reply_at' => now(),
]);
TicketReply::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $request->user()->id,
'body' => $validated['message'],
'is_staff_reply' => false,
]);
$admins = User::query()->role('admin', 'web')->get();
Notification::send($admins, new TicketCreatedNotification($ticket));
$ticket->load('replies.user:id,name');
return (new TicketResource($ticket))
->response()
->setStatusCode(201);
}
public function show(Request $request, SupportTicket $ticket): TicketResource|JsonResponse
{
if ($ticket->user_id !== $request->user()->id) {
return response()->json(['message' => 'Forbidden.'], 403);
}
$ticket->load('replies.user:id,name');
return new TicketResource($ticket);
}
public function reply(Request $request, SupportTicket $ticket): TicketResource|JsonResponse
{
if ($ticket->user_id !== $request->user()->id) {
return response()->json(['message' => 'Forbidden.'], 403);
}
if ($ticket->status === 'closed') {
return response()->json([
'message' => 'Cannot reply to a closed ticket.',
], 422);
}
$validated = $request->validate([
'body' => ['required', 'string', 'max:5000'],
]);
$reply = TicketReply::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $request->user()->id,
'body' => $validated['body'],
'is_staff_reply' => false,
]);
$ticket->update(['last_reply_at' => now()]);
$admins = User::query()->role('admin', 'web')->get();
Notification::send($admins, new TicketCustomerReplyNotification($ticket, $reply));
$ticket->load('replies.user:id,name');
return new TicketResource($ticket);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class ExtendServiceExpiryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'new_expiry_date' => ['required', 'date', 'after:today'],
'reason' => ['nullable', 'string', 'max:500'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'new_expiry_date.required' => 'A new expiry date is required.',
'new_expiry_date.date' => 'Please provide a valid date.',
'new_expiry_date.after' => 'The new expiry date must be in the future.',
'reason.max' => 'The reason cannot exceed 500 characters.',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaxRateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'country_code' => ['required', 'string', 'size:2', 'alpha'],
'region_code' => ['nullable', 'string', 'max:10'],
'rate' => ['required', 'numeric', 'min:0', 'max:100'],
'type' => ['required', Rule::in(['inclusive', 'exclusive'])],
'priority' => ['integer', 'min:0', 'max:999'],
'is_active' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Tax rate name is required.',
'country_code.required' => 'Country code is required.',
'country_code.size' => 'Country code must be exactly 2 characters.',
'country_code.alpha' => 'Country code must contain only letters.',
'rate.required' => 'Tax rate percentage is required.',
'rate.min' => 'Tax rate must be at least 0.',
'rate.max' => 'Tax rate cannot exceed 100%.',
'type.required' => 'Tax type is required.',
'type.in' => 'Tax type must be inclusive or exclusive.',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTaxRateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'country_code' => ['required', 'string', 'size:2', 'alpha'],
'region_code' => ['nullable', 'string', 'max:10'],
'rate' => ['required', 'numeric', 'min:0', 'max:100'],
'type' => ['required', Rule::in(['inclusive', 'exclusive'])],
'priority' => ['integer', 'min:0', 'max:999'],
'is_active' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Tax rate name is required.',
'country_code.required' => 'Country code is required.',
'country_code.size' => 'Country code must be exactly 2 characters.',
'country_code.alpha' => 'Country code must contain only letters.',
'rate.required' => 'Tax rate percentage is required.',
'rate.min' => 'Tax rate must be at least 0.',
'rate.max' => 'Tax rate cannot exceed 100%.',
'type.required' => 'Tax type is required.',
'type.in' => 'Tax type must be inclusive or exclusive.',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEmailTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'subject' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'is_active' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'subject.required' => 'The email subject line is required.',
'subject.max' => 'The email subject must not exceed 255 characters.',
'body.required' => 'The email body content is required.',
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Service */
class AdminServiceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user' => $this->whenLoaded('user', fn () => [
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email,
]),
'service_type' => $this->service_type,
'platform' => $this->platform,
'platform_service_id' => $this->platform_service_id,
'status' => $this->status,
'hostname' => $this->hostname,
'ipv4_address' => $this->ipv4_address,
'plan' => $this->whenLoaded('plan', fn () => [
'id' => $this->plan->id,
'name' => $this->plan->name,
'price' => $this->plan->price,
'billing_cycle' => $this->plan->billing_cycle,
]),
'provisioned_at' => $this->provisioned_at,
'suspended_at' => $this->suspended_at,
'terminated_at' => $this->terminated_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AnalyticsResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\User */
class CustomerResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status,
'phone' => $this->phone,
'company' => $this->company,
'services_count' => $this->whenCounted('services'),
'subscriptions_count' => $this->whenCounted('subscriptions'),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Invoice */
class InvoiceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'total' => $this->total,
'tax' => $this->tax,
'currency' => $this->currency,
'status' => $this->status,
'due_date' => $this->due_date?->toIso8601String(),
'paid_at' => $this->paid_at?->toIso8601String(),
'items' => $this->whenLoaded('items'),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Service */
class ServiceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'service_type' => $this->service_type,
'platform' => $this->platform,
'status' => $this->status,
'hostname' => $this->hostname,
'ipv4_address' => $this->ipv4_address,
'ipv6_address' => $this->ipv6_address,
'domain' => $this->domain,
'plan' => $this->whenLoaded('plan', fn () => [
'name' => $this->plan->name,
'price' => $this->plan->price,
'billing_cycle' => $this->plan->billing_cycle,
]),
'provisioned_at' => $this->provisioned_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \Laravel\Cashier\Subscription */
class SubscriptionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->type,
'stripe_status' => $this->stripe_status,
'plan' => $this->when($this->plan_name !== null, fn () => [
'name' => $this->plan_name,
'price' => $this->plan_price,
]),
'billing_cycle' => $this->plan_billing_cycle ?? null,
'current_period_end' => $this->current_period_end?->toIso8601String(),
'ends_at' => $this->ends_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\TicketReply */
class TicketReplyResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'body' => $this->body,
'is_staff_reply' => $this->is_staff_reply,
'user' => $this->whenLoaded('user', fn () => [
'name' => $this->user->name,
]),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\SupportTicket */
class TicketResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'reference' => $this->ticket_reference,
'subject' => $this->subject,
'status' => $this->status,
'priority' => $this->priority,
'department' => $this->department,
'last_reply_at' => $this->last_reply_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'replies' => TicketReplyResource::collection($this->whenLoaded('replies')),
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EmailTemplate extends Model
{
use HasFactory;
protected $fillable = [
'slug',
'name',
'subject',
'body',
'available_variables',
'is_active',
];
protected function casts(): array
{
return [
'available_variables' => 'array',
'is_active' => 'boolean',
];
}
public static function getTemplate(string $slug): ?self
{
return static::query()
->where('slug', $slug)
->where('is_active', true)
->first();
}
/**
* Render a template by replacing {{variable_name}} placeholders with actual values.
*
* @param array<string, string> $variables
* @return array{subject: string, body: string}|null
*/
public static function render(string $slug, array $variables): ?array
{
$template = static::getTemplate($slug);
if (! $template) {
return null;
}
$subject = $template->subject;
$body = $template->body;
foreach ($variables as $key => $value) {
$placeholder = '{{'.$key.'}}';
$subject = str_replace($placeholder, (string) $value, $subject);
$body = str_replace($placeholder, (string) $value, $body);
}
return [
'subject' => $subject,
'body' => $body,
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TaxRate extends Model
{
use HasFactory;
protected $fillable = [
'name',
'country_code',
'region_code',
'rate',
'type',
'priority',
'is_active',
];
protected function casts(): array
{
return [
'rate' => 'decimal:2',
'is_active' => 'boolean',
'priority' => 'integer',
];
}
/**
* Scope to only active tax rates.
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope to filter by country code.
*/
public function scopeForCountry(Builder $query, string $code): Builder
{
return $query->where('country_code', strtoupper($code));
}
/**
* Scope to filter by country and region.
*/
public function scopeForRegion(Builder $query, string $country, ?string $region): Builder
{
$query->where('country_code', strtoupper($country));
if ($region !== null) {
$query->where(function (Builder $q) use ($region): void {
$q->where('region_code', $region)
->orWhereNull('region_code');
});
}
return $query;
}
/**
* Get applicable tax rates for a given country and optional region, ordered by priority.
*
* @return Collection<int, TaxRate>
*/
public static function getApplicableRates(string $countryCode, ?string $regionCode = null): Collection
{
$query = static::query()
->active()
->where('country_code', strtoupper($countryCode));
if ($regionCode !== null) {
$query->where(function (Builder $q) use ($regionCode): void {
$q->where('region_code', $regionCode)
->orWhereNull('region_code');
});
} else {
$query->whereNull('region_code');
}
return $query->orderBy('priority')->get();
}
}

View File

@@ -8,6 +8,7 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\Plan;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@@ -30,8 +31,21 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::loginView(fn () => Inertia::render('Auth/Login'));
Fortify::registerView(fn () => Inertia::render('Auth/Register'));
Fortify::loginView(function () {
$selectedPlan = $this->getSelectedPlanFromIntendedUrl();
return Inertia::render('Auth/Login', [
'selectedPlan' => $selectedPlan,
]);
});
Fortify::registerView(function () {
$selectedPlan = $this->getSelectedPlanFromIntendedUrl();
return Inertia::render('Auth/Register', [
'selectedPlan' => $selectedPlan,
]);
});
Fortify::requestPasswordResetLinkView(fn () => Inertia::render('Auth/ForgotPassword'));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('Auth/ResetPassword', [
'token' => $request->route('token'),
@@ -51,4 +65,35 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
}
/**
* Extract selected plan info from the intended URL stored in session.
*
* When a guest clicks "Choose Plan" on a pricing/product page, they hit
* /checkout/{plan} which is behind auth middleware. Laravel stores the
* intended URL, and we parse it here to show plan context on auth pages.
*
* @return array{id: int, name: string, price: string, currency: string, billing_cycle: string, service_type: string}|null
*/
private function getSelectedPlanFromIntendedUrl(): ?array
{
$intended = session()->get('url.intended', '');
if (preg_match('/\/checkout\/(\d+)/', $intended, $matches)) {
$plan = Plan::find($matches[1]);
if ($plan && $plan->isAvailable()) {
return [
'id' => $plan->id,
'name' => $plan->name,
'price' => $plan->price,
'currency' => $plan->currency,
'billing_cycle' => $plan->billing_cycle,
'service_type' => $plan->service_type,
];
}
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EmailTemplate>
*/
class EmailTemplateFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'slug' => fake()->unique()->slug(2),
'name' => fake()->sentence(3),
'subject' => 'Test Subject {{customer_name}}',
'body' => "Hello {{customer_name}},\n\nThis is a test email template.\n\nRegards,\nEZSCALE",
'available_variables' => ['customer_name', 'amount', 'currency'],
'is_active' => true,
];
}
public function inactive(): static
{
return $this->state(fn (array $attributes): array => [
'is_active' => false,
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\TaxRate;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TaxRate>
*/
class TaxRateFactory extends Factory
{
protected $model = TaxRate::class;
private static int $factoryIndex = 0;
/** @return array<string, mixed> */
public function definition(): array
{
$countries = ['US', 'GB', 'DE', 'FR', 'CA', 'AU', 'NL', 'SE', 'JP', 'BR'];
$country = $countries[self::$factoryIndex % count($countries)];
self::$factoryIndex++;
return [
'name' => fake()->words(3, true).' Tax',
'country_code' => $country,
'region_code' => null,
'rate' => fake()->randomFloat(2, 1, 30),
'type' => fake()->randomElement(['inclusive', 'exclusive']),
'priority' => 0,
'is_active' => true,
];
}
public function inactive(): static
{
return $this->state(fn (array $attributes): array => [
'is_active' => false,
]);
}
public function inclusive(): static
{
return $this->state(fn (array $attributes): array => [
'type' => 'inclusive',
]);
}
public function exclusive(): static
{
return $this->state(fn (array $attributes): array => [
'type' => 'exclusive',
]);
}
public function forCountry(string $countryCode, ?string $regionCode = null): static
{
return $this->state(fn (array $attributes): array => [
'country_code' => strtoupper($countryCode),
'region_code' => $regionCode,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tax_rates', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('country_code', 2);
$table->string('region_code')->nullable();
$table->decimal('rate', 5, 2);
$table->enum('type', ['inclusive', 'exclusive'])->default('exclusive');
$table->integer('priority')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['country_code', 'region_code', 'type'], 'tax_rates_country_region_type_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tax_rates');
}
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('email_templates', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('name');
$table->string('subject');
$table->text('body');
$table->json('available_variables');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('email_templates');
}
};

View File

@@ -850,10 +850,64 @@ class DemoDataSeeder extends Seeder
$createdAt = now()->subDays(rand(1, 180));
$changes = null;
if (str_contains($actionDef['action'], 'updated') || str_contains($actionDef['action'], 'suspended') || str_contains($actionDef['action'], 'banned')) {
$action = $actionDef['action'];
// Generate realistic changes based on action type
if ($action === 'customer.updated') {
// Per-field old/new format (most common for updates)
$changes = json_encode([
'name' => ['old' => $faker->name(), 'new' => $faker->name()],
'email' => ['old' => $faker->safeEmail(), 'new' => $faker->safeEmail()],
]);
} elseif ($action === 'customer.suspended' || $action === 'customer.banned') {
// Per-field old/new for status changes
$newStatus = str_contains($action, 'suspended') ? 'suspended' : 'banned';
$changes = json_encode([
'status' => ['old' => 'active', 'new' => $newStatus],
]);
} elseif ($action === 'service.updated') {
// Per-field old/new with plan change
$plans = ['Starter VPS', 'Basic VPS', 'Pro VPS', 'Enterprise VPS', 'Basic Dedicated', 'Pro Dedicated'];
$oldPlan = $faker->randomElement($plans);
$newPlan = $faker->randomElement(array_diff($plans, [$oldPlan]));
$changes = json_encode([
'plan' => ['old' => $oldPlan, 'new' => $newPlan],
'status' => ['old' => 'active', 'new' => 'active'],
]);
} elseif ($action === 'plan.updated') {
// Per-field old/new with pricing
$changes = json_encode([
'price' => ['old' => number_format($faker->randomFloat(2, 5, 50), 2), 'new' => number_format($faker->randomFloat(2, 5, 50), 2)],
'name' => ['old' => $faker->word().' Plan', 'new' => $faker->word().' Plan'],
'disk_space' => ['old' => $faker->randomElement(['20GB', '50GB', '100GB']), 'new' => $faker->randomElement(['50GB', '100GB', '200GB'])],
]);
} elseif ($action === 'settings.updated') {
// Per-field old/new for settings
$changes = json_encode([
'site_name' => ['old' => 'EZSCALE Cloud', 'new' => 'EZSCALE Cloud Hosting'],
'maintenance_mode' => ['old' => false, 'new' => true],
]);
} elseif (str_contains($action, 'created') || str_contains($action, 'completed')) {
// Top-level old/new (new only = create)
$changes = json_encode([
'old' => null,
'new' => [
'status' => $faker->randomElement(['active', 'pending', 'completed']),
'amount' => number_format($faker->randomFloat(2, 10, 500), 2),
],
]);
} elseif (str_contains($action, 'cancelled') || str_contains($action, 'voided')) {
// Top-level old/new (both = update)
$changes = json_encode([
'old' => ['status' => 'active'],
'new' => ['status' => $faker->randomElement(['suspended', 'banned', 'active'])],
'new' => ['status' => $faker->randomElement(['cancelled', 'voided'])],
]);
} elseif ($action === 'impersonation.started') {
// Flat key-value data
$changes = json_encode([
'target_user' => $faker->name(),
'target_email' => $faker->safeEmail(),
'reason' => 'Support request',
]);
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\EmailTemplate;
use Illuminate\Database\Seeder;
class EmailTemplateSeeder extends Seeder
{
public function run(): void
{
$templates = $this->getDefaultTemplates();
foreach ($templates as $template) {
EmailTemplate::query()->updateOrCreate(
['slug' => $template['slug']],
$template,
);
}
}
/**
* @return array<int, array{slug: string, name: string, subject: string, body: string, available_variables: array<int, string>}>
*/
public static function getDefaultTemplates(): array
{
return [
[
'slug' => 'payment-succeeded',
'name' => 'Payment Succeeded',
'subject' => 'Payment of {{currency}} {{amount}} Received',
'body' => "Hello {{customer_name}}!\n\nWe've successfully processed your payment of **{{currency}} {{amount}}**.\n\nInvoice: #{{invoice_number}}\nDate: {{date}}\n\nThank you for choosing EZSCALE!",
'available_variables' => ['customer_name', 'amount', 'currency', 'invoice_number', 'date'],
],
[
'slug' => 'payment-failed',
'name' => 'Payment Failed',
'subject' => 'Payment Failed - Action Required',
'body' => "Hello {{customer_name}},\n\nWe were unable to process your payment of **{{currency}} {{amount}}**.\n\nPayment method: {{payment_method}}\nReason: {{error_message}}\n\nPlease update your payment method to avoid service interruption.\n\nIf you need assistance, please contact our support team.",
'available_variables' => ['customer_name', 'amount', 'currency', 'payment_method', 'error_message'],
],
[
'slug' => 'subscription-created',
'name' => 'Subscription Created',
'subject' => 'Subscription Confirmed - {{plan_name}}',
'body' => "Welcome aboard, {{customer_name}}!\n\nYour subscription to **{{plan_name}}** has been created successfully.\n\nAmount: **{{currency}} {{amount}}**\nBilling cycle: **{{billing_cycle}}**\n\nYour service is being provisioned and will be ready shortly.\n\nThank you for choosing EZSCALE!",
'available_variables' => ['customer_name', 'plan_name', 'amount', 'currency', 'billing_cycle'],
],
[
'slug' => 'subscription-cancelled',
'name' => 'Subscription Cancelled',
'subject' => 'Subscription Cancelled - {{plan_name}}',
'body' => "Hello {{customer_name}},\n\nYour subscription to **{{plan_name}}** has been cancelled.\n\nCancellation date: {{cancellation_date}}\nService active until: {{end_date}}\n\nYour service will remain active until the end of your current billing period. You can resubscribe at any time to continue using our services.\n\nWe hope to see you again soon!",
'available_variables' => ['customer_name', 'plan_name', 'cancellation_date', 'end_date'],
],
[
'slug' => 'service-provisioned',
'name' => 'Service Provisioned',
'subject' => 'Your {{service_type}} Service is Ready!',
'body' => "Hello {{customer_name}}!\n\nYour **{{service_type}}** service has been provisioned and is ready to use.\n\nPlan: **{{plan_name}}**\nHostname: **{{hostname}}**\nIP Address: **{{ip_address}}**\n\nIf you need help getting started, check our knowledge base or contact support.",
'available_variables' => ['customer_name', 'service_type', 'hostname', 'ip_address', 'plan_name'],
],
[
'slug' => 'invoice-generated',
'name' => 'Invoice Generated',
'subject' => 'Invoice #{{invoice_number}} - {{currency}} {{amount}}',
'body' => "Hello {{customer_name}},\n\nA new invoice has been generated for your account.\n\nInvoice: **#{{invoice_number}}**\nAmount: **{{currency}} {{amount}}**\nDue date: **{{due_date}}**\n\nThank you for your business!",
'available_variables' => ['customer_name', 'invoice_number', 'amount', 'currency', 'due_date'],
],
[
'slug' => 'service-credentials',
'name' => 'Service Credentials',
'subject' => 'Your {{service_type}} Server Credentials - EZSCALE',
'body' => "Hello {{customer_name}}!\n\nYour **{{service_type}}** service has been provisioned and is ready to use.\n\nHere are your access credentials:\n\n**Hostname:** {{hostname}}\n**IP Address:** {{ip_address}}\n**Username:** {{username}}\n**Password:** {{password}}\n\nFor security, we recommend changing your password after first login.\n\nIf you need help, our support team is available 24/7.",
'available_variables' => ['customer_name', 'service_type', 'hostname', 'username', 'password', 'ip_address'],
],
];
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,19 +0,0 @@
<script lang="ts" setup>
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const page = usePage()
const flash = computed(() => (page.props as Record<string, unknown>).flash as Record<string, string> || {})
</script>
<template>
<VAlert v-if="flash.success" type="success" variant="tonal" closable class="mb-4">
{{ flash.success }}
</VAlert>
<VAlert v-if="flash.error" type="error" variant="tonal" closable class="mb-4">
{{ flash.error }}
</VAlert>
<VAlert v-if="flash.info" type="info" variant="tonal" closable class="mb-4">
{{ flash.info }}
</VAlert>
</template>

View File

@@ -1,163 +0,0 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
interface NotificationItem {
id: string
type: string
message: string
data: Record<string, unknown>
read: boolean
created_at: string
}
const notifications = ref<NotificationItem[]>([])
const unreadCount = ref<number>(0)
const loading = ref<boolean>(false)
const menu = ref<boolean>(false)
function resolveIcon(type: string): string {
const icons: Record<string, string> = {
payment_succeeded: 'tabler-credit-card',
payment_failed: 'tabler-credit-card-off',
subscription_created: 'tabler-rosette-discount-check',
subscription_cancelled: 'tabler-circle-x',
service_provisioned: 'tabler-server',
invoice_generated: 'tabler-file-invoice',
}
return icons[type] ?? 'tabler-bell'
}
function resolveColor(type: string): string {
const colors: Record<string, string> = {
payment_succeeded: 'success',
payment_failed: 'error',
subscription_created: 'primary',
subscription_cancelled: 'warning',
service_provisioned: 'info',
invoice_generated: 'secondary',
}
return colors[type] ?? 'default'
}
async function fetchNotifications(): Promise<void> {
loading.value = true
try {
const response = await axios.get('/notifications')
notifications.value = response.data.notifications
unreadCount.value = response.data.unread_count
}
catch {
// Silently fail for notification fetch
}
finally {
loading.value = false
}
}
async function markAsRead(id: string): Promise<void> {
try {
await axios.post(`/notifications/${id}/read`)
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
catch {
// Silently fail
}
}
async function markAllAsRead(): Promise<void> {
try {
await axios.post('/notifications/read-all')
notifications.value.forEach(n => { n.read = true })
unreadCount.value = 0
}
catch {
// Silently fail
}
}
onMounted(() => {
fetchNotifications()
})
</script>
<template>
<VMenu
v-model="menu"
:close-on-content-click="false"
offset="14px"
@update:model-value="(val: boolean) => { if (val) fetchNotifications() }"
>
<template #activator="{ props: menuProps }">
<VBadge
:content="unreadCount"
:model-value="unreadCount > 0"
color="error"
overlap
>
<VBtn
icon="tabler-bell"
variant="text"
size="small"
v-bind="menuProps"
/>
</VBadge>
</template>
<VCard width="380" max-height="500">
<VCardTitle class="d-flex align-center justify-space-between pa-4">
<span class="text-body-1 font-weight-bold">Notifications</span>
<VBtn
v-if="unreadCount > 0"
variant="text"
size="small"
color="primary"
@click="markAllAsRead"
>
Mark all read
</VBtn>
</VCardTitle>
<VDivider />
<VList v-if="notifications.length > 0" density="compact" class="py-0">
<VListItem
v-for="notification in notifications"
:key="notification.id"
:class="{ 'bg-surface-variant': !notification.read }"
@click="markAsRead(notification.id)"
>
<template #prepend>
<VAvatar
size="36"
:color="resolveColor(notification.type)"
variant="tonal"
>
<VIcon :icon="resolveIcon(notification.type)" size="20" />
</VAvatar>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ notification.message }}
</VListItemTitle>
<VListItemSubtitle class="text-caption">
{{ notification.created_at }}
</VListItemSubtitle>
</VListItem>
</VList>
<div v-else class="text-center pa-8">
<VIcon icon="tabler-bell-off" size="32" color="disabled" class="mb-2" />
<div class="text-body-2 text-medium-emphasis">
No notifications
</div>
</div>
</VCard>
</VMenu>
</template>

View File

@@ -1,53 +0,0 @@
<script lang="ts" setup>
import { computed, useAttrs, useId } from 'vue'
defineOptions({
name: 'AppSelect',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-select-${_elementIdToken}` : _id
})
const label = computed(() => useAttrs().label as string | undefined)
</script>
<template>
<div
class="app-select flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
style="line-height: 15px;"
:text="label"
/>
<VSelect
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
menuProps: { contentClass: ['app-inner-list', 'app-select__content', 'v-select__content', $attrs.multiple !== undefined ? 'v-list-select-multiple' : ''] },
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VSelect>
</div>
</template>

View File

@@ -1,52 +0,0 @@
<script lang="ts" setup>
import { computed, useAttrs, useId } from 'vue'
defineOptions({
name: 'AppTextField',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-text-field-${_elementIdToken}` : _id
})
const label = computed(() => useAttrs().label as string | undefined)
</script>
<template>
<div
class="app-text-field flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2 text-wrap"
style="line-height: 15px;"
:text="label"
/>
<VTextField
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextField>
</div>
</template>

View File

@@ -1,51 +0,0 @@
<script lang="ts" setup>
import { computed, useAttrs, useId } from 'vue'
defineOptions({
name: 'AppTextarea',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-textarea-${_elementIdToken}` : _id
})
const label = computed(() => useAttrs().label as string | undefined)
</script>
<template>
<div
class="app-textarea flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
:text="label"
/>
<VTextarea
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextarea>
</div>
</template>

View File

@@ -209,33 +209,114 @@ interface ChangesDiff {
fields: string[]
}
/**
* Check if a value looks like an {old, new} pair used for per-field diffs.
*/
function isOldNewPair(value: unknown): value is { old: unknown, new: unknown } {
return (
typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& ('old' in value || 'new' in value)
)
}
/**
* Detect per-field old/new format: {"plan": {"old": "Basic", "new": "Pro"}, "status": {"old": "active", "new": "suspended"}}
* Returns true if at least one top-level value is an {old, new} pair.
*/
function isPerFieldOldNewFormat(changes: Record<string, unknown>): boolean {
const values = Object.values(changes)
return values.length > 0 && values.some(v => isOldNewPair(v))
}
/**
* Parse the changes JSON into a normalized diff structure.
*
* Supports multiple formats found in the codebase:
* 1. Per-field old/new: {"plan": {"old": "Basic", "new": "Pro"}, "status": {"old": "active", "new": "suspended"}}
* 2. Top-level old/new: {"old": {"status": "active"}, "new": {"status": "suspended"}}
* 3. Top-level before/after: {"before": {...}, "after": {...}}
* 4. Flat key-value pairs: {"old_status": "active", "new_status": "suspended"} or {"name": "John", "email": "..."}
*/
function parseChanges(changes: Record<string, unknown> | null): ChangesDiff {
if (!changes || Object.keys(changes).length === 0) {
return { type: 'generic', before: null, after: null, fields: [] }
}
const hasBefore = 'before' in changes && changes.before !== null && typeof changes.before === 'object'
const hasAfter = 'after' in changes && changes.after !== null && typeof changes.after === 'object'
// Format 1: Per-field old/new — {"plan": {"old": "Basic", "new": "Pro"}, ...}
if (isPerFieldOldNewFormat(changes)) {
const before: Record<string, unknown> = {}
const after: Record<string, unknown> = {}
const fields: string[] = []
for (const [field, value] of Object.entries(changes)) {
if (isOldNewPair(value)) {
fields.push(field)
before[field] = value.old
after[field] = value.new
}
else {
// Non old/new field mixed in — treat as unchanged context
fields.push(field)
before[field] = value
after[field] = value
}
}
if (hasBefore && hasAfter) {
const before = changes.before as Record<string, unknown>
const after = changes.after as Record<string, unknown>
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])]
return { type: 'update', before, after, fields }
}
// Format 2: Top-level old/new — {"old": {...}, "new": {...}}
const hasOld = 'old' in changes && changes.old !== null && typeof changes.old === 'object'
const hasNew = 'new' in changes && changes.new !== null && typeof changes.new === 'object'
if (hasOld || hasNew) {
const before = hasOld ? (changes.old as Record<string, unknown>) : null
const after = hasNew ? (changes.new as Record<string, unknown>) : null
const fieldSet = new Set<string>([
...(before ? Object.keys(before) : []),
...(after ? Object.keys(after) : []),
])
const fields = [...fieldSet]
if (hasOld && hasNew) {
return { type: 'update', before, after, fields }
}
if (hasNew && !hasOld) {
return { type: 'create', before: null, after, fields }
}
if (hasOld && !hasNew) {
return { type: 'delete', before, after: null, fields }
}
}
// Format 3: Top-level before/after — {"before": {...}, "after": {...}}
const hasBefore = 'before' in changes && changes.before !== null && typeof changes.before === 'object'
const hasAfter = 'after' in changes && changes.after !== null && typeof changes.after === 'object'
if (hasBefore || hasAfter) {
const before = hasBefore ? (changes.before as Record<string, unknown>) : null
const after = hasAfter ? (changes.after as Record<string, unknown>) : null
const fieldSet = new Set<string>([
...(before ? Object.keys(before) : []),
...(after ? Object.keys(after) : []),
])
const fields = [...fieldSet]
if (hasBefore && hasAfter) {
return { type: 'update', before, after, fields }
}
if (hasAfter && !hasBefore) {
const after = changes.after as Record<string, unknown>
return { type: 'create', before: null, after, fields: Object.keys(after) }
return { type: 'create', before: null, after, fields }
}
if (hasBefore && !hasAfter) {
const before = changes.before as Record<string, unknown>
return { type: 'delete', before, after: null, fields: Object.keys(before) }
return { type: 'delete', before, after: null, fields }
}
}
// No before/after structure -- treat top-level keys as generic data
return { type: 'generic', before: null, after: null, fields: Object.keys(changes) }
// Format 4: Flat key-value pairs — treat as generic data display
return { type: 'generic', before: null, after: changes, fields: Object.keys(changes) }
}
function isFieldChanged(before: Record<string, unknown> | null, after: Record<string, unknown> | null, field: string): boolean {
@@ -245,6 +326,18 @@ function isFieldChanged(before: Record<string, unknown> | null, after: Record<st
return JSON.stringify(before[field]) !== JSON.stringify(after[field])
}
function countChangedFields(log: AuditLog): number {
const diff = parseChanges(log.changes)
if (diff.type === 'generic') {
return diff.fields.length
}
if (diff.type === 'create' || diff.type === 'delete') {
return diff.fields.length
}
// For updates, count only fields that actually changed
return diff.fields.filter(f => isFieldChanged(diff.before, diff.after, f)).length
}
function clearFilters(): void {
search.value = ''
actionFilter.value = ''
@@ -419,13 +512,20 @@ function exportData(format: 'csv' | 'json'): void {
@click="hasChanges(log) ? toggleRow(log.id) : undefined"
>
<td>
<VBtn
<VBadge
v-if="hasChanges(log)"
:content="countChangedFields(log)"
color="primary"
:offset-x="-2"
:offset-y="-2"
>
<VBtn
variant="text"
size="x-small"
:icon="isExpanded(log.id) ? 'tabler-chevron-down' : 'tabler-chevron-right'"
@click.stop="toggleRow(log.id)"
/>
</VBadge>
</td>
<td class="text-body-2">
{{ formatDateTime(log.created_at) }}
@@ -597,9 +697,33 @@ function exportData(format: 'csv' | 'json'): void {
</VTable>
</template>
<!-- Generic: raw JSON -->
<!-- Generic: flat key-value data -->
<template v-else>
<pre class="text-caption pa-3 rounded bg-surface" style="white-space: pre-wrap; word-break: break-all;">{{ JSON.stringify(log.changes, null, 2) }}</pre>
<VChip size="x-small" color="secondary" variant="tonal" class="mb-2">
Data
</VChip>
<VTable density="compact" class="rounded border">
<thead>
<tr>
<th class="text-caption" style="width: 30%;">
Field
</th>
<th class="text-caption">
Value
</th>
</tr>
</thead>
<tbody>
<tr v-for="field in parseChanges(log.changes).fields" :key="field">
<td class="text-caption font-weight-medium">
{{ formatFieldName(field) }}
</td>
<td class="text-caption">
{{ formatValue(parseChanges(log.changes).after?.[field] ?? log.changes?.[field]) }}
</td>
</tr>
</tbody>
</VTable>
</template>
</div>
</td>
@@ -864,18 +988,41 @@ function exportData(format: 'csv' | 'json'): void {
</VCard>
</template>
<!-- Generic: raw JSON -->
<!-- Generic: flat key-value data -->
<template v-else>
<VCard variant="outlined">
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
<VIcon icon="tabler-code" size="16" />
Raw Changes Data
<VIcon icon="tabler-list-details" size="16" />
Recorded Data
</VCardTitle>
<VDivider />
<VCardText>
<pre class="text-caption rounded pa-3 bg-surface-variant" style="white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto;">{{ JSON.stringify(selectedLog.changes, null, 2) }}</pre>
<VCardText class="pa-0">
<VTable density="compact">
<tbody>
<tr
v-for="field in parseChanges(selectedLog.changes).fields"
:key="field"
>
<td class="text-caption font-weight-medium" style="width: 40%;">
{{ formatFieldName(field) }}
</td>
<td class="text-caption">
{{ formatValue(parseChanges(selectedLog.changes).after?.[field] ?? selectedLog.changes?.[field]) }}
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
<!-- Also show raw JSON in a collapsible section -->
<VExpansionPanels class="mt-4" variant="accordion">
<VExpansionPanel title="Raw JSON">
<VExpansionPanelText>
<pre class="text-caption rounded pa-3 bg-surface-variant" style="white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto;">{{ JSON.stringify(selectedLog.changes, null, 2) }}</pre>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</template>
</div>

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import { computed } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
interface EmailTemplate {
id: number
slug: string
name: string
subject: string
body: string
available_variables: string[]
is_active: boolean
updated_at: string
}
interface Props {
templates: EmailTemplate[]
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const tableHeaders = computed(() => [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Subject', key: 'subject', sortable: true },
{ title: 'Status', key: 'is_active', sortable: true, align: 'center' as const },
{ title: 'Last Modified', key: 'updated_at', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' as const },
])
interface CategoryGroup {
name: string
icon: string
color: string
slugs: string[]
}
const categories: CategoryGroup[] = [
{ name: 'Payment', icon: 'tabler-credit-card', color: 'success', slugs: ['payment-succeeded', 'payment-failed'] },
{ name: 'Subscription', icon: 'tabler-repeat', color: 'primary', slugs: ['subscription-created', 'subscription-cancelled'] },
{ name: 'Service', icon: 'tabler-server', color: 'info', slugs: ['service-provisioned', 'service-credentials'] },
{ name: 'Invoice', icon: 'tabler-file-invoice', color: 'warning', slugs: ['invoice-generated'] },
]
function getCategoryForTemplate(slug: string): CategoryGroup | undefined {
return categories.find(cat => cat.slugs.includes(slug))
}
const groupedTemplates = computed(() => {
return categories.map(category => ({
...category,
templates: props.templates.filter(t => category.slugs.includes(t.slug)),
})).filter(group => group.templates.length > 0)
})
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">
Email Templates
</div>
<div class="text-body-2 text-medium-emphasis">
Customize notification email content sent to customers
</div>
</div>
</div>
<!-- Template Groups -->
<div
v-for="group in groupedTemplates"
:key="group.name"
class="mb-6"
>
<div class="d-flex align-center gap-2 mb-3">
<VAvatar
:color="group.color"
variant="tonal"
size="32"
rounded
>
<VIcon
:icon="group.icon"
size="18"
/>
</VAvatar>
<span class="text-h6 font-weight-medium">{{ group.name }}</span>
</div>
<VCard>
<VDataTable
:headers="tableHeaders"
:items="group.templates"
:items-per-page="-1"
hover
class="text-no-wrap"
:hide-default-footer="true"
>
<!-- Name -->
<template #item.name="{ item }">
<div class="d-flex align-center gap-2">
<span class="font-weight-medium">{{ item.name }}</span>
<VChip
size="x-small"
variant="tonal"
color="secondary"
class="font-monospace"
>
{{ item.slug }}
</VChip>
</div>
</template>
<!-- Subject -->
<template #item.subject="{ item }">
<span class="text-body-2 text-truncate d-inline-block" style="max-width: 350px;">
{{ item.subject }}
</span>
</template>
<!-- Status -->
<template #item.is_active="{ item }">
<VChip
:color="item.is_active ? 'success' : 'error'"
size="small"
>
{{ item.is_active ? 'Active' : 'Inactive' }}
</VChip>
</template>
<!-- Last Modified -->
<template #item.updated_at="{ item }">
<span class="text-body-2">{{ formatDate(item.updated_at) }}</span>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<Link
:href="`/email-templates/${item.id}/edit`"
class="text-decoration-none"
>
<VBtn
icon="tabler-edit"
variant="text"
size="small"
color="primary"
/>
</Link>
</template>
<!-- No data -->
<template #no-data>
<div class="text-center py-8">
<VIcon
icon="tabler-mail-cog"
size="48"
color="disabled"
class="mb-2"
/>
<div class="text-medium-emphasis">
No email templates found.
</div>
</div>
</template>
</VDataTable>
</VCard>
</div>
<!-- Empty state when no templates at all -->
<VCard v-if="templates.length === 0">
<VCardText class="text-center py-12">
<VIcon
icon="tabler-mail-cog"
size="64"
color="disabled"
class="mb-4"
/>
<div class="text-h6 text-medium-emphasis mb-2">
No Email Templates
</div>
<div class="text-body-2 text-medium-emphasis">
Run the EmailTemplateSeeder to create default templates.
</div>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -36,6 +36,14 @@ interface ProvisioningLogItem {
created_at: string
}
interface ServiceSubscription {
id: number
ends_at: string | null
current_period_end: string | null
stripe_status: string
type: string
}
interface ServiceDetail {
id: number
user_id: number
@@ -54,8 +62,10 @@ interface ServiceDetail {
deleted_at: string | null
created_at: string
updated_at: string
subscription_id: number | null
user: ServiceUser | null
plan: ServicePlan | null
subscription: ServiceSubscription | null
provisioning_logs: ProvisioningLogItem[]
}
@@ -75,6 +85,7 @@ const confirmMessage = ref<string>('')
const confirmColor = ref<string>('warning')
const modifyDialog = ref<boolean>(false)
const extendExpiryDialog = ref<boolean>(false)
const suspendForm = useForm({})
const unsuspendForm = useForm({})
@@ -86,11 +97,21 @@ const modifyForm = useForm({
plan_id: props.service.plan?.id ?? null,
notes: '',
})
const extendExpiryForm = useForm({
new_expiry_date: '',
reason: '',
})
const isProcessing = computed<boolean>(() =>
suspendForm.processing || unsuspendForm.processing || terminateForm.processing || provisionForm.processing || modifyForm.processing || archiveForm.processing || restoreForm.processing,
suspendForm.processing || unsuspendForm.processing || terminateForm.processing || provisionForm.processing || modifyForm.processing || archiveForm.processing || restoreForm.processing || extendExpiryForm.processing,
)
const currentExpiryDate = computed<string | null>(() => {
return props.service.subscription?.ends_at ?? props.service.subscription?.current_period_end ?? null
})
const hasSubscription = computed<boolean>(() => !!props.service.subscription)
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate' | 'provision' | 'archive' | 'restore'): void {
confirmAction.value = action
@@ -171,6 +192,22 @@ function submitModify(): void {
})
}
function openExtendExpiryDialog(): void {
extendExpiryForm.new_expiry_date = ''
extendExpiryForm.reason = ''
extendExpiryForm.clearErrors()
extendExpiryDialog.value = true
}
function submitExtendExpiry(): void {
extendExpiryForm.post(`/services/${props.service.id}/extend-expiry`, {
preserveScroll: true,
onSuccess: () => {
extendExpiryDialog.value = false
},
})
}
function resolveServiceStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
@@ -496,6 +533,76 @@ function formatPrice(price: string | number, cycle?: string): string {
</VCardText>
</VCard>
<!-- Subscription & Expiry -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-calendar-event" size="22" />
<span>Subscription Expiry</span>
</VCardTitle>
<VCardText v-if="!hasSubscription" class="text-center py-6">
<VIcon icon="tabler-calendar-off" size="40" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No subscription associated with this service.
</div>
</VCardText>
<VCardText v-else>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Status</span>
</template>
<VListItemTitle>
<VChip
:color="service.subscription!.stripe_status === 'active' ? 'success' : service.subscription!.stripe_status === 'canceled' ? 'error' : 'warning'"
size="small"
class="text-capitalize"
>
{{ service.subscription!.stripe_status }}
</VChip>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Period End</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDate(service.subscription!.current_period_end) }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Expires / Cancels</span>
</template>
<VListItemTitle class="text-body-2">
<template v-if="service.subscription!.ends_at">
<span class="text-error font-weight-medium">{{ formatDate(service.subscription!.ends_at) }}</span>
</template>
<template v-else>
<span class="text-success">Auto-renewing</span>
</template>
</VListItemTitle>
</VListItem>
</VList>
<VDivider class="my-4" />
<VBtn
color="primary"
variant="tonal"
block
:disabled="isProcessing || service.status === 'terminated'"
@click="openExtendExpiryDialog"
>
<VIcon icon="tabler-calendar-plus" start />
Extend Expiry Date
</VBtn>
</VCardText>
</VCard>
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-user" size="22" />
@@ -675,5 +782,85 @@ function formatPrice(price: string | number, cycle?: string): string {
</VCardActions>
</VCard>
</VDialog>
<!-- Extend Expiry Dialog -->
<VDialog v-model="extendExpiryDialog" max-width="500" persistent>
<VCard>
<VCardTitle class="d-flex align-center gap-2 pa-5">
<VIcon icon="tabler-calendar-plus" size="22" color="primary" />
<span class="text-h5">Extend Service Expiry</span>
</VCardTitle>
<VCardText class="px-5 pb-2">
<VAlert
v-if="currentExpiryDate"
type="info"
variant="tonal"
density="compact"
class="mb-4"
>
Current expiry: <strong>{{ formatDate(currentExpiryDate) }}</strong>
</VAlert>
<VAlert
v-else
type="warning"
variant="tonal"
density="compact"
class="mb-4"
>
No expiry date is currently set (subscription is auto-renewing).
</VAlert>
<VRow>
<VCol cols="12">
<label class="text-body-2 text-medium-emphasis mb-1 d-block">New Expiry Date</label>
<VTextField
v-model="extendExpiryForm.new_expiry_date"
type="date"
:error-messages="extendExpiryForm.errors.new_expiry_date"
variant="outlined"
density="comfortable"
placeholder="Select a date"
:min="new Date(Date.now() + 86400000).toISOString().split('T')[0]"
/>
</VCol>
<VCol cols="12">
<label class="text-body-2 text-medium-emphasis mb-1 d-block">Reason (Optional)</label>
<VTextarea
v-model="extendExpiryForm.reason"
:error-messages="extendExpiryForm.errors.reason"
placeholder="Reason for extending expiry (recorded in audit log)..."
variant="outlined"
density="comfortable"
rows="2"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn
variant="text"
:disabled="extendExpiryForm.processing"
@click="extendExpiryDialog = false"
>
Cancel
</VBtn>
<VBtn
color="primary"
variant="flat"
:loading="extendExpiryForm.processing"
:disabled="!extendExpiryForm.new_expiry_date"
@click="submitExtendExpiry"
>
<VIcon icon="tabler-calendar-plus" start />
Extend Expiry
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -225,7 +225,7 @@ const faqs = [
<!-- Plan CTA -->
<a
:href="accountUrl + '/register'"
:href="accountUrl + '/checkout/' + plan.id"
class="text-decoration-none d-block"
>
<VBtn
@@ -341,7 +341,7 @@ const faqs = [
class="text-center py-2"
>
<a
:href="accountUrl + '/register'"
:href="accountUrl + '/checkout/' + plan.id"
class="text-decoration-none"
>
<VBtn

View File

@@ -282,4 +282,17 @@ export interface UpcomingRenewal {
days_until_renewal: number
}
export interface TaxRate {
id: number
name: string
country_code: string
region_code: string | null
rate: string
type: 'inclusive' | 'exclusive'
priority: number
is_active: boolean
created_at: string
updated_at: string
}
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'

View File

@@ -6,12 +6,14 @@ use App\Http\Controllers\Admin\AuditLogController;
use App\Http\Controllers\Admin\CouponController;
use App\Http\Controllers\Admin\CustomerController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\EmailTemplateController;
use App\Http\Controllers\Admin\ImpersonationController;
use App\Http\Controllers\Admin\InvoiceController;
use App\Http\Controllers\Admin\OrderController;
use App\Http\Controllers\Admin\PlanController;
use App\Http\Controllers\Admin\ServiceController;
use App\Http\Controllers\Admin\SettingsController;
use App\Http\Controllers\Admin\TaxRateController;
use App\Http\Controllers\Admin\TicketController as AdminTicketController;
use Illuminate\Support\Facades\Route;
@@ -40,6 +42,7 @@ Route::post('services/{service}/unsuspend', [ServiceController::class, 'unsuspen
Route::post('services/{service}/terminate', [ServiceController::class, 'terminate'])->name('services.terminate');
Route::post('services/{service}/provision', [ServiceController::class, 'provision'])->name('services.provision');
Route::post('services/{service}/restore', [ServiceController::class, 'restore'])->name('services.restore');
Route::post('services/{service}/extend-expiry', [ServiceController::class, 'extendExpiry'])->name('admin.services.extend-expiry');
Route::resource('invoices', InvoiceController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
Route::get('invoices/{invoice}/download', [InvoiceController::class, 'download'])->name('invoices.download');
@@ -63,6 +66,23 @@ Route::post('orders/{order}/complete', [OrderController::class, 'complete'])->na
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel'])->name('orders.cancel');
Route::put('orders/{order}/notes', [OrderController::class, 'updateNotes'])->name('orders.notes');
Route::resource('tax-rates', TaxRateController::class)->names([
'index' => 'admin.tax-rates.index',
'create' => 'admin.tax-rates.create',
'store' => 'admin.tax-rates.store',
'edit' => 'admin.tax-rates.edit',
'update' => 'admin.tax-rates.update',
'destroy' => 'admin.tax-rates.destroy',
])->except(['show']);
Route::post('tax-rates/{taxRate}/toggle-active', [TaxRateController::class, 'toggleActive'])->name('admin.tax-rates.toggle-active');
// Email Templates
Route::get('email-templates', [EmailTemplateController::class, 'index'])->name('admin.email-templates.index');
Route::get('email-templates/{emailTemplate}/edit', [EmailTemplateController::class, 'edit'])->name('admin.email-templates.edit');
Route::put('email-templates/{emailTemplate}', [EmailTemplateController::class, 'update'])->name('admin.email-templates.update');
Route::post('email-templates/{emailTemplate}/preview', [EmailTemplateController::class, 'preview'])->name('admin.email-templates.preview');
Route::post('email-templates/{emailTemplate}/reset', [EmailTemplateController::class, 'resetToDefault'])->name('admin.email-templates.reset');
Route::get('audit-logs/export', [AuditLogController::class, 'export'])->name('audit-logs.export');
Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index');

View File

@@ -2,55 +2,56 @@
declare(strict_types=1);
use App\Models\Service;
use App\Services\Provisioning\ProvisioningFactory;
use Illuminate\Http\Request;
use App\Http\Controllers\Api\V1\Admin\AdminAnalyticsController;
use App\Http\Controllers\Api\V1\Admin\AdminCustomerController;
use App\Http\Controllers\Api\V1\Admin\AdminServiceController;
use App\Http\Controllers\Api\V1\CustomerInvoiceController;
use App\Http\Controllers\Api\V1\CustomerServiceController;
use App\Http\Controllers\Api\V1\CustomerSubscriptionController;
use App\Http\Controllers\Api\V1\CustomerTicketController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->group(function (): void {
//
Route::middleware(['auth:api', 'throttle:api'])->prefix('v1')->group(function (): void {
// Services
Route::get('services', [CustomerServiceController::class, 'index'])->name('api.v1.services.index');
Route::get('services/{service}', [CustomerServiceController::class, 'show'])->name('api.v1.services.show');
Route::post('services/{service}/reboot', [CustomerServiceController::class, 'reboot'])->name('api.v1.services.reboot');
// Invoices
Route::get('invoices', [CustomerInvoiceController::class, 'index'])->name('api.v1.invoices.index');
Route::get('invoices/{invoice}/pdf', [CustomerInvoiceController::class, 'downloadPdf'])->name('api.v1.invoices.pdf');
// Subscriptions
Route::get('subscriptions', [CustomerSubscriptionController::class, 'index'])->name('api.v1.subscriptions.index');
Route::post('subscriptions/{subscription}/cancel', [CustomerSubscriptionController::class, 'cancel'])->name('api.v1.subscriptions.cancel');
// Tickets
Route::get('tickets', [CustomerTicketController::class, 'index'])->name('api.v1.tickets.index');
Route::post('tickets', [CustomerTicketController::class, 'store'])->name('api.v1.tickets.store');
Route::get('tickets/{ticket}', [CustomerTicketController::class, 'show'])->name('api.v1.tickets.show');
Route::post('tickets/{ticket}/reply', [CustomerTicketController::class, 'reply'])->name('api.v1.tickets.reply');
});
// Debug endpoint to check request details
Route::any('/debug/request', function (Request $request) {
return response()->json([
'method' => $request->method(),
'url' => $request->url(),
'fullUrl' => $request->fullUrl(),
'path' => $request->path(),
'is_webhook' => $request->is('webhooks/*'),
'is_api' => $request->is('api/*'),
'has_csrf_token' => $request->hasHeader('X-CSRF-TOKEN') || $request->hasHeader('X-XSRF-TOKEN'),
'headers' => $request->headers->all(),
]);
})->name('api.debug.request');
/*
|--------------------------------------------------------------------------
| Admin API Routes
|--------------------------------------------------------------------------
|
| These routes are protected by Passport authentication and require the
| admin role. Rate limited at 120 requests per minute.
|
*/
Route::middleware(['auth:api', 'role:admin', 'throttle:120,1'])->prefix('v1/admin')->group(function (): void {
// Customers
Route::get('customers', [AdminCustomerController::class, 'index'])->name('api.v1.admin.customers.index');
Route::get('customers/{user}', [AdminCustomerController::class, 'show'])->name('api.v1.admin.customers.show');
// Test endpoint for VirtFusion provisioning (no auth required for testing)
Route::post('/test/provision/{service}', function (Request $request, Service $service) {
try {
$factory = new ProvisioningFactory;
$provisioningService = $factory->make($service);
// Services
Route::get('services', [AdminServiceController::class, 'index'])->name('api.v1.admin.services.index');
Route::get('services/{service}', [AdminServiceController::class, 'show'])->name('api.v1.admin.services.show');
Route::post('services/{service}/suspend', [AdminServiceController::class, 'suspend'])->name('api.v1.admin.services.suspend');
Route::post('services/{service}/unsuspend', [AdminServiceController::class, 'unsuspend'])->name('api.v1.admin.services.unsuspend');
// Get or create a fake subscription for testing
$subscription = $service->subscription;
if (! $subscription) {
return response()->json([
'error' => 'Service has no subscription. Create a subscription first.',
], 400);
}
$result = $provisioningService->provision($subscription);
return response()->json([
'success' => true,
'service' => $result,
'message' => 'Service provisioned successfully',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
'trace' => config('app.debug') ? $e->getTraceAsString() : null,
], 500);
}
})->name('api.test.provision');
// Analytics
Route::get('analytics', [AdminAnalyticsController::class, 'index'])->name('api.v1.admin.analytics.index');
});

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
use App\Models\EmailTemplate;
use App\Models\User;
use Database\Seeders\EmailTemplateSeeder;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
});
// ---------------------------------------------------------------------------
// Index
// ---------------------------------------------------------------------------
describe('Email Template Index', function (): void {
it('allows admin to list email templates', function (): void {
$admin = User::factory()->admin()->create();
EmailTemplate::factory()->count(3)->create();
$this->actingAs($admin)
->get($this->adminUrl.'/email-templates')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/EmailTemplates/Index')
->has('templates', 3)
);
});
it('denies customer access to email templates', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/email-templates')
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Edit
// ---------------------------------------------------------------------------
describe('Email Template Edit', function (): void {
it('allows admin to view edit form', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create(['slug' => 'payment-succeeded']);
$this->actingAs($admin)
->get($this->adminUrl.'/email-templates/'.$template->id.'/edit')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/EmailTemplates/Edit')
->has('template')
->where('template.slug', 'payment-succeeded')
);
});
});
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
describe('Email Template Update', function (): void {
it('allows admin to update template subject and body', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => 'New Subject {{customer_name}}',
'body' => 'New body content with {{amount}}',
'is_active' => true,
])
->assertRedirect();
$template->refresh();
expect($template->subject)->toBe('New Subject {{customer_name}}');
expect($template->body)->toBe('New body content with {{amount}}');
});
it('allows admin to toggle active status', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create(['is_active' => true]);
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => $template->subject,
'body' => $template->body,
'is_active' => false,
])
->assertRedirect();
$template->refresh();
expect($template->is_active)->toBeFalse();
});
it('validates subject is required', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => '',
'body' => 'Some body',
'is_active' => true,
])
->assertSessionHasErrors('subject');
});
it('validates body is required', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($admin)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => 'Some subject',
'body' => '',
'is_active' => true,
])
->assertSessionHasErrors('body');
});
it('denies non-admin from updating templates', function (): void {
$customer = User::factory()->customer()->create();
$template = EmailTemplate::factory()->create();
$this->actingAs($customer)
->put($this->adminUrl.'/email-templates/'.$template->id, [
'subject' => 'Hacked',
'body' => 'Hacked body',
'is_active' => true,
])
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Preview
// ---------------------------------------------------------------------------
describe('Email Template Preview', function (): void {
it('allows admin to preview template with sample data', function (): void {
$admin = User::factory()->admin()->create();
$template = EmailTemplate::factory()->create([
'slug' => 'payment-succeeded',
'subject' => 'Payment of {{currency}} {{amount}} Received',
'body' => 'Hello {{customer_name}}, your payment of {{currency}} {{amount}} was received.',
'available_variables' => ['customer_name', 'amount', 'currency', 'invoice_number', 'date'],
]);
$this->actingAs($admin)
->postJson($this->adminUrl.'/email-templates/'.$template->id.'/preview')
->assertOk()
->assertJsonStructure(['subject', 'body'])
->assertJsonFragment(['subject' => 'Payment of USD 49.99 Received']);
});
});
// ---------------------------------------------------------------------------
// Reset to Default
// ---------------------------------------------------------------------------
describe('Email Template Reset', function (): void {
it('allows admin to reset template to default', function (): void {
$admin = User::factory()->admin()->create();
// Seed the default template first
$this->seed(EmailTemplateSeeder::class);
$template = EmailTemplate::query()->where('slug', 'payment-succeeded')->first();
$originalSubject = $template->subject;
// Modify it
$template->update(['subject' => 'Custom Subject', 'body' => 'Custom body']);
$this->actingAs($admin)
->post($this->adminUrl.'/email-templates/'.$template->id.'/reset')
->assertRedirect();
$template->refresh();
expect($template->subject)->toBe($originalSubject);
});
});
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
describe('EmailTemplate Model', function (): void {
it('can get active template by slug', function (): void {
$template = EmailTemplate::factory()->create([
'slug' => 'test-template',
'is_active' => true,
]);
$found = EmailTemplate::getTemplate('test-template');
expect($found)->not->toBeNull();
expect($found->id)->toBe($template->id);
});
it('returns null for inactive template', function (): void {
EmailTemplate::factory()->create([
'slug' => 'inactive-template',
'is_active' => false,
]);
$found = EmailTemplate::getTemplate('inactive-template');
expect($found)->toBeNull();
});
it('can render template with variables', function (): void {
EmailTemplate::factory()->create([
'slug' => 'render-test',
'subject' => 'Hello {{customer_name}}',
'body' => 'Your amount is {{amount}} {{currency}}.',
'available_variables' => ['customer_name', 'amount', 'currency'],
'is_active' => true,
]);
$result = EmailTemplate::render('render-test', [
'customer_name' => 'Jane',
'amount' => '99.99',
'currency' => 'USD',
]);
expect($result)->not->toBeNull();
expect($result['subject'])->toBe('Hello Jane');
expect($result['body'])->toBe('Your amount is 99.99 USD.');
});
it('returns null when rendering non-existent template', function (): void {
$result = EmailTemplate::render('non-existent', ['customer_name' => 'Test']);
expect($result)->toBeNull();
});
});

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Plan;
use App\Models\Service;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->admin = User::factory()->admin()->create();
$this->customer = User::factory()->customer()->create();
$this->plan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
]);
$this->subscription = Subscription::factory()->create([
'user_id' => $this->customer->id,
'type' => 'default',
'stripe_status' => 'active',
'ends_at' => now()->addDays(30),
]);
$this->service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
'provisioned_at' => now()->subDays(10),
]);
});
test('admin can extend service expiry date', function (): void {
$newDate = now()->addDays(60)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
'reason' => 'Customer loyalty extension',
]);
$response->assertSessionHas('success', 'Service expiry date has been extended successfully.');
// Verify subscription ends_at was updated
$this->subscription->refresh();
expect($this->subscription->ends_at->format('Y-m-d'))->toBe($newDate);
});
test('extend expiry creates audit log with old and new dates', function (): void {
$oldEndsAt = $this->subscription->ends_at;
$newDate = now()->addDays(90)->format('Y-m-d');
$this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
'reason' => 'Goodwill extension',
]);
$auditLog = AuditLog::where('action', 'extend_service_expiry')
->where('resource_id', $this->service->id)
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->admin_id)->toBe($this->admin->id);
expect($auditLog->user_id)->toBe($this->customer->id);
expect($auditLog->resource_type)->toBe('service');
expect($auditLog->changes)->toHaveKey('old_ends_at');
expect($auditLog->changes)->toHaveKey('new_ends_at');
expect($auditLog->changes['reason'])->toBe('Goodwill extension');
});
test('extend expiry requires date in the future', function (): void {
$pastDate = now()->subDays(5)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $pastDate,
]);
$response->assertSessionHasErrors(['new_expiry_date']);
});
test('extend expiry requires a date', function (): void {
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => '',
]);
$response->assertSessionHasErrors(['new_expiry_date']);
});
test('extend expiry fails for service without subscription', function (): void {
$serviceNoSub = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => null,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$serviceNoSub->id}/extend-expiry", [
'new_expiry_date' => now()->addDays(30)->format('Y-m-d'),
]);
$response->assertSessionHas('error', 'This service does not have an associated subscription.');
});
test('extend expiry works when subscription has no previous ends_at', function (): void {
$this->subscription->update(['ends_at' => null]);
$newDate = now()->addDays(45)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
]);
$response->assertSessionHas('success', 'Service expiry date has been extended successfully.');
$this->subscription->refresh();
expect($this->subscription->ends_at->format('Y-m-d'))->toBe($newDate);
// Audit log should have null for old_ends_at
$auditLog = AuditLog::where('action', 'extend_service_expiry')->first();
expect($auditLog->changes['old_ends_at'])->toBeNull();
});
test('extend expiry reason is optional', function (): void {
$newDate = now()->addDays(60)->format('Y-m-d');
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => $newDate,
]);
$response->assertSessionHas('success');
$auditLog = AuditLog::where('action', 'extend_service_expiry')->first();
expect($auditLog->changes['reason'])->toBeNull();
});
test('non-admin cannot extend service expiry', function (): void {
$response = $this->actingAs($this->customer)
->post("http://admin.ezscale.dev/services/{$this->service->id}/extend-expiry", [
'new_expiry_date' => now()->addDays(30)->format('Y-m-d'),
]);
$response->assertForbidden();
});
test('service show page includes subscription data', function (): void {
$response = $this->actingAs($this->admin)
->get("http://admin.ezscale.dev/services/{$this->service->id}");
$response->assertOk();
$props = $response->viewData('page')['props'];
$service = $props['service'];
expect($service)->toHaveKey('subscription');
expect($service['subscription'])->not->toBeNull();
expect($service['subscription'])->toHaveKey('ends_at');
expect($service['subscription'])->toHaveKey('current_period_end');
expect($service['subscription'])->toHaveKey('stripe_status');
});

View File

@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
use App\Models\TaxRate;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
});
// ---------------------------------------------------------------------------
// Index
// ---------------------------------------------------------------------------
describe('Tax Rate Index', function (): void {
it('allows admin to list tax rates', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->count(3)->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 3)
->has('countries')
->has('filters')
);
});
it('filters tax rates by country', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->forCountry('US')->create();
TaxRate::factory()->forCountry('DE')->create();
TaxRate::factory()->forCountry('US', 'CA')->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates?country=US')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 2)
);
});
it('filters tax rates by active status', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->count(2)->create(['is_active' => true]);
TaxRate::factory()->inactive()->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates?status=active')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 2)
);
});
it('searches tax rates by name', function (): void {
$admin = User::factory()->admin()->create();
TaxRate::factory()->create(['name' => 'EU VAT Germany']);
TaxRate::factory()->create(['name' => 'US Sales Tax']);
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates?search=VAT')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Index')
->has('taxRates.data', 1)
);
});
it('denies non-admin access to tax rates', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/tax-rates')
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
describe('Tax Rate Create', function (): void {
it('displays the create tax rate page', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates/create')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Create')
);
});
it('stores a new tax rate', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'EU VAT - Germany',
'country_code' => 'DE',
'region_code' => null,
'rate' => 19.00,
'type' => 'inclusive',
'priority' => 0,
'is_active' => true,
])
->assertRedirect();
$this->assertDatabaseHas('tax_rates', [
'name' => 'EU VAT - Germany',
'country_code' => 'DE',
'rate' => '19.00',
'type' => 'inclusive',
'is_active' => true,
]);
});
it('stores a tax rate with region code', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'US Sales Tax - California',
'country_code' => 'US',
'region_code' => 'CA',
'rate' => 7.25,
'type' => 'exclusive',
'priority' => 1,
'is_active' => true,
])
->assertRedirect();
$this->assertDatabaseHas('tax_rates', [
'name' => 'US Sales Tax - California',
'country_code' => 'US',
'region_code' => 'CA',
'rate' => '7.25',
'type' => 'exclusive',
]);
});
it('uppercases country code on store', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Test Tax',
'country_code' => 'de',
'rate' => 10.00,
'type' => 'exclusive',
'priority' => 0,
'is_active' => true,
])
->assertRedirect();
$this->assertDatabaseHas('tax_rates', [
'country_code' => 'DE',
]);
});
it('validates required fields when storing', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [])
->assertSessionHasErrors(['name', 'country_code', 'rate', 'type']);
});
it('validates rate range', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Invalid Rate',
'country_code' => 'US',
'rate' => 150,
'type' => 'exclusive',
])
->assertSessionHasErrors(['rate']);
});
it('validates country code format', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Bad Country',
'country_code' => 'USA',
'rate' => 10,
'type' => 'exclusive',
])
->assertSessionHasErrors(['country_code']);
});
it('validates type enum', function (): void {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates', [
'name' => 'Bad Type',
'country_code' => 'US',
'rate' => 10,
'type' => 'invalid',
])
->assertSessionHasErrors(['type']);
});
});
// ---------------------------------------------------------------------------
// Edit / Update
// ---------------------------------------------------------------------------
describe('Tax Rate Edit', function (): void {
it('displays the edit tax rate page', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create();
$this->actingAs($admin)
->get($this->adminUrl.'/tax-rates/'.$taxRate->id.'/edit')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/TaxRates/Edit')
->has('taxRate')
);
});
it('updates an existing tax rate', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create([
'name' => 'Old Name',
'rate' => '10.00',
]);
$this->actingAs($admin)
->put($this->adminUrl.'/tax-rates/'.$taxRate->id, [
'name' => 'New Name',
'country_code' => $taxRate->country_code,
'region_code' => $taxRate->region_code,
'rate' => 15.00,
'type' => $taxRate->type,
'priority' => 0,
'is_active' => true,
])
->assertRedirect();
$fresh = $taxRate->fresh();
expect($fresh->name)->toBe('New Name');
expect((float) $fresh->rate)->toBe(15.0);
});
});
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
describe('Tax Rate Delete', function (): void {
it('deletes a tax rate', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create();
$this->actingAs($admin)
->delete($this->adminUrl.'/tax-rates/'.$taxRate->id)
->assertRedirect();
$this->assertDatabaseMissing('tax_rates', ['id' => $taxRate->id]);
});
});
// ---------------------------------------------------------------------------
// Toggle Active
// ---------------------------------------------------------------------------
describe('Tax Rate Toggle Active', function (): void {
it('toggles a tax rate from active to inactive', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->create(['is_active' => true]);
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates/'.$taxRate->id.'/toggle-active')
->assertRedirect();
expect($taxRate->fresh()->is_active)->toBeFalse();
});
it('toggles a tax rate from inactive to active', function (): void {
$admin = User::factory()->admin()->create();
$taxRate = TaxRate::factory()->inactive()->create();
$this->actingAs($admin)
->post($this->adminUrl.'/tax-rates/'.$taxRate->id.'/toggle-active')
->assertRedirect();
expect($taxRate->fresh()->is_active)->toBeTrue();
});
});
// ---------------------------------------------------------------------------
// Model: getApplicableRates
// ---------------------------------------------------------------------------
describe('TaxRate::getApplicableRates', function (): void {
it('returns matching rates for a country', function (): void {
TaxRate::factory()->forCountry('US')->create(['rate' => '8.00', 'priority' => 0]);
TaxRate::factory()->forCountry('DE')->create(['rate' => '19.00']);
$rates = TaxRate::getApplicableRates('US');
expect($rates)->toHaveCount(1);
expect((float) $rates->first()->rate)->toBe(8.0);
});
it('returns country-level and region-level rates when region is specified', function (): void {
TaxRate::factory()->forCountry('US')->create(['rate' => '5.00', 'priority' => 0]);
TaxRate::factory()->forCountry('US', 'CA')->create(['rate' => '7.25', 'priority' => 1]);
TaxRate::factory()->forCountry('US', 'NY')->create(['rate' => '8.00', 'priority' => 1]);
$rates = TaxRate::getApplicableRates('US', 'CA');
expect($rates)->toHaveCount(2);
expect((float) $rates->first()->rate)->toBe(5.0);
expect((float) $rates->last()->rate)->toBe(7.25);
});
it('excludes inactive rates', function (): void {
TaxRate::factory()->forCountry('GB')->create(['is_active' => true]);
TaxRate::factory()->forCountry('GB')->inactive()->create(['type' => 'inclusive']);
$rates = TaxRate::getApplicableRates('GB');
expect($rates)->toHaveCount(1);
});
it('orders rates by priority', function (): void {
TaxRate::factory()->forCountry('FR')->create([
'name' => 'Low Priority',
'rate' => '5.00',
'priority' => 2,
]);
TaxRate::factory()->forCountry('FR')->create([
'name' => 'High Priority',
'rate' => '20.00',
'priority' => 0,
'type' => 'inclusive',
]);
$rates = TaxRate::getApplicableRates('FR');
expect($rates)->toHaveCount(2);
expect($rates->first()->name)->toBe('High Priority');
expect($rates->last()->name)->toBe('Low Priority');
});
it('returns empty collection when no rates match', function (): void {
TaxRate::factory()->forCountry('US')->create();
$rates = TaxRate::getApplicableRates('ZZ');
expect($rates)->toBeEmpty();
});
});

View File

@@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\Service;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Passport\Passport;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
beforeEach(function (): void {
Passport::$validateKeyPermissions = false;
$this->seed(RoleAndPermissionSeeder::class);
// Create roles and permissions for the 'api' guard so Passport auth works with Spatie
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$apiAdmin = Role::findOrCreate('admin', 'api');
Role::findOrCreate('customer', 'api');
$permissions = Permission::where('guard_name', 'web')->pluck('name');
foreach ($permissions as $permissionName) {
$perm = Permission::findOrCreate($permissionName, 'api');
$apiAdmin->givePermissionTo($perm);
}
});
/**
* Create an admin user with the 'api' guard role and authenticate via Passport.
*/
function createApiAdmin(): User
{
$admin = User::factory()->create();
$admin->assignRole(Role::findByName('admin', 'api'));
Passport::actingAs($admin);
return $admin;
}
/**
* Create a customer user with the 'api' guard role.
*/
function createApiCustomer(array $attributes = []): User
{
$customer = User::factory()->create($attributes);
$customer->assignRole(Role::findByName('customer', 'api'));
return $customer;
}
// ---------------------------------------------------------------------------
// Authentication & Authorization
// ---------------------------------------------------------------------------
describe('Authentication & Authorization', function (): void {
it('returns 401 for unauthenticated requests', function (): void {
$this->getJson('/api/v1/admin/customers')
->assertUnauthorized();
});
it('returns 403 for non-admin users accessing admin endpoints', function (): void {
$customer = createApiCustomer();
Passport::actingAs($customer);
$this->getJson('/api/v1/admin/customers')
->assertForbidden();
});
it('returns 403 for non-admin accessing analytics', function (): void {
$customer = createApiCustomer();
Passport::actingAs($customer);
$this->getJson('/api/v1/admin/analytics')
->assertForbidden();
});
it('returns 403 for non-admin trying to suspend a service', function (): void {
$customer = createApiCustomer();
Passport::actingAs($customer);
$service = Service::factory()->create();
$this->postJson("/api/v1/admin/services/{$service->id}/suspend")
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Customer Management
// ---------------------------------------------------------------------------
describe('Customer Management', function (): void {
it('allows admin to list customers', function (): void {
$admin = createApiAdmin();
// Create customers with the 'web' guard role (used by User::role('customer') query)
User::factory()->customer()->count(5)->create();
$response = $this->getJson('/api/v1/admin/customers');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email', 'status', 'services_count', 'subscriptions_count', 'created_at'],
],
'links',
'meta',
]);
expect($response->json('meta.total'))->toBe(5);
});
it('allows admin to search customers by name', function (): void {
createApiAdmin();
User::factory()->customer()->create(['name' => 'John Searchable']);
User::factory()->customer()->create(['name' => 'Jane Other']);
$response = $this->getJson('/api/v1/admin/customers?search=Searchable');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.name'))->toBe('John Searchable');
});
it('allows admin to search customers by email', function (): void {
createApiAdmin();
User::factory()->customer()->create(['email' => 'unique-test@example.com']);
User::factory()->customer()->create(['email' => 'other@example.com']);
$response = $this->getJson('/api/v1/admin/customers?search=unique-test');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.email'))->toBe('unique-test@example.com');
});
it('allows admin to filter customers by status', function (): void {
createApiAdmin();
User::factory()->customer()->count(3)->create(['status' => 'active']);
User::factory()->customer()->suspended()->count(2)->create();
$response = $this->getJson('/api/v1/admin/customers?status=suspended');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
it('allows admin to view customer details', function (): void {
createApiAdmin();
$customer = User::factory()->customer()->create();
Service::factory()->count(3)->create(['user_id' => $customer->id]);
$response = $this->getJson("/api/v1/admin/customers/{$customer->id}");
$response->assertOk()
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'status', 'services_count', 'subscriptions_count', 'created_at'],
]);
expect($response->json('data.id'))->toBe($customer->id);
expect($response->json('data.services_count'))->toBe(3);
});
it('paginates customers with custom per_page', function (): void {
createApiAdmin();
User::factory()->customer()->count(10)->create();
$response = $this->getJson('/api/v1/admin/customers?per_page=5');
$response->assertOk();
expect($response->json('meta.per_page'))->toBe(5);
expect(count($response->json('data')))->toBe(5);
});
it('creates an audit log when listing customers', function (): void {
$admin = createApiAdmin();
$this->getJson('/api/v1/admin/customers')
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_list_customers',
'resource_type' => 'user',
]);
});
it('creates an audit log when viewing a customer', function (): void {
$admin = createApiAdmin();
$customer = User::factory()->customer()->create();
$this->getJson("/api/v1/admin/customers/{$customer->id}")
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_view_customer',
'resource_type' => 'user',
'resource_id' => $customer->id,
]);
});
});
// ---------------------------------------------------------------------------
// Service Management
// ---------------------------------------------------------------------------
describe('Service Management', function (): void {
it('allows admin to list all services', function (): void {
createApiAdmin();
Service::factory()->count(5)->create();
$response = $this->getJson('/api/v1/admin/services');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'user', 'service_type', 'platform', 'status', 'hostname', 'ipv4_address', 'plan', 'created_at'],
],
'links',
'meta',
]);
expect($response->json('meta.total'))->toBe(5);
});
it('allows admin to filter services by status', function (): void {
createApiAdmin();
Service::factory()->count(3)->create(['status' => 'active']);
Service::factory()->suspended()->count(2)->create();
$response = $this->getJson('/api/v1/admin/services?status=suspended');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
it('allows admin to filter services by service_type', function (): void {
createApiAdmin();
Service::factory()->count(2)->create(['service_type' => 'vps', 'platform' => 'virtfusion']);
Service::factory()->count(3)->create(['service_type' => 'dedicated', 'platform' => 'synergycp']);
$response = $this->getJson('/api/v1/admin/services?service_type=vps');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
it('allows admin to search services by customer name', function (): void {
createApiAdmin();
$customer = User::factory()->customer()->create(['name' => 'SearchTarget User']);
Service::factory()->create(['user_id' => $customer->id]);
Service::factory()->count(3)->create();
$response = $this->getJson('/api/v1/admin/services?search=SearchTarget');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
});
it('allows admin to view service details', function (): void {
createApiAdmin();
$service = Service::factory()->create();
$response = $this->getJson("/api/v1/admin/services/{$service->id}");
$response->assertOk()
->assertJsonStructure([
'data' => ['id', 'user', 'service_type', 'platform', 'status', 'hostname', 'ipv4_address', 'plan', 'created_at'],
]);
expect($response->json('data.id'))->toBe($service->id);
});
it('allows admin to suspend a service', function (): void {
$admin = createApiAdmin();
$service = Service::factory()->create(['status' => 'active']);
$response = $this->postJson("/api/v1/admin/services/{$service->id}/suspend");
$response->assertOk()
->assertJson(['message' => 'Service has been suspended.']);
$service->refresh();
expect($service->status)->toBe('suspended');
expect($service->suspended_at)->not->toBeNull();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_suspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
]);
});
it('returns 422 when suspending an already suspended service', function (): void {
createApiAdmin();
$service = Service::factory()->suspended()->create();
$response = $this->postJson("/api/v1/admin/services/{$service->id}/suspend");
$response->assertUnprocessable()
->assertJson(['message' => 'Service is already suspended.']);
});
it('allows admin to unsuspend a service', function (): void {
$admin = createApiAdmin();
$service = Service::factory()->suspended()->create();
$response = $this->postJson("/api/v1/admin/services/{$service->id}/unsuspend");
$response->assertOk()
->assertJson(['message' => 'Service has been unsuspended.']);
$service->refresh();
expect($service->status)->toBe('active');
expect($service->suspended_at)->toBeNull();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_unsuspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
]);
});
it('returns 422 when unsuspending a non-suspended service', function (): void {
createApiAdmin();
$service = Service::factory()->create(['status' => 'active']);
$response = $this->postJson("/api/v1/admin/services/{$service->id}/unsuspend");
$response->assertUnprocessable()
->assertJson(['message' => 'Service is not suspended.']);
});
it('creates an audit log when listing services', function (): void {
$admin = createApiAdmin();
$this->getJson('/api/v1/admin/services')
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_list_services',
'resource_type' => 'service',
]);
});
});
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
describe('Analytics', function (): void {
it('allows admin to view analytics', function (): void {
createApiAdmin();
// Create some data for analytics — customers must have 'web' guard role
// for User::role('customer') queries in AnalyticsController
User::factory()->customer()->count(3)->create();
Service::factory()->count(2)->create(['status' => 'active']);
Invoice::factory()->count(2)->create(['status' => 'paid', 'total' => 50.00, 'paid_at' => now()]);
Invoice::factory()->create(['status' => 'pending', 'total' => 25.00]);
Invoice::factory()->create(['status' => 'overdue', 'total' => 30.00]);
$response = $this->getJson('/api/v1/admin/analytics');
$response->assertOk()
->assertJsonStructure([
'data' => [
'total_customers',
'new_customers_this_month',
'mrr',
'arr',
'total_revenue',
'revenue_this_month',
'active_services',
'pending_invoices' => ['count', 'amount'],
'overdue_invoices' => ['count', 'amount'],
'revenue_by_month',
'customer_growth',
'churn_data',
'revenue_by_service_type',
],
]);
expect($response->json('data.total_customers'))->toBe(3);
expect($response->json('data.active_services'))->toBe(2);
expect((float) $response->json('data.total_revenue'))->toBe(100.0);
expect($response->json('data.pending_invoices.count'))->toBe(1);
expect((float) $response->json('data.pending_invoices.amount'))->toBe(25.0);
expect($response->json('data.overdue_invoices.count'))->toBe(1);
expect((float) $response->json('data.overdue_invoices.amount'))->toBe(30.0);
});
it('creates an audit log when viewing analytics', function (): void {
$admin = createApiAdmin();
$this->getJson('/api/v1/admin/analytics')
->assertOk();
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $admin->id,
'action' => 'api_view_analytics',
'resource_type' => 'analytics',
]);
});
it('returns churn data with correct structure', function (): void {
createApiAdmin();
$response = $this->getJson('/api/v1/admin/analytics');
$response->assertOk();
$churnData = $response->json('data.churn_data');
expect($churnData)->toBeArray();
expect(count($churnData))->toBe(6);
foreach ($churnData as $month) {
expect($month)->toHaveKeys(['month', 'rate', 'cancelled']);
}
});
});

View File

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\Service;
use App\Models\SupportTicket;
use App\Models\TicketReply;
use App\Models\User;
use App\Services\Provisioning\VirtFusionService;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Passport\Passport;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
Passport::$validateKeyPermissions = false;
});
// ---------------------------------------------------------------------------
// Services
// ---------------------------------------------------------------------------
describe('Services API', function (): void {
it('lists customer services paginated', function (): void {
$customer = User::factory()->customer()->create();
Service::factory()->count(3)->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/services')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'service_type', 'platform', 'status', 'hostname', 'ipv4_address', 'created_at'],
],
'links',
'meta',
])
->assertJsonCount(3, 'data');
});
it('shows a single service with plan details', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/services/{$service->id}")
->assertOk()
->assertJsonStructure([
'data' => ['id', 'service_type', 'platform', 'status', 'hostname', 'plan', 'created_at'],
])
->assertJsonPath('data.id', $service->id);
});
it('returns 403 when accessing another user service', function (): void {
$customer = User::factory()->customer()->create();
$otherCustomer = User::factory()->customer()->create();
$service = Service::factory()->create(['user_id' => $otherCustomer->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/services/{$service->id}")
->assertForbidden();
});
it('reboots a VPS service', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->create([
'user_id' => $customer->id,
'service_type' => 'vps',
'platform' => 'virtfusion',
'status' => 'active',
]);
$mock = Mockery::mock(VirtFusionService::class);
$mock->shouldReceive('restart')->once()->andReturn(true);
$this->app->instance(VirtFusionService::class, $mock);
Passport::actingAs($customer);
$this->postJson("/api/v1/services/{$service->id}/reboot")
->assertOk()
->assertJsonPath('message', 'VPS reboot initiated successfully.');
});
it('rejects reboot for non-VPS services', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->create([
'user_id' => $customer->id,
'service_type' => 'dedicated',
'platform' => 'synergycp',
'status' => 'active',
]);
Passport::actingAs($customer);
$this->postJson("/api/v1/services/{$service->id}/reboot")
->assertUnprocessable()
->assertJsonPath('message', 'Reboot is only available for VPS services.');
});
it('rejects reboot for inactive services', function (): void {
$customer = User::factory()->customer()->create();
$service = Service::factory()->suspended()->create([
'user_id' => $customer->id,
'service_type' => 'vps',
'platform' => 'virtfusion',
]);
Passport::actingAs($customer);
$this->postJson("/api/v1/services/{$service->id}/reboot")
->assertUnprocessable()
->assertJsonPath('message', 'Service must be active to reboot.');
});
});
// ---------------------------------------------------------------------------
// Invoices
// ---------------------------------------------------------------------------
describe('Invoices API', function (): void {
it('lists customer invoices paginated', function (): void {
$customer = User::factory()->customer()->create();
Invoice::factory()->count(3)->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/invoices')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'number', 'total', 'tax', 'currency', 'status', 'due_date', 'created_at'],
],
'links',
'meta',
])
->assertJsonCount(3, 'data');
});
it('filters invoices by status', function (): void {
$customer = User::factory()->customer()->create();
Invoice::factory()->count(2)->create(['user_id' => $customer->id, 'status' => 'paid']);
Invoice::factory()->count(3)->create(['user_id' => $customer->id, 'status' => 'overdue']);
Passport::actingAs($customer);
$this->getJson('/api/v1/invoices?status=paid')
->assertOk()
->assertJsonCount(2, 'data');
});
it('does not show invoices from other users', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
Invoice::factory()->count(2)->create(['user_id' => $customer->id]);
Invoice::factory()->count(3)->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/invoices')
->assertOk()
->assertJsonCount(2, 'data');
});
it('downloads invoice PDF', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/invoices/{$invoice->id}/pdf")
->assertOk()
->assertHeader('content-type', 'application/pdf');
});
it('returns 403 when downloading another user invoice PDF', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
$invoice = Invoice::factory()->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/invoices/{$invoice->id}/pdf")
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Subscriptions
// ---------------------------------------------------------------------------
describe('Subscriptions API', function (): void {
it('lists customer subscriptions', function (): void {
$customer = User::factory()->customer()->create();
// Create subscriptions via Cashier's table directly
$customer->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test_'.uniqid(),
'stripe_status' => 'active',
'stripe_price' => 'price_test',
]);
Passport::actingAs($customer);
$this->getJson('/api/v1/subscriptions')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'stripe_status', 'created_at'],
],
])
->assertJsonCount(1, 'data');
});
});
// ---------------------------------------------------------------------------
// Tickets
// ---------------------------------------------------------------------------
describe('Tickets API', function (): void {
it('lists customer tickets paginated', function (): void {
$customer = User::factory()->customer()->create();
SupportTicket::factory()->count(3)->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->getJson('/api/v1/tickets')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'reference', 'subject', 'status', 'priority', 'department', 'created_at'],
],
'links',
'meta',
])
->assertJsonCount(3, 'data');
});
it('creates a new ticket', function (): void {
$customer = User::factory()->customer()->create();
// Create an admin for notification
User::factory()->admin()->create();
Passport::actingAs($customer);
$this->postJson('/api/v1/tickets', [
'subject' => 'Test Ticket Subject',
'message' => 'This is a detailed test message for the ticket body.',
'priority' => 'medium',
'department' => 'technical',
])
->assertCreated()
->assertJsonStructure([
'data' => ['id', 'reference', 'subject', 'status', 'priority', 'department', 'replies'],
])
->assertJsonPath('data.subject', 'Test Ticket Subject')
->assertJsonPath('data.status', 'open');
$this->assertDatabaseHas('support_tickets', [
'user_id' => $customer->id,
'subject' => 'Test Ticket Subject',
'priority' => 'medium',
'department' => 'technical',
]);
});
it('validates ticket creation data', function (): void {
$customer = User::factory()->customer()->create();
Passport::actingAs($customer);
$this->postJson('/api/v1/tickets', [])
->assertUnprocessable()
->assertJsonValidationErrors(['subject', 'message', 'priority', 'department']);
});
it('shows a ticket with replies', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
TicketReply::factory()->count(2)->create([
'ticket_id' => $ticket->id,
'user_id' => $customer->id,
]);
Passport::actingAs($customer);
$this->getJson("/api/v1/tickets/{$ticket->id}")
->assertOk()
->assertJsonStructure([
'data' => [
'id', 'reference', 'subject', 'status',
'replies' => [
'*' => ['id', 'body', 'is_staff_reply', 'user', 'created_at'],
],
],
])
->assertJsonCount(2, 'data.replies');
});
it('returns 403 when viewing another user ticket', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->getJson("/api/v1/tickets/{$ticket->id}")
->assertForbidden();
});
it('replies to an open ticket', function (): void {
$customer = User::factory()->customer()->create();
User::factory()->admin()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->postJson("/api/v1/tickets/{$ticket->id}/reply", [
'body' => 'This is my reply to the ticket.',
])
->assertOk()
->assertJsonStructure([
'data' => ['id', 'replies'],
]);
$this->assertDatabaseHas('ticket_replies', [
'ticket_id' => $ticket->id,
'user_id' => $customer->id,
'body' => 'This is my reply to the ticket.',
]);
});
it('cannot reply to a closed ticket', function (): void {
$customer = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
Passport::actingAs($customer);
$this->postJson("/api/v1/tickets/{$ticket->id}/reply", [
'body' => 'Trying to reply to closed ticket.',
])
->assertUnprocessable()
->assertJsonPath('message', 'Cannot reply to a closed ticket.');
});
it('cannot reply to another user ticket', function (): void {
$customer = User::factory()->customer()->create();
$other = User::factory()->customer()->create();
$ticket = SupportTicket::factory()->open()->create(['user_id' => $other->id]);
Passport::actingAs($customer);
$this->postJson("/api/v1/tickets/{$ticket->id}/reply", [
'body' => 'Trying to reply to someone else ticket.',
])
->assertForbidden();
});
});
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
describe('API Authentication', function (): void {
it('returns 401 for unauthenticated requests to services', function (): void {
$this->getJson('/api/v1/services')
->assertUnauthorized();
});
it('returns 401 for unauthenticated requests to invoices', function (): void {
$this->getJson('/api/v1/invoices')
->assertUnauthorized();
});
it('returns 401 for unauthenticated requests to subscriptions', function (): void {
$this->getJson('/api/v1/subscriptions')
->assertUnauthorized();
});
it('returns 401 for unauthenticated requests to tickets', function (): void {
$this->getJson('/api/v1/tickets')
->assertUnauthorized();
});
});