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

Commit 37592bb

Browse files
committed
feat: unavailable toggle for nests and eggs in admin panel + creation wizard
- Add unavailable column to nests and egg_resources tables - PUT /settings/nests/:id and PUT /settings/eggs/:nestId/:eggId now support unavailable field - GET /api/servers/nests returns unavailable status for nests and eggs - POST /api/servers/create rejects if selected nest or egg is unavailable - Admin panel: Status column with toggle switch for nests and eggs lists - Creation wizard: greyed out cards + Unavailable badge for unavailable nests/eggs
1 parent 66fa982 commit 37592bb

5 files changed

Lines changed: 101 additions & 15 deletions

File tree

config/migrate.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const tables = {
5858
{ name: 'name', def: 'VARCHAR(255) NOT NULL' },
5959
{ name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' },
6060
{ name: 'description', def: 'TEXT DEFAULT NULL' },
61+
{ name: 'unavailable', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
6162
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
6263
],
6364
},
@@ -70,6 +71,7 @@ const tables = {
7071
{ name: 'cpu_limit', def: 'INT DEFAULT NULL' },
7172
{ name: 'memory_limit', def: 'INT DEFAULT NULL' },
7273
{ name: 'disk_limit', def: 'INT DEFAULT NULL' },
74+
{ name: 'unavailable', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
7375
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
7476
{ name: 'updated_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' },
7577
],

public/js/admin.js

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,11 +1455,12 @@ async function renderAdminEggsSettings() {
14551455
<th>Name</th>
14561456
<th>Description</th>
14571457
<th>Panel Nest ID</th>
1458+
<th>Status</th>
14581459
<th>Actions</th>
14591460
</tr>
14601461
</thead>
14611462
<tbody id="admin-nests-tbody">
1462-
<tr><td colspan="6" style="text-align:center;padding:32px;color:var(--text-secondary)"><span class="spinner"></span> Loading...</td></tr>
1463+
<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--text-secondary)"><span class="spinner"></span> Loading...</td></tr>
14631464
</tbody>
14641465
</table>
14651466
</div>
@@ -1484,13 +1485,40 @@ async function renderAdminEggsSettings() {
14841485
<td><a href="/admin/settings/eggs/${n.ptero_nest_id}" onclick="event.preventDefault();adminNavigateTo('settings/eggs/${n.ptero_nest_id}')" style="font-weight:600;cursor:pointer">${n.name}</a></td>
14851486
<td style="color:var(--text-secondary);font-size:0.85rem;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${n.description || '—'}</td>
14861487
<td><span class="server-detail-tag">${n.ptero_nest_id}</span></td>
1488+
<td>
1489+
<label class="toggle-switch" style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
1490+
<input type="checkbox" class="nest-unavailable-toggle" data-id="${n.id}" ${n.unavailable ? 'checked' : ''} style="opacity:0;width:0;height:0">
1491+
<span class="toggle-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--bg-secondary);border:1px solid var(--border);border-radius:24px;transition:0.2s"></span>
1492+
</label>
1493+
<span class="nest-status-label" data-id="${n.id}" style="margin-left:8px;font-size:0.82rem;${n.unavailable ? 'color:var(--accent-red)' : 'color:var(--accent-green)'}">${n.unavailable ? 'Unavailable' : 'Available'}</span>
1494+
</td>
14871495
<td style="display:flex;gap:6px">
14881496
<button class="btn btn-ghost btn-sm btn-rename-nest" data-id="${n.id}" data-name="${n.name}" data-logo="${n.logo || ''}" data-description="${(n.description || '').replace(/"/g, '&quot;')}" style="width:auto">Edit</button>
14891497
<button class="btn btn-danger btn-sm btn-delete-nest" data-id="${n.id}" data-name="${n.name}" style="width:auto">Delete</button>
14901498
</td>
14911499
</tr>
14921500
`).join('');
14931501

1502+
tbody.querySelectorAll('.nest-unavailable-toggle').forEach(toggle => {
1503+
toggle.addEventListener('change', async () => {
1504+
const nestId = parseInt(toggle.dataset.id, 10);
1505+
const label = tbody.querySelector(`.nest-status-label[data-id="${nestId}"]`);
1506+
try {
1507+
await adminApi(`/settings/nests/${nestId}`, {
1508+
method: 'PUT',
1509+
body: JSON.stringify({ unavailable: toggle.checked }),
1510+
});
1511+
if (label) {
1512+
label.textContent = toggle.checked ? 'Unavailable' : 'Available';
1513+
label.style.color = toggle.checked ? 'var(--accent-red)' : 'var(--accent-green)';
1514+
}
1515+
} catch (err) {
1516+
toggle.checked = !toggle.checked;
1517+
showToast(err.message, 'error');
1518+
}
1519+
});
1520+
});
1521+
14941522
tbody.querySelectorAll('.btn-rename-nest').forEach(btn => {
14951523
btn.addEventListener('click', () => {
14961524
showRenameNestModal(btn.dataset.id, btn.dataset.name, btn.dataset.logo, btn.dataset.description);
@@ -1531,11 +1559,12 @@ async function renderAdminNestEggs(nestId) {
15311559
<th>Name</th>
15321560
<th>Description</th>
15331561
<th>Resources</th>
1562+
<th>Status</th>
15341563
<th>Actions</th>
15351564
</tr>
15361565
</thead>
15371566
<tbody id="admin-eggs-tbody">
1538-
<tr><td colspan="6" style="text-align:center;padding:32px;color:var(--text-secondary)"><span class="spinner"></span> Loading...</td></tr>
1567+
<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--text-secondary)"><span class="spinner"></span> Loading...</td></tr>
15391568
</tbody>
15401569
</table>
15411570
</div>
@@ -1556,19 +1585,48 @@ async function renderAdminNestEggs(nestId) {
15561585
const resStr = res
15571586
? `CPU: ${res.cpu_limit ?? 'default'}% / RAM: ${res.memory_limit ?? 'default'} MB / Disk: ${res.disk_limit ?? 'default'} MB`
15581587
: 'Defaults';
1588+
const isUnavailable = res?.unavailable;
15591589
return ahtml`
15601590
<tr>
15611591
<td>${e.id}</td>
15621592
<td>${res?.logo ? `<img src="${res.logo}" alt="" style="width:28px;height:28px;object-fit:contain;border-radius:4px">` : '<span style="color:var(--text-secondary);font-size:0.75rem">—</span>'}</td>
15631593
<td><strong>${e.name}</strong></td>
15641594
<td style="color:var(--text-secondary);font-size:0.85rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${e.description || '—'}</td>
15651595
<td><span class="server-detail-tag" style="font-size:0.75rem">${resStr}</span></td>
1596+
<td>
1597+
<label class="toggle-switch" style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
1598+
<input type="checkbox" class="egg-unavailable-toggle" data-nest="${nestId}" data-egg="${e.id}" ${isUnavailable ? 'checked' : ''} style="opacity:0;width:0;height:0">
1599+
<span class="toggle-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--bg-secondary);border:1px solid var(--border);border-radius:24px;transition:0.2s"></span>
1600+
</label>
1601+
<span class="egg-status-label" data-nest="${nestId}" data-egg="${e.id}" style="margin-left:8px;font-size:0.82rem;${isUnavailable ? 'color:var(--accent-red)' : 'color:var(--accent-green)'}">${isUnavailable ? 'Unavailable' : 'Available'}</span>
1602+
</td>
15661603
<td>
15671604
<a href="/admin/settings/eggs/${nestId}/${e.id}" onclick="event.preventDefault();adminNavigateTo('settings/eggs/${nestId}/${e.id}')" class="btn btn-ghost btn-sm">Configure</a>
15681605
</td>
15691606
</tr>
15701607
`;
15711608
}).join('');
1609+
1610+
tbody.querySelectorAll('.egg-unavailable-toggle').forEach(toggle => {
1611+
toggle.addEventListener('change', async () => {
1612+
const nId = parseInt(toggle.dataset.nest, 10);
1613+
const eId = parseInt(toggle.dataset.egg, 10);
1614+
const label = tbody.querySelector(`.egg-status-label[data-nest="${nId}"][data-egg="${eId}"]`);
1615+
try {
1616+
await adminApi(`/settings/eggs/${nId}/${eId}`, {
1617+
method: 'PUT',
1618+
body: JSON.stringify({ unavailable: toggle.checked }),
1619+
});
1620+
if (label) {
1621+
label.textContent = toggle.checked ? 'Unavailable' : 'Available';
1622+
label.style.color = toggle.checked ? 'var(--accent-red)' : 'var(--accent-green)';
1623+
}
1624+
} catch (err) {
1625+
toggle.checked = !toggle.checked;
1626+
showToast(err.message, 'error');
1627+
}
1628+
});
1629+
});
15721630
} catch (err) {
15731631
const tbody = $a('#admin-eggs-tbody');
15741632
if (tbody) tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;padding:32px;color:var(--accent-red)">Error: ${err.message}</td></tr>`;

public/js/app.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,15 +1807,19 @@ function renderNestStep() {
18071807
<div class="wizard-step-title">Choose a Nest</div>
18081808
<p class="wizard-step-desc">Select the type of server you want to create</p>
18091809
<div class="nest-grid">
1810-
${createState.nests.map(n => html`
1811-
<div class="nest-card ${createState.selectedNest?.pteroNestId === n.pteroNestId ? 'selected' : ''}" data-nest-id="${n.pteroNestId}">
1812-
<div class="nest-card-logo">
1813-
${n.logo ? html`<img src="${n.logo}" alt="" />` : html`<i data-lucide="box" style="width:40px;height:40px;color:var(--text-secondary)"></i>`}
1810+
${createState.nests.map(n => {
1811+
const unavail = n.unavailable;
1812+
return html`
1813+
<div class="nest-card ${unavail ? 'unavailable' : ''} ${createState.selectedNest?.pteroNestId === n.pteroNestId ? 'selected' : ''}" data-nest-id="${n.pteroNestId}" ${unavail ? 'style="opacity:0.5;pointer-events:none"' : ''}>
1814+
<div class="nest-card-logo">
1815+
${n.logo ? html`<img src="${n.logo}" alt="" />` : html`<i data-lucide="box" style="width:40px;height:40px;color:var(--text-secondary)"></i>`}
1816+
</div>
1817+
<div class="nest-card-name">${escapeHtml(n.name)}</div>
1818+
${n.description ? html`<div class="nest-card-desc">${escapeHtml(n.description)}</div>` : ''}
1819+
${unavail ? html`<div class="nest-card-badge" style="margin-top:8px;display:inline-block;padding:2px 10px;border-radius:12px;font-size:0.75rem;font-weight:600;background:rgba(239,68,68,0.15);color:var(--accent-red)">Unavailable</div>` : ''}
18141820
</div>
1815-
<div class="nest-card-name">${escapeHtml(n.name)}</div>
1816-
${n.description ? html`<div class="nest-card-desc">${escapeHtml(n.description)}</div>` : ''}
1817-
</div>
1818-
`).join('')}
1821+
`;
1822+
}).join('')}
18191823
</div>
18201824
<div class="wizard-actions">
18211825
<button class="btn btn-primary" id="wizard-next-btn" ${createState.selectedNest ? '' : 'disabled'}>
@@ -1834,14 +1838,16 @@ function renderEggStep() {
18341838
const eggCards = nest.eggs.map(e => {
18351839
const dockerImages = e.dockerImages || {};
18361840
const images = Object.entries(dockerImages);
1841+
const unavail = e.unavailable;
18371842
return html`
1838-
<div class="egg-card ${createState.selectedEgg?.eggId === e.eggId ? 'selected' : ''}" data-egg-id="${e.eggId}">
1843+
<div class="egg-card ${unavail ? 'unavailable' : ''} ${createState.selectedEgg?.eggId === e.eggId ? 'selected' : ''}" data-egg-id="${e.eggId}" ${unavail ? 'style="opacity:0.5;pointer-events:none"' : ''}>
18391844
<div class="egg-card-logo">
18401845
${e.logo ? html`<img src="${e.logo}" alt="" />` : html`<i data-lucide="egg" style="width:32px;height:32px;color:var(--text-secondary)"></i>`}
18411846
</div>
18421847
<div class="egg-card-info">
18431848
<div class="egg-card-name">${escapeHtml(e.name)}</div>
18441849
${e.description ? html`<div class="egg-card-desc">${escapeHtml(e.description)}</div>` : ''}
1850+
${unavail ? html`<div class="egg-card-badge" style="margin-top:6px;display:inline-block;padding:2px 10px;border-radius:12px;font-size:0.75rem;font-weight:600;background:rgba(239,68,68,0.15);color:var(--accent-red)">Unavailable</div>` : ''}
18451851
</div>
18461852
</div>
18471853
`;

routes/admin.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,14 +746,15 @@ router.post('/settings/nests', authenticateToken, requireAdmin, async (req, res)
746746
router.put('/settings/nests/:id', authenticateToken, requireAdmin, async (req, res) => {
747747
try {
748748
const id = parseInt(req.params.id, 10);
749-
const { name, logo, description } = req.body;
749+
const { name, logo, description, unavailable } = req.body;
750750
if (name !== undefined && (typeof name !== 'string' || name.trim().length === 0)) return res.status(400).json({ error: 'Name cannot be empty' });
751751
if (name && name.trim().length > 255) return res.status(400).json({ error: 'Name must be 255 characters or less' });
752752
const updates = [];
753753
const params = [];
754754
if (name !== undefined) { updates.push('name = ?'); params.push(name.trim()); }
755755
if (logo !== undefined) { updates.push('logo = ?'); params.push(logo || null); }
756756
if (description !== undefined) { updates.push('description = ?'); params.push(description || null); }
757+
if (unavailable !== undefined) { updates.push('unavailable = ?'); params.push(unavailable ? 1 : 0); }
757758
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
758759
params.push(id);
759760
await query(`UPDATE nests SET ${updates.join(', ')} WHERE id = ?`, params);
@@ -815,13 +816,14 @@ router.put('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, asy
815816
const nestId = parseInt(req.params.nestId, 10);
816817
const eggId = parseInt(req.params.eggId, 10);
817818
if (isNaN(nestId) || isNaN(eggId)) return res.status(400).json({ error: 'Invalid nest or egg ID' });
818-
const { cpu_limit, memory_limit, disk_limit, logo } = req.body;
819+
const { cpu_limit, memory_limit, disk_limit, logo, unavailable } = req.body;
819820

820821
const sanitized = {
821822
cpu_limit: (cpu_limit != null && !isNaN(Number(cpu_limit))) ? Number(cpu_limit) : null,
822823
memory_limit: (memory_limit != null && !isNaN(Number(memory_limit))) ? Number(memory_limit) : null,
823824
disk_limit: (disk_limit != null && !isNaN(Number(disk_limit))) ? Number(disk_limit) : null,
824825
logo: logo !== undefined ? (logo || null) : undefined,
826+
unavailable: unavailable !== undefined ? (unavailable ? 1 : 0) : undefined,
825827
};
826828

827829
if (sanitized.cpu_limit != null && sanitized.cpu_limit < 0) return res.status(400).json({ error: 'CPU limit cannot be negative' });
@@ -833,12 +835,14 @@ router.put('/settings/eggs/:nestId/:eggId', authenticateToken, requireAdmin, asy
833835
const updates = ['cpu_limit = ?', 'memory_limit = ?', 'disk_limit = ?'];
834836
const vals = [sanitized.cpu_limit, sanitized.memory_limit, sanitized.disk_limit];
835837
if (sanitized.logo !== undefined) { updates.push('logo = ?'); vals.push(sanitized.logo); }
838+
if (sanitized.unavailable !== undefined) { updates.push('unavailable = ?'); vals.push(sanitized.unavailable); }
836839
vals.push(existing.id);
837840
await query(`UPDATE egg_resources SET ${updates.join(', ')} WHERE id = ?`, vals);
838841
} else {
839842
const cols = ['ptero_nest_id', 'ptero_egg_id', 'cpu_limit', 'memory_limit', 'disk_limit'];
840843
const vals = [nestId, eggId, sanitized.cpu_limit, sanitized.memory_limit, sanitized.disk_limit];
841844
if (sanitized.logo !== undefined) { cols.push('logo'); vals.push(sanitized.logo); }
845+
if (sanitized.unavailable !== undefined) { cols.push('unavailable'); vals.push(sanitized.unavailable); }
842846
await query(`INSERT INTO egg_resources (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, vals);
843847
}
844848
res.json({ success: true });

routes/servers.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ router.get('/list', authenticateToken, async (req, res) => {
118118

119119
router.get('/nests', authenticateToken, async (req, res) => {
120120
try {
121-
const dbNests = await query('SELECT ptero_nest_id, name, logo, description FROM nests');
121+
const dbNests = await query('SELECT ptero_nest_id, name, logo, description, unavailable FROM nests');
122122
const nestIds = dbNests.map(n => n.ptero_nest_id);
123123

124124
const eggs = await getAllEggs(nestIds);
@@ -127,7 +127,7 @@ router.get('/nests', authenticateToken, async (req, res) => {
127127
nestMap[n.ptero_nest_id] = n;
128128
}
129129

130-
const eggResources = await query('SELECT ptero_nest_id, ptero_egg_id, logo, cpu_limit, memory_limit, disk_limit FROM egg_resources');
130+
const eggResources = await query('SELECT ptero_nest_id, ptero_egg_id, logo, cpu_limit, memory_limit, disk_limit, unavailable FROM egg_resources');
131131
const eggResMap = {};
132132
for (const r of eggResources) {
133133
eggResMap[`${r.ptero_nest_id}-${r.ptero_egg_id}`] = r;
@@ -148,6 +148,7 @@ router.get('/nests', authenticateToken, async (req, res) => {
148148
cpu_limit: res.cpu_limit ?? null,
149149
memory_limit: res.memory_limit ?? null,
150150
disk_limit: res.disk_limit ?? null,
151+
unavailable: !!res.unavailable,
151152
});
152153
}
153154

@@ -157,6 +158,7 @@ router.get('/nests', authenticateToken, async (req, res) => {
157158
name: n.name,
158159
logo: n.logo || null,
159160
description: n.description || '',
161+
unavailable: !!n.unavailable,
160162
eggs: nestEggs[n.ptero_nest_id] || [],
161163
});
162164
}
@@ -226,6 +228,20 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi
226228
return res.status(400).json({ error: 'Please complete the security check' });
227229
}
228230

231+
// Check if nest or egg is unavailable
232+
try {
233+
const [nestRow] = await query('SELECT unavailable FROM nests WHERE ptero_nest_id = ?', [nestId]);
234+
if (nestRow && nestRow.unavailable) {
235+
return res.status(403).json({ error: 'This nest is currently unavailable' });
236+
}
237+
const [eggRow] = await query('SELECT unavailable FROM egg_resources WHERE ptero_nest_id = ? AND ptero_egg_id = ?', [nestId, eggId]);
238+
if (eggRow && eggRow.unavailable) {
239+
return res.status(403).json({ error: 'This egg is currently unavailable' });
240+
}
241+
} catch (err) {
242+
console.warn('Failed to check nest/egg availability:', err.message);
243+
}
244+
229245
const existingServers = await getServersByUser(pteroId);
230246
if (existingServers.length >= 3) {
231247
return res.status(403).json({ error: 'Server limit reached. You can only create up to 3 servers.' });

0 commit comments

Comments
 (0)