Skip to content

Commit aaf3fab

Browse files
committed
feat: add server power controls (start/stop/restart) from dashboard
1 parent 6075b0d commit aaf3fab

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

public/js/app.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ function openPyrodactylPanel(serverIdentifier) {
5252
window.open(url, '_blank');
5353
}
5454

55+
async function sendPowerCommand(identifier, signal) {
56+
const btn = event?.target;
57+
if (btn) {
58+
btn.disabled = true;
59+
btn.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>';
60+
}
61+
try {
62+
await api(`/servers/power/${identifier}`, { method: 'POST', body: JSON.stringify({ signal }) });
63+
} catch (err) {
64+
alert('Failed to send ' + signal + ' command: ' + err.message);
65+
} finally {
66+
if (btn) {
67+
btn.disabled = false;
68+
btn.textContent = signal.charAt(0).toUpperCase() + signal.slice(1);
69+
}
70+
}
71+
}
72+
5573
function $(sel) { return document.querySelector(sel); }
5674
function md5(s) {
5775
function F(x,y,z) { return (x & y) | (~x & z); }
@@ -1804,6 +1822,23 @@ async function renderServerDetail(serverId) {
18041822
</div>
18051823
18061824
<div id="server-tab-actions" class="tab-content" style="display:${activeTab === 'actions' ? 'block' : 'none'}">
1825+
<div class="action-card">
1826+
<div class="action-card-header">
1827+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
1828+
<div>
1829+
<h3 class="action-card-title">Power Controls</h3>
1830+
<p class="action-card-desc">
1831+
Current state: <strong>${s.currentState || 'Unknown'}</strong>
1832+
</p>
1833+
</div>
1834+
</div>
1835+
<div style="display:flex;gap:8px;flex-wrap:wrap">
1836+
<button class="btn btn-success btn-full" style="flex:1" onclick="sendPowerCommand('${s.identifier}','start')" ${s.currentState === 'running' ? 'disabled' : ''}>Start</button>
1837+
<button class="btn btn-warning btn-full" style="flex:1" onclick="sendPowerCommand('${s.identifier}','stop')" ${s.currentState !== 'running' ? 'disabled' : ''}>Stop</button>
1838+
<button class="btn btn-ghost btn-full" style="flex:1" onclick="sendPowerCommand('${s.identifier}','restart')" ${s.currentState !== 'running' ? 'disabled' : ''}>Restart</button>
1839+
</div>
1840+
</div>
1841+
18071842
<div class="action-card">
18081843
<div class="action-card-header">
18091844
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/></svg>

routes/servers.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,27 @@ router.get('/details/:id', authenticateToken, async (req, res) => {
186186
const server = await getServerById(serverId);
187187
const meta = await query('SELECT * FROM server_meta WHERE ptero_server_id = ?', [serverId]);
188188
server.serverMeta = meta.length > 0 ? meta[0] : null;
189+
190+
const users = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [req.user.userId]);
191+
const clientApiKey = users[0]?.ptero_client_api_key;
192+
if (clientApiKey) {
193+
try {
194+
const pteroRes = await fetch(`${PTERO_URL}/api/client/servers/${server.identifier}/resources`, {
195+
headers: {
196+
'Authorization': `Bearer ${clientApiKey}`,
197+
'Accept': 'application/json',
198+
},
199+
signal: AbortSignal.timeout(8000),
200+
});
201+
if (pteroRes.ok) {
202+
const data = await pteroRes.json();
203+
server.currentState = data.attributes.current_state;
204+
}
205+
} catch {
206+
server.currentState = null;
207+
}
208+
}
209+
189210
res.json({ server });
190211
} catch (err) {
191212
console.error('Get server error:', err.message);
@@ -390,6 +411,40 @@ router.get('/resources/:identifier', authenticateToken, async (req, res) => {
390411
}
391412
});
392413

414+
router.post('/power/:identifier', authenticateToken, async (req, res) => {
415+
try {
416+
const { identifier } = req.params;
417+
const { signal } = req.body;
418+
const userId = req.user.userId;
419+
420+
const users = await query('SELECT ptero_client_api_key FROM users WHERE id = ?', [userId]);
421+
if (!users[0]?.ptero_client_api_key) {
422+
return res.status(400).json({ error: 'No Pyrodactyl API key configured' });
423+
}
424+
425+
const apiKey = users[0].ptero_client_api_key;
426+
const pteroRes = await fetch(`${PTERO_URL}/api/client/servers/${identifier}/power`, {
427+
method: 'POST',
428+
headers: {
429+
'Authorization': `Bearer ${apiKey}`,
430+
'Accept': 'application/json',
431+
'Content-Type': 'application/json',
432+
},
433+
body: JSON.stringify({ signal }),
434+
signal: AbortSignal.timeout(10000),
435+
});
436+
437+
if (!pteroRes.ok) {
438+
return res.status(502).json({ error: 'Failed to send power command' });
439+
}
440+
441+
res.json({ success: true });
442+
} catch (err) {
443+
console.error('Power command error:', err.message);
444+
res.status(500).json({ error: 'Failed to send power command' });
445+
}
446+
});
447+
393448
router.put('/client-api-key', authenticateToken, async (req, res) => {
394449
try {
395450
const { apiKey } = req.body;

0 commit comments

Comments
 (0)