Skip to content
This repository was archived by the owner on Jul 28, 2026. It is now read-only.

Commit b4d1548

Browse files
committed
feat: remove hardcoded PTERO_URL, add node management in admin panel
- Add GET /api/config endpoint to expose PTERO_URL from env to frontend - Add getAllNodes, getNodeDetail, getNodeAllocations, getNodeServers in pyrodactyl service - Add admin API routes for nodes: list, detail, allocations, servers - Add Nodes page in admin panel with table view and detail view (resources, allocations, servers) - Replace hardcoded PTERO_URL in app.js with dynamic fetch from /api/config
1 parent baa8406 commit b4d1548

5 files changed

Lines changed: 393 additions & 2 deletions

File tree

public/js/admin.js

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ function adminNavigateTo(page) {
7272
adminState.currentPage = basePage;
7373
adminState.serverId = param ? parseInt(param, 10) : null;
7474
adminState.userId = param ? parseInt(param, 10) : null;
75+
adminState.nodeId = param ? parseInt(param, 10) : null;
7576
updateAdminNav();
7677

7778
if (basePage === 'server' && adminState.serverId) {
@@ -80,6 +81,12 @@ function adminNavigateTo(page) {
8081
} else if (basePage === 'user' && adminState.userId) {
8182
renderAdminUserDetail(adminState.userId);
8283
history.pushState({ adminPage: 'user', userId: adminState.userId }, '', `/admin/user/${adminState.userId}`);
84+
} else if (basePage === 'node' && adminState.nodeId) {
85+
renderAdminNodeDetail(adminState.nodeId);
86+
history.pushState({ adminPage: 'node', nodeId: adminState.nodeId }, '', `/admin/node/${adminState.nodeId}`);
87+
} else if (basePage === 'nodes') {
88+
renderAdminNodes();
89+
history.pushState({ adminPage: 'nodes' }, '', '/admin/nodes');
8390
} else if (basePage === 'users') {
8491
renderAdminUsers();
8592
history.pushState({ adminPage: 'users' }, '', '/admin/users');
@@ -212,6 +219,10 @@ function renderAdminLayout() {
212219
<i data-lucide="server" style="width:18px;height:18px"></i>
213220
Servers
214221
</a>
222+
<a class="admin-nav-link" data-page="nodes" href="/admin/nodes">
223+
<i data-lucide="network" style="width:18px;height:18px"></i>
224+
Nodes
225+
</a>
215226
<a class="admin-nav-link" data-page="users" href="/admin/users">
216227
<i data-lucide="users" style="width:18px;height:18px"></i>
217228
Users
@@ -237,6 +248,8 @@ function renderAdminLayout() {
237248
<div class="admin-page" id="admin-page-dashboard"></div>
238249
<div class="admin-page active" id="admin-page-servers"></div>
239250
<div class="admin-page" id="admin-page-server-detail"></div>
251+
<div class="admin-page" id="admin-page-nodes"></div>
252+
<div class="admin-page" id="admin-page-node-detail"></div>
240253
<div class="admin-page" id="admin-page-users"></div>
241254
<div class="admin-page" id="admin-page-user-detail"></div>
242255
<div class="admin-page" id="admin-page-activity"></div>
@@ -286,6 +299,17 @@ function renderAdminLayout() {
286299
adminState.userId = uid;
287300
$a('#admin-page-user-detail').classList.add('active');
288301
renderAdminUserDetail(uid);
302+
} else if (basePage === 'node' && param) {
303+
const nid = parseInt(param, 10);
304+
adminState.currentPage = 'node';
305+
updateAdminNav();
306+
adminState.nodeId = nid;
307+
$a('#admin-page-node-detail').classList.add('active');
308+
renderAdminNodeDetail(nid);
309+
} else if (basePage === 'nodes') {
310+
adminState.currentPage = 'nodes';
311+
updateAdminNav();
312+
renderAdminNodes();
289313
} else if (basePage === 'users') {
290314
adminState.currentPage = 'users';
291315
updateAdminNav();
@@ -1941,6 +1965,236 @@ function showDeleteNestConfirm(nestId, name) {
19411965
});
19421966
}
19431967

1968+
// ─── Nodes ──────────────────────────────────────────────
1969+
async function renderAdminNodes() {
1970+
document.querySelectorAll('.admin-page').forEach(p => p.classList.remove('active'));
1971+
const el = $a('#admin-page-nodes');
1972+
if (!el) return;
1973+
el.classList.add('active');
1974+
el.innerHTML = ahtml`
1975+
<div class="page-header">
1976+
<h1 class="page-title">Nodes</h1>
1977+
<p class="page-subtitle">Pterodactyl panel nodes</p>
1978+
</div>
1979+
<div class="table-container">
1980+
<table>
1981+
<thead>
1982+
<tr>
1983+
<th>ID</th>
1984+
<th>Name</th>
1985+
<th>FQDN</th>
1986+
<th>RAM</th>
1987+
<th>Disk</th>
1988+
<th>CPU</th>
1989+
<th>Allocations</th>
1990+
<th>Status</th>
1991+
</tr>
1992+
</thead>
1993+
<tbody id="admin-nodes-tbody">
1994+
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-secondary)"><span class="spinner"></span> Loading...</td></tr>
1995+
</tbody>
1996+
</table>
1997+
</div>
1998+
`;
1999+
2000+
try {
2001+
const data = await adminApi('/nodes');
2002+
const tbody = $a('#admin-nodes-tbody');
2003+
if (!tbody) return;
2004+
2005+
if (data.nodes.length === 0) {
2006+
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-secondary)">No nodes found in the panel.</td></tr>';
2007+
return;
2008+
}
2009+
2010+
tbody.innerHTML = data.nodes.map(n => {
2011+
const ramMB = n.memory ? Math.round(n.memory / 1024) : '—';
2012+
const diskGB = n.disk ? (n.disk / 1024).toFixed(1) : '—';
2013+
const cpuPct = n.cpu ? n.cpu + '%' : '—';
2014+
const allocCount = n.allocation_count ?? '—';
2015+
const isOnline = n.is_online;
2016+
return ahtml`
2017+
<tr>
2018+
<td>${n.id}</td>
2019+
<td><a href="/admin/node/${n.id}" onclick="event.preventDefault();adminNavigateTo('node/${n.id}')" style="font-weight:600;cursor:pointer">${escapeHtml(n.name)}</a></td>
2020+
<td><span class="server-detail-tag">${escapeHtml(n.fqdn)}</span></td>
2021+
<td>${ramMB !== '—' ? ramMB + ' MB' : '—'}</td>
2022+
<td>${diskGB !== '—' ? diskGB + ' GB' : '—'}</td>
2023+
<td>${cpuPct}</td>
2024+
<td>${allocCount}</td>
2025+
<td>${isOnline !== undefined ? (isOnline ? '<span style="color:var(--accent-green)">Online</span>' : '<span style="color:var(--accent-red)">Offline</span>') : '—'}</td>
2026+
</tr>
2027+
`;
2028+
}).join('');
2029+
} catch (err) {
2030+
const tbody = $a('#admin-nodes-tbody');
2031+
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;
2032+
}
2033+
}
2034+
2035+
async function renderAdminNodeDetail(nodeId) {
2036+
document.querySelectorAll('.admin-page').forEach(p => p.classList.remove('active'));
2037+
const el = $a('#admin-page-node-detail');
2038+
if (!el) return;
2039+
el.classList.add('active');
2040+
el.innerHTML = ahtml`
2041+
<div class="page-header">
2042+
<a href="/admin/nodes" onclick="event.preventDefault();adminNavigateTo('nodes')" class="btn btn-ghost btn-sm" style="display:inline-flex;width:auto;margin-bottom:16px">
2043+
<i data-lucide="arrow-left" style="width:14px;height:14px"></i>
2044+
Back to Nodes
2045+
</a>
2046+
<h1 class="page-title" style="margin-bottom:0">Node #${nodeId}</h1>
2047+
<p class="page-subtitle" id="admin-node-name">Loading...</p>
2048+
</div>
2049+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px" id="admin-node-info-cards">
2050+
<div class="card" style="padding:20px">
2051+
<h3 style="color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;margin-bottom:12px">Details</h3>
2052+
<div id="admin-node-details"><span class="spinner"></span></div>
2053+
</div>
2054+
<div class="card" style="padding:20px">
2055+
<h3 style="color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;margin-bottom:12px">Resources</h3>
2056+
<div id="admin-node-resources"><span class="spinner"></span></div>
2057+
</div>
2058+
</div>
2059+
<div class="card" style="padding:20px;margin-bottom:24px">
2060+
<h3 style="color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;margin-bottom:12px">Allocations</h3>
2061+
<div class="table-container" style="margin:0">
2062+
<table>
2063+
<thead>
2064+
<tr>
2065+
<th>ID</th>
2066+
<th>IP</th>
2067+
<th>Port</th>
2068+
<th>Alias</th>
2069+
<th>Server</th>
2070+
</tr>
2071+
</thead>
2072+
<tbody id="admin-node-alloc-tbody">
2073+
<tr><td colspan="5" style="text-align:center;padding:16px;color:var(--text-secondary)"><span class="spinner"></span></td></tr>
2074+
</tbody>
2075+
</table>
2076+
</div>
2077+
</div>
2078+
<div class="card" style="padding:20px">
2079+
<h3 style="color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;margin-bottom:12px">Servers on this Node</h3>
2080+
<div class="table-container" style="margin:0">
2081+
<table>
2082+
<thead>
2083+
<tr>
2084+
<th>ID</th>
2085+
<th>Name</th>
2086+
<th>Identifier</th>
2087+
<th>Status</th>
2088+
</tr>
2089+
</thead>
2090+
<tbody id="admin-node-servers-tbody">
2091+
<tr><td colspan="4" style="text-align:center;padding:16px;color:var(--text-secondary)"><span class="spinner"></span></td></tr>
2092+
</tbody>
2093+
</table>
2094+
</div>
2095+
</div>
2096+
`;
2097+
initIcons();
2098+
2099+
try {
2100+
const [nodeData, allocData, serversData] = await Promise.all([
2101+
adminApi(`/nodes/${nodeId}`),
2102+
adminApi(`/nodes/${nodeId}/allocations`).catch(() => ({ allocations: [] })),
2103+
adminApi(`/nodes/${nodeId}/servers`).catch(() => ({ servers: [] })),
2104+
]);
2105+
2106+
const n = nodeData.node;
2107+
const nameEl = $a('#admin-node-name');
2108+
if (nameEl) nameEl.textContent = escapeHtml(n.name);
2109+
2110+
const detailsEl = $a('#admin-node-details');
2111+
if (detailsEl) {
2112+
detailsEl.innerHTML = ahtml`
2113+
<div style="display:grid;gap:8px;font-size:0.88rem">
2114+
<div><span style="color:var(--text-secondary)">FQDN:</span> <strong>${escapeHtml(n.fqdn)}</strong></div>
2115+
<div><span style="color:var(--text-secondary)">Scheme:</span> ${n.scheme || 'https'}</div>
2116+
<div><span style="color:var(--text-secondary)">Port:</span> ${n.port || '—'}</div>
2117+
<div><span style="color:var(--text-secondary)">Daemon Token:</span> <span style="font-family:monospace;font-size:0.8rem">${n.daemon_token ? '••••••••' : '—'}</span></div>
2118+
<div><span style="color:var(--text-secondary)">Status:</span> ${n.is_online !== undefined ? (n.is_online ? '<span style="color:var(--accent-green)">Online</span>' : '<span style="color:var(--accent-red)">Offline</span>') : '—'}</div>
2119+
<div><span style="color:var(--text-secondary)">Communication:</span> ${n.communications || '—'}</div>
2120+
</div>
2121+
`;
2122+
}
2123+
2124+
const resEl = $a('#admin-node-resources');
2125+
if (resEl) {
2126+
const memTotal = n.memory ? Math.round(n.memory / 1024) : 0;
2127+
const memUsed = n.memory_allocated ? Math.round(n.memory_allocated / 1024) : 0;
2128+
const diskTotal = n.disk ? (n.disk / 1024).toFixed(1) : 0;
2129+
const diskUsed = n.disk_allocated ? (n.disk_allocated / 1024).toFixed(1) : 0;
2130+
resEl.innerHTML = ahtml`
2131+
<div style="display:grid;gap:12px;font-size:0.88rem">
2132+
<div>
2133+
<div style="display:flex;justify-content:space-between;margin-bottom:4px"><span>RAM</span><span>${memUsed} / ${memTotal} MB</span></div>
2134+
<div style="background:var(--bg-secondary);border-radius:4px;height:8px;overflow:hidden"><div style="background:var(--accent-1);height:100%;width:${memTotal > 0 ? (memUsed / memTotal * 100) : 0}%;border-radius:4px"></div></div>
2135+
</div>
2136+
<div>
2137+
<div style="display:flex;justify-content:space-between;margin-bottom:4px"><span>Disk</span><span>${diskUsed} / ${diskTotal} GB</span></div>
2138+
<div style="background:var(--bg-secondary);border-radius:4px;height:8px;overflow:hidden"><div style="background:var(--accent-3);height:100%;width:${diskTotal > 0 ? (diskUsed / diskTotal * 100) : 0}%;border-radius:4px"></div></div>
2139+
</div>
2140+
<div>
2141+
<div style="display:flex;justify-content:space-between;margin-bottom:4px"><span>CPU</span><span>${n.cpu || 0}%</span></div>
2142+
<div style="background:var(--bg-secondary);border-radius:4px;height:8px;overflow:hidden"><div style="background:var(--accent-2);height:100%;width:${n.cpu || 0}%;border-radius:4px"></div></div>
2143+
</div>
2144+
<div style="margin-top:4px"><span style="color:var(--text-secondary)">Allocation Count:</span> <strong>${n.allocation_count ?? '—'}</strong></div>
2145+
</div>
2146+
`;
2147+
}
2148+
2149+
const allocTbody = $a('#admin-node-alloc-tbody');
2150+
if (allocTbody) {
2151+
const allocs = allocData.allocations || [];
2152+
if (allocs.length === 0) {
2153+
allocTbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:16px;color:var(--text-secondary)">No allocations found.</td></tr>';
2154+
} else {
2155+
allocTbody.innerHTML = allocs.map(a => ahtml`
2156+
<tr>
2157+
<td>${a.id}</td>
2158+
<td><span class="server-detail-tag">${escapeHtml(a.ip)}</span></td>
2159+
<td>${a.port}</td>
2160+
<td>${a.alias ? escapeHtml(a.alias) : '—'}</td>
2161+
<td>${a.server ? `<span style="color:var(--accent-1)">${a.server}</span>` : '<span style="color:var(--text-secondary)">Free</span>'}</td>
2162+
</tr>
2163+
`).join('');
2164+
}
2165+
}
2166+
2167+
const serversTbody = $a('#admin-node-servers-tbody');
2168+
if (serversTbody) {
2169+
const servers = serversData.servers || [];
2170+
if (servers.length === 0) {
2171+
serversTbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:16px;color:var(--text-secondary)">No servers on this node.</td></tr>';
2172+
} else {
2173+
serversTbody.innerHTML = servers.map(s => ahtml`
2174+
<tr>
2175+
<td>${s.id}</td>
2176+
<td>${escapeHtml(s.name)}</td>
2177+
<td><span class="server-detail-tag">${escapeHtml(s.identifier)}</span></td>
2178+
<td><span class="server-detail-tag">${s.status || '—'}</span></td>
2179+
</tr>
2180+
`).join('');
2181+
}
2182+
}
2183+
} catch (err) {
2184+
el.innerHTML = ahtml`
2185+
<div class="page-header">
2186+
<a href="/admin/nodes" onclick="event.preventDefault();adminNavigateTo('nodes')" class="btn btn-ghost btn-sm" style="display:inline-flex;width:auto;margin-bottom:16px">
2187+
<i data-lucide="arrow-left" style="width:14px;height:14px"></i>
2188+
Back to Nodes
2189+
</a>
2190+
<h1 class="page-title">Error</h1>
2191+
<p style="color:var(--accent-red)">${err.message}</p>
2192+
</div>
2193+
`;
2194+
initIcons();
2195+
}
2196+
}
2197+
19442198
// ─── Common ─────────────────────────────────────────────
19452199
function formatDateWithTooltip(d) {
19462200
if (!d) return 'N/A';
@@ -1987,6 +2241,12 @@ window.addEventListener('popstate', () => {
19872241
if (tabBtn) tabBtn.click();
19882242
}, 50);
19892243
}
2244+
} else if (basePage === 'node' && param) {
2245+
const nid = parseInt(param, 10);
2246+
$a('#admin-page-node-detail')?.classList.add('active');
2247+
renderAdminNodeDetail(nid);
2248+
} else if (basePage === 'nodes') {
2249+
adminNavigateTo('nodes');
19902250
} else if (basePage === 'users') {
19912251
adminNavigateTo('users');
19922252
} else if (basePage === 'dashboard' || !basePage || basePage === 'login') {

public/js/app.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ function renderCookieBanner() {
5252
});
5353
}
5454

55-
const PTERO_URL = 'https://panel.zero-host.org';
55+
let PTERO_URL = '';
56+
57+
async function fetchConfig() {
58+
try {
59+
const res = await fetch('/api/config');
60+
const data = await res.json();
61+
if (data.pteroUrl) PTERO_URL = data.pteroUrl;
62+
} catch {}
63+
}
64+
65+
fetchConfig();
5666

5767
function openPyrodactylPanel(serverIdentifier) {
5868
const url = `${PTERO_URL}${serverIdentifier ? '/server/' + serverIdentifier : ''}`;

routes/admin.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import argon2 from 'argon2';
44
import jwt from 'jsonwebtoken';
55
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
66
import { query } from '../config/db.js';
7-
import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser, updatePteroServerBuild, getPergoServerIdsByEgg } from '../services/pyrodactyl.js';
7+
import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser, updatePteroServerBuild, getPergoServerIdsByEgg, getAllNodes, getNodeDetail, getNodeAllocations, getNodeServers } from '../services/pyrodactyl.js';
88
import { verifyCap } from '../config/cap.js';
99
import { logActivity } from '../services/activity.js';
1010
import { createNotification } from '../services/notification.js';
@@ -256,6 +256,62 @@ router.delete('/servers/:id', authenticateToken, requireAdmin, async (req, res)
256256
}
257257
});
258258

