From df2349b197445f69e8890b1373bdf54777ead4bf Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 20 Jun 2026 16:47:45 +0200 Subject: [PATCH 01/29] Bump version to v0.9.7 BETA --- package.json | 2 +- public/js/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d8631e5..7b80abf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.0.0", + "version": "0.9.7", "private": true, "type": "module", "scripts": { diff --git a/public/js/app.js b/public/js/app.js index d11f187..315958a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -398,7 +398,7 @@ async function renderDashboard() {
-
v0.9.6 BETA
+
v0.9.7 BETA
From 3499996420417096250efc46a782398d78cbd6d2 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Sat, 20 Jun 2026 18:16:21 +0200 Subject: [PATCH 02/29] Replace Cloudflare Turnstile with self-hosted Cap CAPTCHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add config/cap.js with Cap server-side verification - Remove config/turnstile.js - Update client-side: replace Turnstile widget with cap-widget web component - Update routes: rename cfTurnstile → capToken, verifyTurnstile → verifyCap - Update .env.example with Cap configuration --- .env.example | 5 +++-- config/cap.js | 35 +++++++++++++++++++++++++++++++++ config/turnstile.js | 30 ---------------------------- public/index.html | 2 +- public/js/app.js | 48 ++++++++++----------------------------------- routes/auth.js | 12 ++++++------ routes/servers.js | 6 +++--- 7 files changed, 58 insertions(+), 80 deletions(-) create mode 100644 config/cap.js delete mode 100644 config/turnstile.js diff --git a/.env.example b/.env.example index 04b120c..0037729 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,9 @@ PTERO_API_KEY=change_me # Encryption key for sensitive data — Generate with: openssl rand -hex 32 ENCRYPTION_KEY=change_me_to_a_random_hex_string -# Cloudflare Turnstile -TURNSTILE_SECRET=change_me +# Cap CAPTCHA (self-hosted alternative to Turnstile) +CAP_ENDPOINT=https://cap.zero-host.org/f6c8171b08/ +CAP_SECRET=change_me # Session & Cookie COOKIE_SECRET=change_me_to_a_random_string diff --git a/config/cap.js b/config/cap.js new file mode 100644 index 0000000..cb79d0f --- /dev/null +++ b/config/cap.js @@ -0,0 +1,35 @@ +const CAP_SECRET = process.env.CAP_SECRET; +const CAP_ENDPOINT = process.env.CAP_ENDPOINT; + +if (!CAP_SECRET) { + console.error('Missing CAP_SECRET environment variable'); +} + +if (!CAP_ENDPOINT) { + console.error('Missing CAP_ENDPOINT environment variable'); +} + +async function fetchWithTimeout(url, options = {}, timeout = 10000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +export async function verifyCap(token) { + if (!token) return false; + try { + const res = await fetchWithTimeout(`${CAP_ENDPOINT}siteverify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret: CAP_SECRET, response: token }), + }); + const data = await res.json(); + return data.success === true; + } catch { + return false; + } +} diff --git a/config/turnstile.js b/config/turnstile.js deleted file mode 100644 index e470372..0000000 --- a/config/turnstile.js +++ /dev/null @@ -1,30 +0,0 @@ -const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET; - -if (!TURNSTILE_SECRET) { - console.error('Missing TURNSTILE_SECRET environment variable'); -} - -async function fetchWithTimeout(url, options = {}, timeout = 10000) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} - -export async function verifyTurnstile(token) { - if (!token) return false; - try { - const res = await fetchWithTimeout('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token }), - }); - const data = await res.json(); - return data.success === true; - } catch { - return false; - } -} diff --git a/public/index.html b/public/index.html index 7c1c6d6..689ea10 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ - + diff --git a/public/js/app.js b/public/js/app.js index 315958a..4f80f51 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -24,7 +24,7 @@ function renderCookieBanner() { banner.innerHTML = ` @@ -150,29 +150,7 @@ function hideError(form) { if (errorEl) errorEl.classList.remove('show'); } -const turnstileWidgets = {}; - -function initTurnstile(selector) { - const el = typeof selector === 'string' ? document.querySelector(selector) : selector; - if (!el) return; - const tryRender = () => { - if (typeof turnstile !== 'undefined') { - if (el.querySelector('iframe')) return; - const widgetId = turnstile.render(el, { sitekey: '0x4AAAAAADjivxHTaDDdYR8W', theme: 'dark' }); - turnstileWidgets[selector] = widgetId; - } else { - setTimeout(tryRender, 200); - } - }; - tryRender(); -} -function resetTurnstile(selector) { - const id = turnstileWidgets[selector]; - if (typeof turnstile !== 'undefined' && id !== undefined) { - turnstile.reset(id); - } -} // ===== AUTH PAGES ===== function renderLoginPage() { @@ -196,7 +174,7 @@ function renderLoginPage() { -
+ @@ -210,7 +188,6 @@ function renderLoginPage() { `; $('#login-form').addEventListener('submit', handleLogin); - initTurnstile('#login-turnstile'); $('#go-register').addEventListener('click', (e) => { e.preventDefault(); renderRegisterPage(); @@ -248,7 +225,7 @@ function renderRegisterPage() { I agree to the privacy policy and consent to the processing of my personal data (email, username, IP address) for account management purposes. * -
+ @@ -262,7 +239,6 @@ function renderRegisterPage() { `; $('#register-form').addEventListener('submit', handleRegister); - initTurnstile('#register-turnstile'); $('#go-login').addEventListener('click', (e) => { e.preventDefault(); renderLoginPage(); @@ -277,13 +253,13 @@ async function handleLogin(e) { btn.innerHTML = ' Signing in...'; try { - const turnstileToken = document.querySelector('#login-turnstile')?.querySelector('[name="cf-turnstile-response"]')?.value || ''; + const capToken = document.querySelector('[name="cap-token"]')?.value || ''; const data = await api('/auth/login', { method: 'POST', body: JSON.stringify({ email: $('#login-email').value, password: $('#login-password').value, - cfTurnstile: turnstileToken, + capToken, }), }); state.token = data.token; @@ -293,7 +269,6 @@ async function handleLogin(e) { renderDashboard(); } catch (err) { showError(e.target, err.message); - resetTurnstile('#login-turnstile'); } finally { btn.disabled = false; btn.innerHTML = 'Sign In'; @@ -315,14 +290,14 @@ async function handleRegister(e) { btn.innerHTML = ' Creating...'; try { - const turnstileToken = document.querySelector('#register-turnstile')?.querySelector('[name="cf-turnstile-response"]')?.value || ''; + const capToken = document.querySelector('[name="cap-token"]')?.value || ''; const data = await api('/auth/register', { method: 'POST', body: JSON.stringify({ email: $('#reg-email').value, username: $('#reg-username').value, password: $('#reg-password').value, - cfTurnstile: turnstileToken, + capToken, rgpdConsent: true, }), }); @@ -333,7 +308,6 @@ async function handleRegister(e) { renderDashboard(); } catch (err) { showError(e.target, err.message); - resetTurnstile('#register-turnstile'); } finally { btn.disabled = false; btn.innerHTML = 'Create Account'; @@ -763,7 +737,7 @@ async function renderCreateServer() { 3 GB Disk -
+ @@ -225,7 +225,7 @@ function renderRegisterPage() { I agree to the privacy policy and consent to the processing of my personal data (email, username, IP address) for account management purposes. * - + @@ -738,7 +738,7 @@ async function renderCreateServer() {
- +
@@ -228,7 +254,6 @@ function renderRegisterPage() { I agree to the privacy policy and consent to the processing of my personal data (email, username, IP address) for account management purposes. * - @@ -256,7 +281,7 @@ async function handleLogin(e) { btn.innerHTML = ' Signing in...'; try { - const capToken = document.querySelector('[name="cap-token"]')?.value || ''; + const capToken = await showCapModal(); const data = await api('/auth/login', { method: 'POST', body: JSON.stringify({ @@ -293,7 +318,7 @@ async function handleRegister(e) { btn.innerHTML = ' Creating...'; try { - const capToken = document.querySelector('[name="cap-token"]')?.value || ''; + const capToken = await showCapModal(); const data = await api('/auth/register', { method: 'POST', body: JSON.stringify({ From 927063f2a3a46843f88f4f191234cec21c113a98 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 03:09:23 +0200 Subject: [PATCH 17/29] Fix cap-widget not rendering in modal: set explicit width, use createElement instead of innerHTML --- public/css/style.css | 10 ++++++++-- public/js/app.js | 45 ++++++++++++++++++++++++++++---------------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index aac0216..f0f148c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -9,7 +9,7 @@ --bg-secondary: #1c1917; --bg-card: #1c1917; --bg-card-hover: #292524; - --bg-sidebar: #000; + --bg-sidebar: #0d0d0d; --border: rgba(238, 129, 50, 0.15); --border-hover: rgba(238, 129, 50, 0.3); --accent-1: #ee8132; @@ -1413,11 +1413,17 @@ tbody tr:hover { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; - padding: 32px; + padding: 24px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); animation: capScaleIn 0.2s ease; } +.cap-modal cap-widget { + display: block !important; + width: 300px !important; + min-width: unset !important; +} + .cap-modal-overlay.cap-modal-fadeout .cap-modal { animation: capScaleOut 0.3s ease forwards; } diff --git a/public/js/app.js b/public/js/app.js index 6170649..0e87ff3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -154,28 +154,41 @@ function hideError(form) { function showCapModal() { return new Promise((resolve) => { + if (!customElements.get('cap-widget')) { + resolve(''); + return; + } + const capApiEndpoint = 'https://cap.zero-host.org/f6c8171b08/'; const overlay = document.createElement('div'); overlay.className = 'cap-modal-overlay'; - overlay.innerHTML = ` -
- -
- `; + + const modal = document.createElement('div'); + modal.className = 'cap-modal'; + + const widget = document.createElement('cap-widget'); + widget.setAttribute('data-cap-api-endpoint', capApiEndpoint); + widget.setAttribute('theme', 'dark'); + + modal.appendChild(widget); + overlay.appendChild(modal); document.body.appendChild(overlay); - const check = setInterval(() => { - const input = overlay.querySelector('[name="cap-token"]'); - if (input && input.value) { - clearInterval(check); - overlay.classList.add('cap-modal-fadeout'); - setTimeout(() => { - overlay.remove(); - resolve(input.value); - }, 300); - } - }, 200); + setTimeout(() => { + const check = setInterval(() => { + const hiddenInput = widget.querySelector('[name="cap-token"]'); + const token = widget.token || (hiddenInput && hiddenInput.value) || ''; + if (token) { + clearInterval(check); + overlay.classList.add('cap-modal-fadeout'); + setTimeout(() => { + overlay.remove(); + resolve(token); + }, 300); + } + }, 200); + }, 100); }); } From c24b0c3e8467e54a58bb9fde11ed29c4e28917f7 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:20:34 +0200 Subject: [PATCH 18/29] Replace Pterodactyl with Pyrodactyl in sidebar labels --- public/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 0e87ff3..24fd05c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -385,7 +385,7 @@ async function renderDashboard() { - Open Pterodactyl + Open Pyrodactyl @@ -726,7 +726,7 @@ async function renderServers() {
Manage - Open Pterodactyl + Open Pyrodactyl ${canRenew ? html` ` : ''} From 782b779b6512664c61bc2deddb23c7d02c01b624 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:25:44 +0200 Subject: [PATCH 19/29] Add server power state detection, resource bars, activity logging, search/filter, and live resources tab --- config/migrate.js | 14 +- public/css/style.css | 333 +++++++++++++++++++++++++++++++ public/js/app.js | 460 +++++++++++++++++++++++++++++++++++++++---- routes/auth.js | 8 + routes/servers.js | 90 ++++++++- server.js | 24 +++ services/activity.js | 20 ++ 7 files changed, 908 insertions(+), 41 deletions(-) create mode 100644 services/activity.js diff --git a/config/migrate.js b/config/migrate.js index 08fd6ef..6dac236 100644 --- a/config/migrate.js +++ b/config/migrate.js @@ -12,6 +12,7 @@ const tables = { { name: 'first_name', def: 'VARCHAR(255)' }, { name: 'last_name', def: 'VARCHAR(255)' }, { name: 'password_set', def: 'TINYINT(1) NOT NULL DEFAULT 0' }, + { name: 'ptero_client_api_key', def: 'VARCHAR(255) DEFAULT NULL' }, { name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, ], }, @@ -33,6 +34,16 @@ const tables = { { name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, ], }, + activity_log: { + columns: [ + { name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' }, + { name: 'user_id', def: 'INT NOT NULL' }, + { name: 'action', def: 'VARCHAR(50) NOT NULL' }, + { name: 'details', def: 'VARCHAR(255) DEFAULT \'\'' }, + { name: 'server_id', def: 'INT DEFAULT NULL' }, + { name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, + ], + }, }; @@ -51,7 +62,8 @@ const constraints = [ { table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_ip (ip_address)', name: 'idx_ip' }, { table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_user (user_id)', name: 'idx_user' }, { table: 'user_ips', sql: 'ALTER TABLE user_ips ADD CONSTRAINT fk_user_ips_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_user_ips_user' }, - + { table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_user (user_id)', name: 'idx_activity_user' }, + { table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_created (created_at)', name: 'idx_activity_created' }, ]; export async function migrate() { diff --git a/public/css/style.css b/public/css/style.css index f0f148c..4459766 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -731,6 +731,31 @@ cap-widget { letter-spacing: 0.05em; } +.power-state-dot { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.7rem; + font-weight: 500; + margin-left: 6px; +} +.power-state-dot::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.power-state-dot.running::before { background: #10b981; } +.power-state-dot.offline::before { background: #6b7280; } +.power-state-dot.starting::before { background: #f59e0b; animation: pulse-dot 1s ease-in-out infinite; } +.power-state-dot.stopping::before { background: #f59e0b; animation: pulse-dot 1s ease-in-out infinite; } + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + .status-active { background: rgba(16, 185, 129, 0.12); color: var(--accent-green); @@ -1448,4 +1473,312 @@ tbody tr:hover { to { transform: scale(0.9); opacity: 0; } } +/* ===== ACTIVITY TIMELINE ===== */ +.activity-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.activity-item { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); + position: relative; +} + +.activity-item:last-child { + border-bottom: none; +} + +.activity-item::before { + content: ''; + position: absolute; + left: 15px; + top: 42px; + bottom: 0; + width: 1px; + background: rgba(255,255,255,0.06); +} + +.activity-item:last-child::before { + display: none; +} + +.activity-icon { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 0.8rem; + position: relative; + z-index: 1; +} + +.activity-icon-server_created { background: rgba(16,185,129,0.15); color: var(--accent-green); } +.activity-icon-server_renewed { background: rgba(6,182,212,0.15); color: var(--accent-cyan); } +.activity-icon-server_renamed { background: rgba(238,129,50,0.15); color: var(--accent-1); } +.activity-icon-server_reinstalled { background: rgba(245,158,11,0.15); color: var(--accent-orange); } +.activity-icon-server_deleted { background: rgba(239,68,68,0.15); color: var(--accent-red); } +.activity-icon-account_registered { background: rgba(168,85,247,0.15); color: #a855f7; } +.activity-icon-password_changed { background: rgba(59,130,246,0.15); color: #3b82f6; } +.activity-icon-email_changed { background: rgba(59,130,246,0.15); color: #3b82f6; } +.activity-icon-account_deleted { background: rgba(239,68,68,0.15); color: var(--accent-red); } +.activity-icon-api_key_updated { background: rgba(236,201,75,0.15); color: #ecc94b; } + +.activity-content { + flex: 1; + min-width: 0; +} + +.activity-action { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); +} + +.activity-details { + font-size: 0.78rem; + color: var(--text-muted); + margin-top: 2px; +} + +.activity-time { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + padding-top: 2px; +} + +.activity-empty { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 0.88rem; +} + +/* ===== SEARCH & FILTERS ===== */ +.servers-toolbar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; + align-items: center; +} + +.search-wrapper { + position: relative; + flex: 1; + min-width: 200px; +} + +.search-wrapper svg { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} + +.search-wrapper input { + width: 100%; + padding: 10px 14px 10px 40px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.9rem; + outline: none; + transition: all var(--transition); +} + +.search-wrapper input:focus { + border-color: var(--accent-1); + box-shadow: 0 0 0 3px rgba(238,129,50,0.15); +} + +.search-wrapper input::placeholder { + color: var(--text-muted); +} + +.filter-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.filter-btn { + padding: 8px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 99px; + color: var(--text-secondary); + font-family: 'Schibsted Grotesk', sans-serif; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.filter-btn:hover { + border-color: var(--border-hover); + color: var(--text-primary); +} + +.filter-btn.active { + background: rgba(238,129,50,0.12); + border-color: var(--accent-1); + color: var(--accent-1); +} + +/* ===== RESOURCE BARS ===== */ +.resource-bars { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.resource-bar-row { + display: flex; + align-items: center; + gap: 8px; +} + +.resource-bar-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--text-muted); +} + +.resource-bar-track { + flex: 1; + height: 6px; + background: rgba(255,255,255,0.06); + border-radius: 99px; + overflow: hidden; + position: relative; +} + +.resource-bar-fill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); + width: 100%; + position: relative; +} + +.resource-bar-fill.memory { background: linear-gradient(90deg, #ee8132, #f59e0b); } +.resource-bar-fill.cpu { background: linear-gradient(90deg, #06b6d4, #3b82f6); } +.resource-bar-fill.disk { background: linear-gradient(90deg, #059669, #10b981); } + +.resource-bar-fill.usage-low { background: linear-gradient(90deg, #059669, #10b981); } +.resource-bar-fill.usage-mid { background: linear-gradient(90deg, #f59e0b, #ee8132); } +.resource-bar-fill.usage-high { background: linear-gradient(90deg, #ef4444, #dc2626); } + +.resource-bar-label { + font-size: 0.68rem; + color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; + white-space: nowrap; + min-width: 75px; + text-align: right; +} + +.resource-bar-value { + font-size: 0.68rem; + color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; + white-space: nowrap; + min-width: 32px; +} + +/* Resource gauges for server detail */ +.resource-gauges { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 8px; +} + +.resource-gauge { + text-align: center; + padding: 20px 12px; + background: rgba(255,255,255,0.02); + border: 1px solid var(--border); + border-radius: var(--radius-md); + position: relative; + overflow: hidden; +} + +.resource-gauge-value { + font-size: 1.8rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 4px; +} + +.resource-gauge-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.resource-gauge-bar { + margin-top: 12px; + height: 4px; + background: rgba(255,255,255,0.06); + border-radius: 99px; + overflow: hidden; +} + +.resource-gauge-fill { + height: 100%; + border-radius: 99px; + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); +} + +.resource-gauge-sub { + font-size: 0.68rem; + color: var(--text-muted); + margin-top: 6px; + font-family: 'JetBrains Mono', monospace; +} + +/* Account API key section */ +.api-key-input-group { + display: flex; + gap: 8px; + align-items: stretch; +} + +.api-key-input-group input { + flex: 1; +} + +@media (max-width: 768px) { + .servers-toolbar { + flex-direction: column; + align-items: stretch; + } + .filter-group { + justify-content: center; + } + .resource-gauges { + grid-template-columns: 1fr; + } +} + diff --git a/public/js/app.js b/public/js/app.js index 24fd05c..24426a6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -557,6 +557,14 @@ async function renderOverview() {
Server Slots
To Renew
+
+
+

Resource Overview

+
+
+
Loading...
+
+

Recent Servers

@@ -590,6 +598,38 @@ async function renderOverview() { $('#stat-renew').textContent = toRenew; state.servers = data.servers; + // Resource summary + const servers = data.servers; + const totalMem = servers.reduce((sum, s) => sum + (s.limits?.memory || 0), 0); + const totalCpu = servers.reduce((sum, s) => sum + (s.limits?.cpu || 0), 0); + const totalDisk = servers.reduce((sum, s) => sum + (s.limits?.disk || 0), 0); + const maxMem = limit * 512; + const maxDisk = limit * 3072; + const maxCpu = limit * 50; + const memPct = maxMem > 0 ? Math.min(100, Math.round((totalMem / maxMem) * 100)) : 0; + const cpuPct = maxCpu > 0 ? Math.min(100, Math.round((totalCpu / maxCpu) * 100)) : 0; + const diskPct = maxDisk > 0 ? Math.min(100, Math.round((totalDisk / maxDisk) * 100)) : 0; + + $('#resource-summary-content').innerHTML = html` +
+
+ +
+ ${totalMem} / ${maxMem} MB +
+
+ +
+ ${totalCpu} / ${maxCpu}% +
+
+ +
+ ${(totalDisk / 1024).toFixed(1)} / ${(maxDisk / 1024).toFixed(1)} GB +
+
+ `; + if (data.servers.length === 0 && !data.pteroError) { $('#recent-servers-list').innerHTML = html`
@@ -607,6 +647,8 @@ async function renderOverview() {
`; } + + renderActivity(); } catch (err) { $('#recent-servers-list').innerHTML = html`
Failed to load: ${err.message}
@@ -614,6 +656,92 @@ async function renderOverview() { } } +let activityIcons = { + server_created: '', + server_renewed: '', + server_renamed: '', + server_reinstalled: '', + server_deleted: '', + account_registered: '', + password_changed: '', + email_changed: '', + account_deleted: '', + api_key_updated: '', +}; + +function formatRelativeTime(dateStr) { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return mins + 'm ago'; + const hours = Math.floor(mins / 60); + if (hours < 24) return hours + 'h ago'; + const days = Math.floor(hours / 24); + if (days < 7) return days + 'd ago'; + return formatDate(dateStr); +} + +function getActionLabel(action) { + const labels = { + server_created: 'Created server', + server_renewed: 'Renewed server', + server_renamed: 'Renamed server', + server_reinstalled: 'Reinstalled server', + server_deleted: 'Deleted server', + account_registered: 'Account created', + password_changed: 'Password changed', + email_changed: 'Email changed', + account_deleted: 'Account deleted', + api_key_updated: 'API key updated', + }; + return labels[action] || action; +} + +async function renderActivity() { + const el = $('#page-overview'); + let activitySection = $('#activity-section'); + + if (!activitySection) { + const card = el.querySelector('.card:last-child'); + const section = document.createElement('div'); + section.id = 'activity-section'; + section.className = 'card'; + section.style.marginTop = '20px'; + section.innerHTML = html` +
+

Recent Activity

+
+
Loading...
+ `; + card.parentNode.insertBefore(section, card.nextSibling); + activitySection = section; + } + + try { + const data = await api('/activity'); + const list = $('#activity-list'); + + if (data.activities.length === 0) { + list.innerHTML = '
No activity yet. Create a server to get started.
'; + return; + } + + list.innerHTML = data.activities.slice(0, 10).map(a => html` +
+
${activityIcons[a.action] || ''}
+
+
${getActionLabel(a.action)}
+
${a.details || ''}
+
+
${formatRelativeTime(a.created_at)}
+
+ `).join(''); + } catch (err) { + const list = $('#activity-list'); + if (list) list.innerHTML = '
Could not load activity.
'; + } +} + function formatDate(d) { if (!d) return 'N/A'; const date = new Date(d); @@ -647,7 +775,23 @@ function renderServerCard(s) {
${eggName} ${allocStr} - ${s.limits.memory > 0 ? s.limits.memory + ' MB' : '∞'} +
+
+
+ +
+ ${s.limits.memory > 0 ? s.limits.memory + ' MB' : '∞'} +
+
+ +
+ ${s.limits.cpu}% +
+
+ +
+ ${s.limits.disk > 0 ? (s.limits.disk / 1024).toFixed(1) + ' GB' : '∞'} +
${meta ? html`
@@ -668,6 +812,51 @@ function renderServerCard(s) { `; } +function renderServerRow(s) { + const eggName = s.eggDetails?.name || `Egg #${s.egg}`; + const alloc = s.allocationDetails; + const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + const isSuspended = s.status === 'suspended'; + const statusClass = isSuspended ? 'status-suspended' : (isInstalling ? 'status-installing' : 'status-active'); + const statusLabel = isSuspended ? 'Suspended' : (isInstalling ? 'Installing' : 'Active'); + const allocStr = alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`); + const meta = s.serverMeta; + const days = meta ? daysRemaining(meta.expires_at) : null; + const canRenew = days !== null && days <= 7 && days >= -7; + return html` + + ${s.name} + ${eggName} + ${allocStr} + +
+
+
+ ${s.limits.memory > 0 ? s.limits.memory + 'MB' : '∞'} +
+
+
+ ${s.limits.cpu}% +
+
+ + + ${statusLabel} + ${s.currentState ? html`${s.currentState.charAt(0).toUpperCase() + s.currentState.slice(1)}` : ''} + + +
+ Manage + Open Pterodactyl + ${canRenew ? html` + + ` : ''} +
+ + + `; +} + // ===== SERVERS PAGE ===== async function renderServers() { const el = $('#page-servers'); @@ -676,6 +865,18 @@ async function renderServers() {

My Servers

All your servers on ZeroHost

+
+
+ + +
+
+ + + + +
+
@@ -683,12 +884,13 @@ async function renderServers() { + - +
Name Egg AllocationResources Status Actions
Loading...
Loading...
@@ -701,43 +903,56 @@ async function renderServers() { if (data.servers.length === 0) { $('#servers-table-body').innerHTML = html` - No servers yet. Create one + No servers yet. Create one `; return; } - $('#servers-table-body').innerHTML = data.servers.map(s => { - const eggName = s.eggDetails?.name || `Egg #${s.egg}`; - const alloc = s.allocationDetails; - const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; - const isSuspended = s.status === 'suspended'; - const statusClass = isSuspended ? 'status-suspended' : (isInstalling ? 'status-installing' : 'status-active'); - const statusLabel = isSuspended ? 'Suspended' : (isInstalling ? 'Installing' : 'Active'); - const meta = s.serverMeta; - const days = meta ? daysRemaining(meta.expires_at) : null; - const canRenew = days !== null && days <= 7 && days >= -7; - const expClass = days !== null && days <= 0 ? 'expired' : (days !== null && days <= 7 ? 'expiring' : ''); - return html` - - ${s.name} - ${eggName} - ${alloc ? `${alloc.alias || alloc.nodeFqdn || alloc.ip}:${alloc.port}` : (s.nodeFqdn || `Node #${s.node}`)} - ${statusLabel} - -
- Manage - Open Pyrodactyl - ${canRenew ? html` - - ` : ''} -
- - - `; - }).join(''); + function applyFilters() { + const searchTerm = ($('#server-search-input')?.value || '').toLowerCase(); + const activeFilter = document.querySelector('.filter-btn.active')?.dataset?.filter || 'all'; + const searchWords = searchTerm.split(/\s+/).filter(Boolean); + + let filtered = data.servers; + + if (activeFilter !== 'all') { + filtered = filtered.filter(s => { + if (activeFilter === 'installing') return s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false; + return s.status === activeFilter; + }); + } + + if (searchWords.length > 0) { + filtered = filtered.filter(s => { + const eggName = s.eggDetails?.name || `Egg #${s.egg}`; + const searchable = [s.name, eggName, s.identifier || '', s.node?.toString() || ''].join(' ').toLowerCase(); + return searchWords.every(w => searchable.includes(w)); + }); + } + + $('#servers-table-body').innerHTML = filtered.map(s => renderServerRow(s)).join(''); + + if (filtered.length === 0) { + $('#servers-table-body').innerHTML = html` + No servers match your search. + `; + } + } + + $('#server-search-input').addEventListener('input', applyFilters); + + document.querySelectorAll('#server-filters .filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#server-filters .filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + applyFilters(); + }); + }); + + applyFilters(); } catch (err) { $('#servers-table-body').innerHTML = html` - Error: ${err.message} + Error: ${err.message} `; } } @@ -1045,10 +1260,56 @@ function renderAccountLinks() {

Manage your linked accounts

-
-

No third-party account linking available.

+
+

Pterodactyl API Key

+

+ Add your Pterodactyl Client API key to enable live resource monitoring (CPU, RAM, Disk usage) directly in the dashboard. + Generate one at panel.zero-host.org/account/api. +

+
+
+ + +
+
+
`; + + $('#api-key-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = $('#save-api-key-btn'); + const input = $('#ptero-api-key-input'); + const status = $('#api-key-status'); + const key = input.value.trim(); + + if (!key) { + status.textContent = 'Please enter an API key.'; + status.style.color = 'var(--accent-red)'; + return; + } + + btn.disabled = true; + btn.innerHTML = ''; + status.textContent = ''; + + try { + await api('/servers/client-api-key', { + method: 'PUT', + body: JSON.stringify({ apiKey: key }), + }); + status.textContent = 'API key saved successfully!'; + status.style.color = 'var(--accent-green)'; + input.value = ''; + showToast('Pterodactyl API key saved', 'success'); + } catch (err) { + status.textContent = err.message; + status.style.color = 'var(--accent-red)'; + } finally { + btn.disabled = false; + btn.innerHTML = 'Save'; + } + }); } async function handleChangeEmail(e) { @@ -1250,6 +1511,7 @@ async function renderServerDetail(serverId) {
+
@@ -1260,15 +1522,36 @@ async function renderServerDetail(serverId) {
Egg${eggName}
Allocation${allocStr}
-
Memory${s.limits.memory > 0 ? s.limits.memory + ' MB' : 'Unlimited'}
-
Disk${s.limits.disk > 0 ? s.limits.disk + ' MB' : 'Unlimited'}
-
CPU${s.limits.cpu}%
IO${s.limits.io}
Swap${s.limits.swap > 0 ? s.limits.swap + ' MB' : 'Disabled'}
Identifier${s.identifier}
+
+

Resources

+
+
+
${s.limits.memory > 0 ? s.limits.memory + ' MB' : '∞'}
+
Memory
+
+
${s.limits.memory > 0 ? '512 MB max' : 'No limit'}
+
+
+
${s.limits.cpu}%
+
CPU
+
+
50% max
+
+
+
${s.limits.disk > 0 ? (s.limits.disk / 1024).toFixed(1) + ' GB' : '∞'}
+
Disk
+
+
3 GB max
+
+
+
+

Lifetime

${meta ? html` @@ -1290,6 +1573,21 @@ async function renderServerDetail(serverId) {
+
+
+
+

Live Resource Usage

+ +
+
+
Fetching live data...
+
+
+
+
@@ -1326,6 +1624,10 @@ async function renderServerDetail(serverId) {
`; + if (activeTab === 'resources') { + fetchLiveResources(s.identifier); + } + } catch (err) { el.innerHTML = html`
@@ -1338,6 +1640,88 @@ async function renderServerDetail(serverId) { } } +async function fetchLiveResources(identifier) { + const container = $('#live-resources-container'); + if (!container) return; + + try { + const data = await api(`/servers/resources/${identifier}`); + + if (!data.resources) { + container.innerHTML = html` +
+
No live data
+
+ ${data.error || 'Add your Pterodactyl API key in Account → Linked Accounts to enable live monitoring.'} +
+ +
+ `; + return; + } + + const res = data.resources; + const cpuPct = Math.round(res.cpu_absolute || 0); + const memUsed = res.memory_bytes ? Math.round(res.memory_bytes / (1024 * 1024)) : 0; + const memLimit = res.memory_limit_bytes ? Math.round(res.memory_limit_bytes / (1024 * 1024)) : 512; + const memPct = memLimit > 0 ? Math.min(100, Math.round((memUsed / memLimit) * 100)) : 0; + const diskUsed = res.disk_bytes ? Math.round(res.disk_bytes / (1024 * 1024)) : 0; + const diskLimit = 3072; + const diskPct = diskLimit > 0 ? Math.min(100, Math.round((diskUsed / diskLimit) * 100)) : 0; + + function usageClass(pct) { + if (pct >= 80) return 'usage-high'; + if (pct >= 50) return 'usage-mid'; + return 'usage-low'; + } + + container.innerHTML = html` +
+
+
${cpuPct}%
+
CPU
+
+
${cpuPct >= 80 ? 'High load' : cpuPct >= 50 ? 'Moderate' : 'Idle'}
+
+
+
${memUsed} / ${memLimit} MB
+
Memory
+
+
${memPct}% used
+
+
+
${(diskUsed / 1024).toFixed(1)} / ${(diskLimit / 1024).toFixed(1)} GB
+
Disk
+
+
${diskPct}% used
+
+
+
+ Updated ${formatRelativeTime(new Date().toISOString())} · + ${res.current_state ? html`Status: ${res.current_state}` : ''} +
+ `; + + const refreshBtn = $('#refresh-resources-btn'); + if (refreshBtn && !refreshBtn.dataset.listenerAttached) { + refreshBtn.dataset.listenerAttached = '1'; + refreshBtn.addEventListener('click', () => { + container.innerHTML = '
Fetching live data...
'; + fetchLiveResources(identifier); + }); + } + + } catch (err) { + if (container) { + container.innerHTML = html` +
+ Could not load live resources: ${err.message} +
+ `; + } + } +} + // ===== DELETE SERVER ===== document.addEventListener('click', function(e) { const tabBtn = e.target.closest('.tab'); diff --git a/routes/auth.js b/routes/auth.js index ce9dfdf..8ce1efd 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -5,6 +5,7 @@ import { query } from '../config/db.js'; import { generateToken, authenticateToken } from '../middleware/auth.js'; import { createPteroUser, updatePteroPassword, updatePteroEmail, deletePteroUser, getServersByUser, deletePteroServer } from '../services/pterodactyl.js'; import { verifyCap } from '../config/cap.js'; +import { logActivity } from '../services/activity.js'; const router = Router(); @@ -131,6 +132,8 @@ router.post('/register', async (req, res) => { maxAge: 2 * 60 * 60 * 1000, }); + logActivity(localUserId, 'account_registered', 'Created account'); + res.status(201).json({ token, user: { @@ -261,6 +264,7 @@ router.post('/change-password', authenticateToken, async (req, res) => { console.error('Failed to update Pterodactyl password:', err.message); } + logActivity(req.user.userId, 'password_changed', 'Changed password'); res.json({ message: 'Password updated successfully' }); } catch (err) { console.error('Change password error:', err.message); @@ -311,6 +315,8 @@ router.post('/change-email', authenticateToken, async (req, res) => { // Update local DB await query('UPDATE users SET email = ? WHERE id = ?', [newEmail, userId]); + logActivity(userId, 'email_changed', `Changed email to ${newEmail}`); + // Generate new token with updated email const token = generateToken({ userId: user.id, @@ -359,6 +365,8 @@ router.post('/delete-account', authenticateToken, async (req, res) => { return res.status(401).json({ error: 'Password is incorrect' }); } + logActivity(user.id, 'account_deleted', 'Deleted account'); + // Delete from local DB first (cascades to user_ips) await query('DELETE FROM users WHERE id = ?', [user.id]); diff --git a/routes/servers.js b/routes/servers.js index de16ff9..bac2c22 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -11,8 +11,10 @@ import { getEgg, getAllEggs, } from '../services/pterodactyl.js'; +import { PTERO_URL } from '../config/pterodactyl.js'; import { query } from '../config/db.js'; import { verifyCap } from '../config/cap.js'; +import { logActivity } from '../services/activity.js'; const router = Router(); @@ -36,6 +38,34 @@ router.get('/list', authenticateToken, async (req, res) => { } } + // Fetch live power state from Pterodactyl Client API + const userRows = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [req.user.userId]); + const clientApiKey = userRows[0]?.ptero_client_api_key; + + if (clientApiKey) { + await Promise.all(servers.map(async (server) => { + try { + const pteroRes = await fetch(`${PTERO_URL}/api/client/servers/${server.identifier}/resources`, { + headers: { + 'Authorization': `Bearer ${clientApiKey}`, + 'Accept': 'application/json', + }, + signal: AbortSignal.timeout(8000), + }); + if (pteroRes.ok) { + const data = await pteroRes.json(); + server.currentState = data.attributes.current_state; + } else { + server.currentState = null; + } + } catch { + server.currentState = null; + } + })); + } else { + servers.forEach(s => s.currentState = null); + } + res.json({ servers }); } catch (err) { console.error('List servers error:', err.message); @@ -133,6 +163,7 @@ router.post('/create', authenticateToken, async (req, res) => { [server.id, req.user.userId, 'active'] ).catch(err => console.error('Failed to log server meta:', err.message)); + logActivity(req.user.userId, 'server_created', `Created server "${name}"`, server.id); res.status(201).json({ server }); } catch (err) { console.error('Create server error:', err.message); @@ -205,6 +236,7 @@ router.post('/renew/:id', authenticateToken, async (req, res) => { } } + logActivity(req.user.userId, 'server_renewed', `Renewed server #${serverId}`, serverId); const updated = await query('SELECT * FROM server_meta WHERE id = ?', [row.id]); res.json({ serverMeta: updated[0] }); } catch (err) { @@ -233,6 +265,7 @@ router.patch('/:id', authenticateToken, async (req, res) => { } await renamePteroServer(serverId, name.trim()); + logActivity(req.user.userId, 'server_renamed', `Renamed server #${serverId} to "${name.trim()}"`, serverId); res.json({ success: true }); } catch (err) { console.error('Rename server error:', err.message); @@ -252,6 +285,7 @@ router.post('/:id/reinstall', authenticateToken, async (req, res) => { } await reinstallPteroServer(serverId); + logActivity(req.user.userId, 'server_reinstalled', `Reinstalled server #${serverId}`, serverId); res.json({ success: true }); } catch (err) { console.error('Reinstall server error:', err.message); @@ -261,8 +295,10 @@ router.post('/:id/reinstall', authenticateToken, async (req, res) => { router.delete('/:id', authenticateToken, async (req, res) => { try { - await deletePteroServer(req.params.id); - await query('DELETE FROM server_meta WHERE ptero_server_id = ?', [req.params.id]); + const serverId = parseInt(req.params.id); + await deletePteroServer(serverId); + await query('DELETE FROM server_meta WHERE ptero_server_id = ?', [serverId]); + logActivity(req.user.userId, 'server_deleted', `Deleted server #${serverId}`); res.json({ success: true }); } catch (err) { console.error('Delete server error:', err.message); @@ -307,4 +343,54 @@ router.get('/overview', authenticateToken, async (req, res) => { } }); +router.get('/resources/:identifier', authenticateToken, async (req, res) => { + try { + const { identifier } = req.params; + const userId = req.user.userId; + + const users = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [userId]); + if (users.length === 0 || !users[0].ptero_client_api_key) { + return res.json({ resources: null, error: 'No Pterodactyl API key configured. Set one in Account settings.' }); + } + + const apiKey = users[0].ptero_client_api_key; + const pteroRes = await fetch(`https://panel.zero-host.org/api/client/servers/${identifier}/resources`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': 'application/json', + }, + signal: AbortSignal.timeout(8000), + }); + + if (!pteroRes.ok) { + return res.status(502).json({ error: 'Failed to fetch resources from panel' }); + } + + const data = await pteroRes.json(); + res.json({ resources: data.attributes }); + } catch (err) { + console.error('Resources error:', err.message); + res.status(500).json({ error: 'Failed to fetch server resources' }); + } +}); + +router.put('/client-api-key', authenticateToken, async (req, res) => { + try { + const { apiKey } = req.body; + const userId = req.user.userId; + + if (!apiKey || typeof apiKey !== 'string') { + return res.status(400).json({ error: 'API key is required' }); + } + + await query('UPDATE users SET ptero_client_api_key = ? WHERE id = ?', [apiKey.trim(), userId]); + logActivity(req.user.userId, 'api_key_updated', 'Updated Pterodactyl API key'); + + res.json({ success: true }); + } catch (err) { + console.error('API key update error:', err.message); + res.status(500).json({ error: 'Failed to update API key' }); + } +}); + export default router; diff --git a/server.js b/server.js index 42a633f..a934af6 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; +import jwt from 'jsonwebtoken'; import path from 'path'; import authRoutes from './routes/auth.js'; @@ -18,6 +19,7 @@ import serverRoutes from './routes/servers.js'; import { startScheduler } from './services/scheduler.js'; import { migrate } from './config/migrate.js'; import { query } from './config/db.js'; +import { getRecentActivity } from './services/activity.js'; const app = express(); let portFromFile = 3000; @@ -91,6 +93,28 @@ app.use('/api', apiLimiter); app.use('/api/auth', authRoutes); app.use('/api/servers', serverRoutes); +app.get('/api/activity', async (req, res) => { + try { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const userId = decoded.userId; + + const activities = await getRecentActivity(userId); + res.json({ activities }); + } catch (err) { + if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + console.error('Activity route error:', err.message); + res.status(500).json({ error: 'Failed to fetch activities' }); + } +}); + app.get('/api/health', async (req, res) => { try { await query('SELECT 1'); diff --git a/services/activity.js b/services/activity.js new file mode 100644 index 0000000..afb7de2 --- /dev/null +++ b/services/activity.js @@ -0,0 +1,20 @@ +import { query } from '../config/db.js'; + +export async function logActivity(userId, action, details = '', serverId = null) { + try { + await query( + 'INSERT INTO activity_log (user_id, action, details, server_id) VALUES (?, ?, ?, ?)', + [userId, action, details, serverId] + ); + } catch (err) { + console.error('Failed to log activity:', err.message); + } +} + +export async function getRecentActivity(userId, limit = 20) { + const rows = await query( + 'SELECT * FROM activity_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', + [userId, limit] + ); + return rows; +} From f9b6aa82d2b6a88e52f49cf8e8f9fa851fc80f5e Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:26:30 +0200 Subject: [PATCH 20/29] Replace Pterodactyl API key form with account linking notice on /account/links --- public/js/app.js | 51 +++++------------------------------------------- 1 file changed, 5 insertions(+), 46 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 24426a6..7caa7b5 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1261,55 +1261,14 @@ function renderAccountLinks() {
-

Pterodactyl API Key

-

- Add your Pterodactyl Client API key to enable live resource monitoring (CPU, RAM, Disk usage) directly in the dashboard. - Generate one at panel.zero-host.org/account/api. +

+ Aucun système de liaison de comptes n'est activé pour le moment en raison de la complexité + à maintenir cela à la fois sur le dashboard et le panel Pyrodactyl. C'est pour cette raison + que nous avons retiré la création de compte et la connexion via Discord. Peut-être que cela + reviendra plus tard.

-
-
- - -
-
-
`; - - $('#api-key-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const btn = $('#save-api-key-btn'); - const input = $('#ptero-api-key-input'); - const status = $('#api-key-status'); - const key = input.value.trim(); - - if (!key) { - status.textContent = 'Please enter an API key.'; - status.style.color = 'var(--accent-red)'; - return; - } - - btn.disabled = true; - btn.innerHTML = ''; - status.textContent = ''; - - try { - await api('/servers/client-api-key', { - method: 'PUT', - body: JSON.stringify({ apiKey: key }), - }); - status.textContent = 'API key saved successfully!'; - status.style.color = 'var(--accent-green)'; - input.value = ''; - showToast('Pterodactyl API key saved', 'success'); - } catch (err) { - status.textContent = err.message; - status.style.color = 'var(--accent-red)'; - } finally { - btn.disabled = false; - btn.innerHTML = 'Save'; - } - }); } async function handleChangeEmail(e) { From 63b4c02bca99a46d166691bf9d24c2382d9b5052 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:28:01 +0200 Subject: [PATCH 21/29] Translate remaining French text to English in account linking notice --- public/js/app.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 7caa7b5..059031f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1262,10 +1262,10 @@ function renderAccountLinks() {

- Aucun système de liaison de comptes n'est activé pour le moment en raison de la complexité - à maintenir cela à la fois sur le dashboard et le panel Pyrodactyl. C'est pour cette raison - que nous avons retiré la création de compte et la connexion via Discord. Peut-être que cela - reviendra plus tard. + No account linking system is currently active due to the complexity + of maintaining it both on the dashboard and the Pyrodactyl panel. This is why + we have removed account creation and login via Discord. Perhaps this will + return later.

`; From 9929878a7b727ecabc785b9f4a741923930d4823 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:30:43 +0200 Subject: [PATCH 22/29] Add Pterodactyl API key form to /account/edit page --- public/js/app.js | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/public/js/app.js b/public/js/app.js index 059031f..15137d6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1241,11 +1241,62 @@ function renderAccountEdit() { + +
+

Pterodactyl API Key

+

+ Add your Pterodactyl Client API key to enable live resource monitoring and server power state detection directly in the dashboard. + Generate one at panel.zero-host.org/account/api. +

+
+
+ + +
+
+
+
`; $('#change-email-form').addEventListener('submit', handleChangeEmail); $('#change-password-form').addEventListener('submit', handleChangePassword); + $('#api-key-form').addEventListener('submit', handleSaveApiKey); +} + +async function handleSaveApiKey(e) { + e.preventDefault(); + const btn = $('#save-api-key-btn'); + const input = $('#ptero-api-key-input'); + const status = $('#api-key-status'); + const key = input.value.trim(); + + if (!key) { + status.textContent = 'Please enter an API key.'; + status.style.color = 'var(--accent-red)'; + return; + } + + btn.disabled = true; + btn.innerHTML = ''; + status.textContent = ''; + + try { + await api('/servers/client-api-key', { + method: 'PUT', + body: JSON.stringify({ apiKey: key }), + }); + status.textContent = 'API key saved successfully!'; + status.style.color = 'var(--accent-green)'; + input.value = ''; + showToast('Pterodactyl API key saved', 'success'); + } catch (err) { + status.textContent = err.message; + status.style.color = 'var(--accent-red)'; + } finally { + btn.disabled = false; + btn.innerHTML = 'Save'; + } } function renderAccountLinks() { From bd485f1b9e612f3e1da91c6da8de00dd3d24560b Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:32:01 +0200 Subject: [PATCH 23/29] Match API key input style with other forms on /account/edit --- public/js/app.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 15137d6..a002ed1 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1243,16 +1243,17 @@ function renderAccountEdit() {
-

Pterodactyl API Key

+

Pterodactyl API Key

Add your Pterodactyl Client API key to enable live resource monitoring and server power state detection directly in the dashboard. Generate one at panel.zero-host.org/account/api.

-
-
+ +
+ -
+
From 2ed7f892489d3d55827ebaeb4a64eb86bdf1620a Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:34:58 +0200 Subject: [PATCH 24/29] Fix html() falsy-0 bug causing resource bars to show full and labels to omit zero values --- public/css/style.css | 2 +- public/js/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 4459766..a5237c0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1674,7 +1674,7 @@ tbody tr:hover { border-radius: 99px; background: linear-gradient(90deg, var(--accent-1), var(--accent-2)); transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); - width: 100%; + width: 0%; position: relative; } diff --git a/public/js/app.js b/public/js/app.js index a002ed1..5f63371 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -88,7 +88,7 @@ function gravatarUrl(email, size = 32) { } function html(strings, ...values) { - return strings.reduce((acc, str, i) => acc + str + (values[i] || ''), ''); + return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), ''); } async function api(path, options = {}) { From c4dd152b0f82a5a6c75815e4f10655f4afae9a4b Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:37:32 +0200 Subject: [PATCH 25/29] Replace resource bar SVGs with clearer RAM chip and CPU processor icons --- public/js/app.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 5f63371..ee742cd 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -613,12 +613,12 @@ async function renderOverview() { $('#resource-summary-content').innerHTML = html`
- +
${totalMem} / ${maxMem} MB
- +
${totalCpu} / ${maxCpu}%
@@ -778,12 +778,12 @@ function renderServerCard(s) {
- +
${s.limits.memory > 0 ? s.limits.memory + ' MB' : '∞'}
- +
${s.limits.cpu}%
From b96fc061c3a2602999d8d28157ab2767ea0782ec Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:39:24 +0200 Subject: [PATCH 26/29] Remove Resources column from servers table --- public/js/app.js | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index ee742cd..d9fd6a7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -828,18 +828,6 @@ function renderServerRow(s) { ${s.name} ${eggName} ${allocStr} - -
-
-
- ${s.limits.memory > 0 ? s.limits.memory + 'MB' : '∞'} -
-
-
- ${s.limits.cpu}% -
-
- ${statusLabel} ${s.currentState ? html`${s.currentState.charAt(0).toUpperCase() + s.currentState.slice(1)}` : ''} @@ -884,13 +872,12 @@ async function renderServers() { Name Egg Allocation - Resources Status Actions - Loading... + Loading...
@@ -903,7 +890,7 @@ async function renderServers() { if (data.servers.length === 0) { $('#servers-table-body').innerHTML = html` - No servers yet. Create one + No servers yet. Create one `; return; } @@ -934,7 +921,7 @@ async function renderServers() { if (filtered.length === 0) { $('#servers-table-body').innerHTML = html` - No servers match your search. + No servers match your search. `; } } @@ -952,7 +939,7 @@ async function renderServers() { applyFilters(); } catch (err) { $('#servers-table-body').innerHTML = html` - Error: ${err.message} + Error: ${err.message} `; } } From 16b53488e1336272e6858564a9c9476a1f841c92 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:40:34 +0200 Subject: [PATCH 27/29] Change Configure API Key link from account/links to account/edit --- public/js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/app.js b/public/js/app.js index d9fd6a7..85dc630 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1652,7 +1652,7 @@ async function fetchLiveResources(identifier) {
${data.error || 'Add your Pterodactyl API key in Account → Linked Accounts to enable live monitoring.'}
- +
`; return; From c5f642f48d6355584bb55d0989e97ef512f92b07 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:41:40 +0200 Subject: [PATCH 28/29] Remove max-width constraint on Lifetime card to match Server Info width --- public/css/style.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index a5237c0..eca552e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1252,10 +1252,7 @@ tbody tr:hover { align-items: start; } -.server-detail-grid .card:last-child { - grid-column: 1 / -1; - max-width: 480px; -} + .detail-list { display: flex; From 988490c4440925d531f616915879036dda7f8dd4 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Mon, 22 Jun 2026 16:43:14 +0200 Subject: [PATCH 29/29] Remove resource bars from Recent Servers cards on overview --- public/js/app.js | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 85dc630..3c85f6f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -776,23 +776,7 @@ function renderServerCard(s) { ${eggName} ${allocStr} -
-
- -
- ${s.limits.memory > 0 ? s.limits.memory + ' MB' : '∞'} -
-
- -
- ${s.limits.cpu}% -
-
- -
- ${s.limits.disk > 0 ? (s.limits.disk / 1024).toFixed(1) + ' GB' : '∞'} -
-
+ ${meta ? html`
Expires: ${formatDate(meta.expires_at)}