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

Commit 98d44f7

Browse files
committed
feat: dynamic nest/egg management via admin panel
- Add nests + egg_resources DB tables (auto-migrated) - Remove hardcoded NEST_IDS from config - Add admin UI in Settings > Eggs Settings to manage nests/eggs - Fetch available nests from Pterodactyl panel, add/rename/delete - Configure per-egg CPU/RAM/Disk resource overrides - Server creation now respects per-egg resource overrides - Dynamic nest names in user-facing egg selector
1 parent 7db82ec commit 98d44f7

7 files changed

Lines changed: 653 additions & 22 deletions

File tree

config/migrate.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ const tables = {
5151
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
5252
],
5353
},
54+
nests: {
55+
columns: [
56+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
57+
{ name: 'ptero_nest_id', def: 'INT NOT NULL UNIQUE' },
58+
{ name: 'name', def: 'VARCHAR(255) NOT NULL' },
59+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
60+
],
61+
},
62+
egg_resources: {
63+
columns: [
64+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
65+
{ name: 'ptero_nest_id', def: 'INT NOT NULL' },
66+
{ name: 'ptero_egg_id', def: 'INT NOT NULL' },
67+
{ name: 'cpu_limit', def: 'INT DEFAULT NULL' },
68+
{ name: 'memory_limit', def: 'INT DEFAULT NULL' },
69+
{ name: 'disk_limit', def: 'INT DEFAULT NULL' },
70+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
71+
{ name: 'updated_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' },
72+
],
73+
},
5474

5575
};
5676

