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

Commit 66fa982

Browse files
committed
feat: tab system for node detail, settings tab with unavailable toggle, filter nodes on server creation
- Add node_settings table in migration (ptero_node_id, unavailable) - Add admin API routes: GET/PUT nodes/:id/settings, GET nodes/unavailable - Add initNodeTabs with Info/Allocations/Servers/Settings tabs - Add Settings tab with unavailable toggle switch - Filter unavailable nodes when creating servers (extract available locations) - createPteroServer now accepts optional deployLocations parameter - Add CSS for toggle switch component
1 parent b4d1548 commit 66fa982

6 files changed

Lines changed: 301 additions & 42 deletions

File tree

config/migrate.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ const tables = {
9898
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
9999
],
100100
},
101+
node_settings: {
102+
columns: [
103+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
104+
{ name: 'ptero_node_id', def: 'INT NOT NULL UNIQUE' },
105+
{ name: 'unavailable', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
106+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
107+
{ name: 'updated_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' },
108+
],
109+
},
101110

102111
};
103112

@@ -132,6 +141,7 @@ const constraints = [
132141
{ table: 'passkeys', sql: 'ALTER TABLE passkeys ADD INDEX idx_passkey_user (user_id)', name: 'idx_passkey_user' },
133142
{ table: 'passkeys', sql: 'ALTER TABLE passkeys ADD INDEX idx_passkey_credential (credential_id(255))', name: 'idx_passkey_credential' },
134143
{ table: 'passkeys', sql: 'ALTER TABLE passkeys ADD CONSTRAINT fk_passkey_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_passkey_user' },
144+
{ table: 'node_settings', sql: 'ALTER TABLE node_settings ADD INDEX idx_node_settings_node (ptero_node_id)', name: 'idx_node_settings_node' },
135145
];
136146