259+
// ─── Nodes ──────────────────────────────────────────────
260+
router.get('/nodes', authenticateToken, requireAdmin, async (req, res) => {
261+
try {
262+
const nodes = await getAllNodes();
263+
res.json({ nodes });
264+
} catch (err) {
265+
console.error('Admin nodes list error:', err.message);
266+
res.status(500).json({ error: 'Failed to fetch nodes' });
267+
}
268+
});
269+
270+
router.get('/nodes/:id', authenticateToken, requireAdmin, async (req, res) => {
271+
try {
272+
const nodeId = parseInt(req.params.id, 10);
273+
if (isNaN(nodeId)) {
274+
return res.status(400).json({ error: 'Invalid node ID' });
275+
}
276+
277+
const node = await getNodeDetail(nodeId);
278+
res.json({ node });
279+
} catch (err) {
280+
console.error('Admin node detail error:', err.message);
281+
res.status(500).json({ error: 'Failed to fetch node details' });
282+
}
283+
});
284+
285+
router.get('/nodes/:id/allocations', authenticateToken, requireAdmin, async (req, res) => {
286+
try {
287+
const nodeId = parseInt(req.params.id, 10);
288+
if (isNaN(nodeId)) {
289+
return res.status(400).json({ error: 'Invalid node ID' });
290+
}
291+
292+
const allocations = await getNodeAllocations(nodeId);
293+
res.json({ allocations });
294+
} catch (err) {
295+
console.error('Admin node allocations error:', err.message);
296+
res.status(500).json({ error: 'Failed to fetch node allocations' });
297+
}
298+
});
299+
300+
router.get('/nodes/:id/servers', authenticateToken, requireAdmin, async (req, res) => {
301+
try {
302+
const nodeId = parseInt(req.params.id, 10);
303+
if (isNaN(nodeId)) {
304+
return res.status(400).json({ error: 'Invalid node ID' });
305+
}
306+
307+
const servers = await getNodeServers(nodeId);
308+
res.json({ servers });
309+
} catch (err) {
310+
console.error('Admin node servers error:', err.message);
311+
res.status(500).json({ error: 'Failed to fetch node servers' });
312+
}
313+
});
314+
259315
// ─── Users ──────────────────────────────────────────────
260316
router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
261317
try {

0 commit comments

Comments
 (0)