@@ -71,6 +91,7 @@ const constraints = [
7191
{ 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' },
7292
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_user (user_id)', name: 'idx_activity_user' },
7393
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_created (created_at)', name: 'idx_activity_created' },
94+
{ table: 'egg_resources', sql: 'ALTER TABLE egg_resources ADD UNIQUE INDEX idx_egg_resources_nest_egg (ptero_nest_id, ptero_egg_id)', name: 'idx_egg_resources_nest_egg' },
7495
];
7596

7697
export async function migrate() {

config/pyrodactyl.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,3 @@ export const FEATURE_LIMITS = {
1717
};
1818

1919
export const DEPLOY_LOCATIONS = [1];
20-
21-
export const NEST_IDS = [5, 6, 7];

public/js/admin.js

Lines changed: 470 additions & 7 deletions
Large diffs are not rendered by default.

public/js/app.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,16 +1247,15 @@ async function renderCreateServer() {
12471247
const data = await api('/servers/eggs');
12481248
eggCache = data.eggs;
12491249
const dropdown = $('#custom-egg-dropdown');
1250-
const nestLabels = { 5: 'Application', 6: 'Code', 7: 'Database' };
12511250
const grouped = {};
12521251
for (const e of data.eggs) {
1253-
if (!grouped[e.nestId]) grouped[e.nestId] = [];
1254-
grouped[e.nestId].push(e);
1252+
if (!grouped[e.nestId]) grouped[e.nestId] = { name: e.nestName, eggs: [] };
1253+
grouped[e.nestId].eggs.push(e);
12551254
}
12561255
let htmlStr = '';
12571256
for (const nestId of Object.keys(grouped).sort()) {
1258-
htmlStr += `<div class="custom-select-category">${nestLabels[nestId] || `Nest ${nestId}`}</div>`;
1259-
for (const e of grouped[nestId]) {
1257+
htmlStr += `<div class="custom-select-category">${grouped[nestId].name || `Nest ${nestId}`}</div>`;
1258+
for (const e of grouped[nestId].eggs) {
12601259
htmlStr += `<div class="custom-select-option" data-value="${e.eggId},${e.nestId}">${e.name}</div>`;
12611260
}
12621261
}

routes/admin.js

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import argon2 from 'argon2';
33
import jwt from 'jsonwebtoken';
44
import { authenticateToken } from '../middleware/auth.js';
55
import { query } from '../config/db.js';
6-
import { getAllServers, getServerById, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser } from '../services/pyrodactyl.js';
6+
import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, suspendPteroServer, unsuspendPteroServer, deletePteroServer, deletePteroUser } from '../services/pyrodactyl.js';
77
import { verifyCap } from '../config/cap.js';
88
import { logActivity } from '../services/activity.js';
99

@@ -482,4 +482,125 @@ router.get('/activity', authenticateToken, requireAdmin, async (req, res) => {
482482
}
483483
});
484484

485+
// ─── Settings: Nests ────────────────────────────────────
486+
router.get('/settings/nests', authenticateToken, requireAdmin, async (req, res) => {
487+
try {
488+
const nests = await query('SELECT * FROM nests ORDER BY name ASC');
489+
res.json({ nests });
490+
} catch (err) {
491+
res.status(500).json({ error: 'Failed to fetch nests' });
492+
}
493+
});
494+
495+
router.get('/settings/nests/available', authenticateToken, requireAdmin, async (req, res) => {
496+
try {
497+
const pteroNests = await getPteroNests();
498+
const localNests = await query('SELECT ptero_nest_id FROM nests');
499+
const localIds = new Set(localNests.map(n => n.ptero_nest_id));
500+
const available = pteroNests.filter(n => !localIds.has(n.id));
501+
res.json({ nests: available });
502+
} catch (err) {
503+
res.status(500).json({ error: 'Failed to fetch available nests: ' + err.message });
504+
}
505+
});
506+
507+
router.post('/settings/nests', authenticateToken, requireAdmin, async (req, res) => {
508+
try {
509+
const { pteroNestId, name } = req.body;
510+
if (!pteroNestId) return res.status(400).json({ error: 'Nest ID is required' });
511+
512+
const pteroNests = await getPteroNests();
513+
const nest = pteroNests.find(n => n.id === pteroNestId);
514+
if (!nest) return res.status(404).json({ error: 'Nest not found in panel' });
515+
516+
const displayName = name || nest.name;
517+
await query('INSERT INTO nests (ptero_nest_id, name) VALUES (?, ?)', [pteroNestId, displayName]);
518+
const [inserted] = await query('SELECT * FROM nests WHERE ptero_nest_id = ?', [pteroNestId]);
519+
res.status(201).json({ nest: inserted });
520+
} catch (err) {
521+
if (err.code === 'ER_DUP_ENTRY') {
522+
return res.status(409).json({ error: 'Nest already added' });
523+
}
524+
res.status(500).json({ error: 'Failed to add nest: ' + err.message });
525+
}
526+
});
527+
528+
router.put('/settings/nests/:id', authenticateToken, requireAdmin, async (req, res) => {
529+
try {
530+
const id = parseInt(req.params.id, 10);
531+
const { name } = req.body;
532+
if (!name) return res.status(400).json({ error: 'Name is required' });
533+
await query('UPDATE nests SET name = ? WHERE id = ?', [name, id]);
534+
res.json({ success: true });
535+
} catch (err) {
536+
res.status(500).json({ error: 'Failed to rename nest' });
537+
}
538+
});
539+
540+
router.delete('/settings/nests/:id', authenticateToken, requireAdmin, async (req, res) => {
541+
try {
542+
const id = parseInt(req.params.id, 10);
543+
const [nest] = await query('SELECT ptero_nest_id FROM nests WHERE id = ?', [id]);
544+
if (!nest) return res.status(404).json({ error: 'Nest not found' });
545+
await query('DELETE FROM egg_resources WHERE ptero_nest_id = ?', [nest.ptero_nest_id]);
546+
await query('DELETE FROM nests WHERE id = ?', [id]);
547+
res.json({ success: true });
548+
} catch (err) {
549+
res.status(500).json({ error: 'Failed to delete nest' });
550+
}
551+
});
552+
553+
// ─── Settings: Eggs ─────────────────────────────────────
554+
router.get('/settings/nests/:nestId/eggs', authenticateToken, requireAdmin, async (req, res) => {
555+
try {
556+
const nestId = parseInt(req.params.nestId, 10);
557+
const pteroEggs = await getPteroNestEggs(nestId);
558+
const resources = await query('SELECT * FROM egg_resources WHERE ptero_nest_id = ?', [nestId]);
559+
const resourceMap = {};
560+
for (const r of resources) {
561+
resourceMap[r.ptero_egg_id] = r;
562+
}
563+
const eggs = pteroEggs.map(e => ({
564+
...e,
565+
customResources: resourceMap[e.id] || null,
566+
}));
567+
res.json({ eggs });
568+
} catch (err) {
569+
res.status(500).json({ error: 'Failed to fetch eggs: ' + err.message });
570+
}
571+
});
572+
573+
router.get('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, async (req, res) => {
574+
try {
575+
const { nestId, eggId } = req.params;
576+
const [resources] = await query('SELECT * FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]);
577+
let eggDetails = null;
578+
try {
579+
eggDetails = await getEgg(nestId, eggId);
580+
} catch {}
581+
res.json({ resources: resources || null, egg: eggDetails });
582+
} catch (err) {
583+
res.status(500).json({ error: 'Failed to fetch egg settings' });
584+
}
585+
});
586+
587+
router.put('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, async (req, res) => {
588+
try {
589+
const { nestId, eggId } = req.params;
590+
const { cpu_limit, memory_limit, disk_limit } = req.body;
591+
592+
const [existing] = await query('SELECT id FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]);
593+
if (existing) {
594+
await query('UPDATE egg_resources SET cpu_limit = ?, memory_limit = ?, disk_limit = ? WHERE id = ?',
595+
[cpu_limit ?? null, memory_limit ?? null, disk_limit ?? null, existing.id]);
596+
} else {
597+
await query('INSERT INTO egg_resources (ptero_nest_id, ptero_egg_id, cpu_limit, memory_limit, disk_limit) VALUES (?, ?, ?, ?, ?)',
598+
[nestId, eggId, cpu_limit ?? null, memory_limit ?? null, disk_limit ?? null]);
599+
}
600+
res.json({ success: true });
601+
} catch (err) {
602+
res.status(500).json({ error: 'Failed to save egg settings' });
603+
}
604+
});
605+
485606
export default router;

routes/servers.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,20 @@ router.get('/list', authenticateToken, async (req, res) => {
7575

7676
router.get('/eggs', authenticateToken, async (req, res) => {
7777
try {
78-
const eggs = await getAllEggs();
78+
const dbNests = await query('SELECT ptero_nest_id, name FROM nests');
79+
const nestMap = {};
80+
for (const n of dbNests) {
81+
nestMap[n.ptero_nest_id] = n.name;
82+
}
83+
const nestIds = dbNests.map(n => n.ptero_nest_id);
84+
85+
const eggs = await getAllEggs(nestIds);
7986
const simplified = [];
8087

8188
for (const { nest, egg } of eggs) {
8289
simplified.push({
8390
nestId: nest,
91+
nestName: nestMap[nest] || `Nest ${nest}`,
8492
eggId: egg.id,
8593
name: egg.name,
8694
description: egg.description,
@@ -135,6 +143,15 @@ router.post('/create', authenticateToken, async (req, res) => {
135143
mergedEnv[v.env_variable] = v.default_value ?? '';
136144
}
137145

146+
// Check for per-egg custom resource overrides
147+
const [eggRes] = await query('SELECT * FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]);
148+
const customLimits = {};
149+
if (eggRes) {
150+
if (eggRes.cpu_limit != null) customLimits.cpu = eggRes.cpu_limit;
151+
if (eggRes.memory_limit != null) customLimits.memory = eggRes.memory_limit;
152+
if (eggRes.disk_limit != null) customLimits.disk = eggRes.disk_limit;
153+
}
154+
138155
const server = await createPteroServer({
139156
name,
140157
userId: pteroId,
@@ -143,6 +160,7 @@ router.post('/create', authenticateToken, async (req, res) => {
143160
environment: mergedEnv,
144161
startup: egg.startup,
145162
dockerImage,
163+
customLimits: Object.keys(customLimits).length > 0 ? customLimits : undefined,
146164
});
147165

148166
// Log server creation date

services/pyrodactyl.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PTERO_URL, PTERO_API_KEY, SERVER_LIMITS, FEATURE_LIMITS, DEPLOY_LOCATIONS, NEST_IDS } from '../config/pyrodactyl.js';
1+
import { PTERO_URL, PTERO_API_KEY, SERVER_LIMITS, FEATURE_LIMITS, DEPLOY_LOCATIONS } from '../config/pyrodactyl.js';
22

33
const FETCH_TIMEOUT = 15000;
44
const CACHE_TTL = 5 * 60 * 1000;
@@ -93,6 +93,16 @@ export async function getEgg(nestId, eggId) {
9393
return data.attributes;
9494
}
9595

96+
export async function getPteroNests() {
97+
const data = await pteroFetch('/nests?per_page=100');
98+
return data.data.map(n => n.attributes);
99+
}
100+
101+
export async function getPteroNestEggs(nestId) {
102+
const data = await pteroFetch(`/nests/${nestId}/eggs?per_page=100`);
103+
return data.data.map(e => e.attributes);
104+
}
105+
96106
export async function getAllServers() {
97107
let allServers = [];
98108
let page = 1;
@@ -193,15 +203,16 @@ export async function getServerById(serverId) {
193203
return server;
194204
}
195205

196-
export async function createPteroServer({ name, userId, eggId, nestId, environment, startup, dockerImage }) {
206+
export async function createPteroServer({ name, userId, eggId, nestId, environment, startup, dockerImage, customLimits }) {
207+
const limits = { ...SERVER_LIMITS, ...customLimits };
197208
const body = {
198209
name,
199210
user: userId,
200211
egg: eggId,
201212
docker_image: dockerImage,
202213
startup,
203214
environment,
204-
limits: SERVER_LIMITS,
215+
limits,
205216
feature_limits: FEATURE_LIMITS,
206217
deploy: {
207218
locations: DEPLOY_LOCATIONS,
@@ -280,12 +291,12 @@ export async function deletePteroUser(userId) {
280291
await pteroFetch(`/users/${userId}`, { method: 'DELETE' });
281292
}
282293

283-
export async function getAllEggs() {
294+
export async function getAllEggs(nestIds = []) {
284295
const eggs = [];
285296

286-
for (const nestId of NEST_IDS) {
297+
for (const nestId of nestIds) {
287298
try {
288-
const nestData = await pteroFetch(`/nests/${nestId}/eggs`);
299+
const nestData = await pteroFetch(`/nests/${nestId}/eggs?per_page=100`);
289300
for (const e of nestData.data) {
290301
try {
291302
const full = await getEgg(nestId, e.attributes.id);

0 commit comments

Comments
 (0)