137147
export async function migrate() {

public/css/style.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2720,4 +2720,39 @@ tbody tr:hover {
27202720
opacity: 1;
27212721
}
27222722

2723+
/* ─── Toggle Switch ─── */
2724+
.toggle-switch input {
2725+
opacity: 0;
2726+
width: 0;
2727+
height: 0;
2728+
}
2729+
.toggle-switch .toggle-slider {
2730+
position: absolute;
2731+
cursor: pointer;
2732+
inset: 0;
2733+
background: var(--bg-secondary);
2734+
border: 1px solid var(--border);
2735+
border-radius: 24px;
2736+
transition: 0.2s;
2737+
}
2738+
.toggle-switch .toggle-slider::before {
2739+
content: '';
2740+
position: absolute;
2741+
height: 18px;
2742+
width: 18px;
2743+
left: 2px;
2744+
bottom: 2px;
2745+
background: var(--text-secondary);
2746+
border-radius: 50%;
2747+
transition: 0.2s;
2748+
}
2749+
.toggle-switch input:checked + .toggle-slider {
2750+
background: var(--accent-red);
2751+
border-color: var(--accent-red);
2752+
}
2753+
.toggle-switch input:checked + .toggle-slider::before {
2754+
transform: translateX(20px);
2755+
background: #fff;
2756+
}
2757+
27232758

public/js/admin.js

Lines changed: 179 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,61 +2046,99 @@ async function renderAdminNodeDetail(nodeId) {
20462046
<h1 class="page-title" style="margin-bottom:0">Node #${nodeId}</h1>
20472047
<p class="page-subtitle" id="admin-node-name">Loading...</p>
20482048
</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>
2049+
<div class="tabs" id="admin-node-tabs">
2050+
<button class="tab active" data-tab="info">Info</button>
2051+
<button class="tab" data-tab="allocations">Allocations</button>
2052+
<button class="tab" data-tab="servers">Servers</button>
2053+
<button class="tab" data-tab="settings">Settings</button>
2054+
<div class="tab-indicator" id="admin-node-tab-indicator"></div>
2055+
</div>
2056+
2057+
<div id="admin-node-tab-info" class="tab-content" style="display:block">
2058+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
2059+
<div class="card" style="padding:20px">
2060+
<h3 style="color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;margin-bottom:12px">Details</h3>
2061+
<div id="admin-node-details"><span class="spinner"></span></div>
2062+
</div>
2063+
<div class="card" style="padding:20px">
2064+
<h3 style="color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;margin-bottom:12px">Resources</h3>
2065+
<div id="admin-node-resources"><span class="spinner"></span></div>
2066+
</div>
20532067
</div>
2068+
</div>
2069+
2070+
<div id="admin-node-tab-allocations" class="tab-content" style="display:none">
20542071
<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>
2072+
<div class="table-container" style="margin:0">
2073+
<table>
2074+
<thead>
2075+
<tr>
2076+
<th>ID</th>
2077+
<th>IP</th>
2078+
<th>Port</th>
2079+
<th>Alias</th>
2080+
<th>Server</th>
2081+
</tr>
2082+
</thead>
2083+
<tbody id="admin-node-alloc-tbody">
2084+
<tr><td colspan="5" style="text-align:center;padding:16px;color:var(--text-secondary)"><span class="spinner"></span></td></tr>
2085+
</tbody>
2086+
</table>
2087+
</div>
20572088
</div>
20582089
</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>
2090+
2091+
<div id="admin-node-tab-servers" class="tab-content" style="display:none">
2092+
<div class="card" style="padding:20px">
2093+
<div class="table-container" style="margin:0">
2094+
<table>
2095+
<thead>
2096+
<tr>
2097+
<th>ID</th>
2098+
<th>Name</th>
2099+
<th>Identifier</th>
2100+
<th>Status</th>
2101+
</tr>
2102+
</thead>
2103+
<tbody id="admin-node-servers-tbody">
2104+
<tr><td colspan="4" style="text-align:center;padding:16px;color:var(--text-secondary)"><span class="spinner"></span></td></tr>
2105+
</tbody>
2106+
</table>
2107+
</div>
20762108
</div>
20772109
</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>
2110+
2111+
<div id="admin-node-tab-settings" class="tab-content" style="display:none">
2112+
<div class="card" style="padding:24px;max-width:480px">
2113+
<h2 class="card-title" style="margin-bottom:16px">Node Availability</h2>
2114+
<p style="color:var(--text-secondary);font-size:0.88rem;margin-bottom:20px">
2115+
When a node is set to <strong>Unavailable</strong>, new servers will not be created on it. Existing servers are not affected.
2116+
</p>
2117+
<div id="admin-node-settings-form">
2118+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
2119+
<label class="toggle-switch" style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
2120+
<input type="checkbox" id="node-unavailable-toggle" style="opacity:0;width:0;height:0">
2121+
<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>
2122+
</label>
2123+
<div>
2124+
<div id="node-unavailable-label" style="font-weight:600;font-size:0.9rem">Available</div>
2125+
<div style="color:var(--text-secondary);font-size:0.8rem">Node is accepting new server deployments</div>
2126+
</div>
2127+
</div>
2128+
<div id="node-settings-error" class="auth-error" style="margin-bottom:12px"></div>
2129+
<button class="btn btn-primary" id="btn-save-node-settings" style="width:auto">Save Settings</button>
2130+
</div>
20942131
</div>
20952132
</div>
20962133
`;
20972134
initIcons();
20982135

20992136
try {
2100-
const [nodeData, allocData, serversData] = await Promise.all([
2137+
const [nodeData, allocData, serversData, settingsData] = await Promise.all([
21012138
adminApi(`/nodes/${nodeId}`),
21022139
adminApi(`/nodes/${nodeId}/allocations`).catch(() => ({ allocations: [] })),
21032140
adminApi(`/nodes/${nodeId}/servers`).catch(() => ({ servers: [] })),
2141+
adminApi(`/nodes/${nodeId}/settings`).catch(() => ({ settings: { unavailable: false } })),
21042142
]);
21052143

21062144
const n = nodeData.node;
@@ -2180,6 +2218,39 @@ async function renderAdminNodeDetail(nodeId) {
21802218
`).join('');
21812219
}
21822220
}
2221+
2222+
const toggle = $a('#node-unavailable-toggle');
2223+
const label = $a('#node-unavailable-label');
2224+
const isUnavailable = !!settingsData.settings?.unavailable;
2225+
if (toggle) {
2226+
toggle.checked = isUnavailable;
2227+
updateNodeSettingsLabel(isUnavailable);
2228+
toggle.addEventListener('change', () => updateNodeSettingsLabel(toggle.checked));
2229+
}
2230+
2231+
const saveBtn = $a('#btn-save-node-settings');
2232+
if (saveBtn) {
2233+
saveBtn.addEventListener('click', async () => {
2234+
saveBtn.disabled = true;
2235+
saveBtn.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span> Saving...';
2236+
const errEl = $a('#node-settings-error');
2237+
if (errEl) errEl.classList.remove('show');
2238+
try {
2239+
await adminApi(`/nodes/${nodeId}/settings`, {
2240+
method: 'PUT',
2241+
body: JSON.stringify({ unavailable: toggle.checked }),
2242+
});
2243+
saveBtn.innerHTML = 'Saved!';
2244+
setTimeout(() => { saveBtn.disabled = false; saveBtn.innerHTML = 'Save Settings'; }, 1500);
2245+
} catch (err) {
2246+
if (errEl) { errEl.textContent = err.message; errEl.classList.add('show'); }
2247+
saveBtn.disabled = false;
2248+
saveBtn.innerHTML = 'Save Settings';
2249+
}
2250+
});
2251+
}
2252+
2253+
initNodeTabs(nodeId);
21832254
} catch (err) {
21842255
el.innerHTML = ahtml`
21852256
<div class="page-header">
@@ -2195,6 +2266,67 @@ async function renderAdminNodeDetail(nodeId) {
21952266
}
21962267
}
21972268

2269+
function updateNodeSettingsLabel(isUnavailable) {
2270+
const label = $a('#node-unavailable-label');
2271+
const desc = label?.nextElementSibling;
2272+
if (label) {
2273+
label.textContent = isUnavailable ? 'Unavailable' : 'Available';
2274+
label.style.color = isUnavailable ? 'var(--accent-red)' : '';
2275+
}
2276+
if (desc) {
2277+
desc.textContent = isUnavailable
2278+
? 'Node will be excluded from new server deployments'
2279+
: 'Node is accepting new server deployments';
2280+
}
2281+
}
2282+
2283+
function initNodeTabs(nodeId) {
2284+
const tabs = $a('#admin-node-tabs');
2285+
if (!tabs) return;
2286+
const indicator = $a('#admin-node-tab-indicator');
2287+
const btns = tabs.querySelectorAll('.tab');
2288+
2289+
function switchTab(tabBtn) {
2290+
const tabName = tabBtn.dataset.tab;
2291+
btns.forEach(t => t.classList.remove('active'));
2292+
tabBtn.classList.add('active');
2293+
2294+
document.querySelectorAll('#admin-page-node-detail .tab-content').forEach(c => c.style.display = 'none');
2295+
const target = $a('#admin-node-tab-' + tabName);
2296+
if (target) target.style.display = 'block';
2297+
2298+
if (indicator) {
2299+
indicator.style.left = tabBtn.offsetLeft + 'px';
2300+
indicator.style.width = tabBtn.offsetWidth + 'px';
2301+
}
2302+
2303+
history.pushState({ adminPage: 'node', nodeId, tab: tabName }, '', `/admin/node/${nodeId}/${tabName}`);
2304+
}
2305+
2306+
btns.forEach(btn => {
2307+
btn.addEventListener('click', () => {
2308+
if (btn.classList.contains('active')) return;
2309+
switchTab(btn);
2310+
});
2311+
});
2312+
2313+
const pathParts = window.location.pathname.replace('/admin/', '').split('/');
2314+
const tabFromUrl = pathParts[2];
2315+
if (tabFromUrl) {
2316+
const tabBtn = Array.from(btns).find(b => b.dataset.tab === tabFromUrl);
2317+
if (tabBtn) {
2318+
switchTab(tabBtn);
2319+
return;
2320+
}
2321+
}
2322+
2323+
const activeBtn = tabs.querySelector('.tab.active');
2324+
if (activeBtn && indicator) {
2325+
indicator.style.left = activeBtn.offsetLeft + 'px';
2326+
indicator.style.width = activeBtn.offsetWidth + 'px';
2327+
}
2328+
}
2329+
21982330
// ─── Common ─────────────────────────────────────────────
21992331
function formatDateWithTooltip(d) {
22002332
if (!d) return 'N/A';
@@ -2245,6 +2377,13 @@ window.addEventListener('popstate', () => {
22452377
const nid = parseInt(param, 10);
22462378
$a('#admin-page-node-detail')?.classList.add('active');
22472379
renderAdminNodeDetail(nid);
2380+
const tab = pathParts[2];
2381+
if (tab) {
2382+
setTimeout(() => {
2383+
const tabBtn = document.querySelector('#admin-node-tabs .tab[data-tab="' + tab + '"]');
2384+
if (tabBtn) tabBtn.click();
2385+
}, 50);
2386+
}
22482387
} else if (basePage === 'nodes') {
22492388
adminNavigateTo('nodes');
22502389
} else if (basePage === 'users') {

routes/admin.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,61 @@ router.get('/nodes/:id/servers', authenticateToken, requireAdmin, async (req, re
312312
}
313313
});
314314

315+
router.get('/nodes/:id/settings', authenticateToken, requireAdmin, async (req, res) => {
316+
try {
317+
const nodeId = parseInt(req.params.id, 10);
318+
if (isNaN(nodeId)) {
319+
return res.status(400).json({ error: 'Invalid node ID' });
320+
}
321+
322+
const rows = await query('SELECT * FROM node_settings WHERE ptero_node_id = ?', [nodeId]);
323+
if (rows.length === 0) {
324+
return res.json({ settings: { ptero_node_id: nodeId, unavailable: false } });
325+
}
326+
res.json({ settings: rows[0] });
327+
} catch (err) {
328+
console.error('Admin node settings error:', err.message);
329+
res.status(500).json({ error: 'Failed to fetch node settings' });
330+
}
331+
});
332+
333+
router.put('/nodes/:id/settings', authenticateToken, requireAdmin, async (req, res) => {
334+
try {
335+
const nodeId = parseInt(req.params.id, 10);
336+
if (isNaN(nodeId)) {
337+
return res.status(400).json({ error: 'Invalid node ID' });
338+
}
339+
340+
const { unavailable } = req.body;
341+
if (typeof unavailable !== 'boolean') {
342+
return res.status(400).json({ error: 'unavailable must be a boolean' });
343+
}
344+
345+
const existing = await query('SELECT id FROM node_settings WHERE ptero_node_id = ?', [nodeId]);
346+
if (existing.length === 0) {
347+
await query('INSERT INTO node_settings (ptero_node_id, unavailable) VALUES (?, ?)', [nodeId, unavailable ? 1 : 0]);
348+
} else {
349+
await query('UPDATE node_settings SET unavailable = ? WHERE ptero_node_id = ?', [unavailable ? 1 : 0, nodeId]);
350+
}
351+
352+
await logActivity(req.user.userId, 'admin_node_settings', `${unavailable ? 'Disabled' : 'Enabled'} node #${nodeId}`);
353+
res.json({ success: true, unavailable });
354+
} catch (err) {
355+
console.error('Admin node settings update error:', err.message);
356+
res.status(500).json({ error: 'Failed to update node settings' });
357+
}
358+
});
359+
360+
router.get('/nodes/unavailable', authenticateToken, requireAdmin, async (req, res) => {
361+
try {
362+
const rows = await query('SELECT ptero_node_id FROM node_settings WHERE unavailable = 1');
363+
res.json({ nodeIds: rows.map(r => r.ptero_node_id) });
364+
} catch (err) {
365+
console.error('Admin unavailable nodes error:', err.message);
366+
res.status(500).json({ error: 'Failed to fetch unavailable nodes' });
367+
}
368+
});
369+
315370
// ─── Users ──────────────────────────────────────────────
316371
router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
317372
try {

0 commit comments

Comments
 (0)