Skip to content

Commit f87b921

Browse files
committed
feat(service-controls): implement service start/stop/restart functionality in admin dashboard
1 parent dd2854f commit f87b921

File tree

3 files changed

+878
-14
lines changed

3 files changed

+878
-14
lines changed

bin/lib/admin.js

Lines changed: 277 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import http from 'http';
55
import { spawn } from 'node:child_process';
66
import { WebSocketServer, WebSocket } from 'ws';
77
import { getLogsForAPI, LogFileWatcher } from './logs.js';
8+
import { startService, stopService, restartService, getServiceStatus, getAllServiceStatuses, validateServiceCanRun } from './service-manager.js';
89

910
// ws helper
1011
function sendWebSocketMessage(ws, message) {
@@ -98,12 +99,28 @@ async function checkServiceStatus(service) {
9899
// Check status of all services
99100
export async function getServicesStatus(services) {
100101
const statusPromises = services.map(async (service) => {
101-
const status = await checkServiceStatus(service);
102-
return {
102+
const healthStatus = await checkServiceStatus(service);
103+
const processStatus = getServiceStatus(service.name);
104+
105+
// Combine health check and process management status
106+
const combinedStatus = {
103107
...service,
104-
...status,
108+
...healthStatus,
109+
processStatus: processStatus.status,
110+
pid: processStatus.pid,
111+
uptime: processStatus.uptime,
112+
processStartTime: processStatus.startTime,
105113
lastChecked: new Date().toISOString()
106114
};
115+
116+
// Override status if we know the process is managed locally
117+
if (processStatus.status === 'running' && healthStatus.status === 'down') {
118+
combinedStatus.status = 'starting'; // Process running but not responding yet
119+
} else if (processStatus.status === 'stopped' && healthStatus.status === 'up') {
120+
combinedStatus.status = 'external'; // Running externally (not managed by us)
121+
}
122+
123+
return combinedStatus;
107124
});
108125

109126
return Promise.all(statusPromises);
@@ -182,11 +199,89 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
182199
.log-level { font-weight:600; margin-right:8px; }
183200
.logs-empty { text-align:center; color:#6b7280; padding:60px 20px; }
184201
202+
/* Service control buttons */
203+
.service-controls { display:flex; gap:4px; flex-wrap:wrap; }
204+
.btn-sm { padding:3px 8px; font-size:0.7rem; border:none; border-radius:3px; cursor:pointer; color:#fff; transition:opacity 0.2s; }
205+
.btn-sm:disabled { opacity:0.5; cursor:not-allowed; }
206+
.btn-start { background:#10b981; }
207+
.btn-start:hover:not(:disabled) { background:#059669; }
208+
.btn-stop { background:#ef4444; }
209+
.btn-stop:hover:not(:disabled) { background:#dc2626; }
210+
.btn-restart { background:#f59e0b; }
211+
.btn-restart:hover:not(:disabled) { background:#d97706; }
212+
213+
/* Additional status styles */
214+
.status-badge.starting { background:#f59e0b; color:#fff; }
215+
.status-badge.external { background:#8b5cf6; color:#fff; }
216+
.dot.starting { background:#f59e0b; }
217+
.dot.external { background:#8b5cf6; }
218+
185219
@media (max-width: 920px) { .layout { flex-direction:column; } .sidebar { width:100%; flex-direction:row; flex-wrap:wrap; } .service-list { display:flex; flex-wrap:wrap; gap:8px; } .service-list li { margin:0; } }
186220
</style>
187221
<script>
188-
const REFRESH_MS = ${refreshInterval};
189-
let nextRefreshLabel;
222+
var REFRESH_MS = ${refreshInterval};
223+
var nextRefreshLabel;
224+
225+
function startService(serviceName) {
226+
fetch('/api/services/start', {
227+
method: 'POST',
228+
headers: { 'Content-Type': 'application/json' },
229+
body: JSON.stringify({ serviceName: serviceName })
230+
})
231+
.then(function(response) { return response.json(); })
232+
.then(function(result) {
233+
if (result.error) {
234+
alert('Failed to start ' + serviceName + ': ' + result.error);
235+
} else {
236+
alert('Started ' + serviceName + ' successfully');
237+
setTimeout(fetchStatus, 1000);
238+
}
239+
})
240+
.catch(function(error) {
241+
alert('Error starting ' + serviceName + ': ' + error.message);
242+
});
243+
}
244+
245+
function stopService(serviceName) {
246+
fetch('/api/services/stop', {
247+
method: 'POST',
248+
headers: { 'Content-Type': 'application/json' },
249+
body: JSON.stringify({ serviceName: serviceName })
250+
})
251+
.then(function(response) { return response.json(); })
252+
.then(function(result) {
253+
if (result.error) {
254+
alert('Failed to stop ' + serviceName + ': ' + result.error);
255+
} else {
256+
alert('Stopped ' + serviceName + ' successfully');
257+
setTimeout(fetchStatus, 1000);
258+
}
259+
})
260+
.catch(function(error) {
261+
alert('Error stopping ' + serviceName + ': ' + error.message);
262+
});
263+
}
264+
265+
function restartService(serviceName) {
266+
fetch('/api/services/restart', {
267+
method: 'POST',
268+
headers: { 'Content-Type': 'application/json' },
269+
body: JSON.stringify({ serviceName: serviceName })
270+
})
271+
.then(function(response) { return response.json(); })
272+
.then(function(result) {
273+
if (result.error) {
274+
alert('Failed to restart ' + serviceName + ': ' + result.error);
275+
} else {
276+
alert('Restarted ' + serviceName + ' successfully');
277+
setTimeout(fetchStatus, 1000);
278+
}
279+
})
280+
.catch(function(error) {
281+
alert('Error restarting ' + serviceName + ': ' + error.message);
282+
});
283+
}
284+
190285
function scheduleCountdown() {
191286
const el = document.querySelector('.refresh');
192287
if (!el) return;
@@ -223,17 +318,24 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
223318
if (meta) meta.textContent = 'Auto-refresh: ' + (REFRESH_MS/1000).toFixed(1) + 's | Services: ' + services.length;
224319
225320
// Build rows HTML
226-
const rows = services.map(s => (
227-
'<tr id="row-'+s.name+'">'
321+
const rows = services.map(s => {
322+
const processInfo = s.processStatus && s.processStatus !== 'stopped' ?
323+
'<div style="font-size:0.6rem;color:#6b7280;margin-top:2px;">PID: ' + (s.pid || 'N/A') + ' | Uptime: ' + (s.uptime ? Math.floor(s.uptime / 60) + 'm' : '0m') + '</div>' : '';
324+
325+
return '<tr id="row-'+s.name+'">'
228326
+ '<td><strong>'+s.name+'</strong></td>'
229327
+ '<td>'+s.type+'</td>'
230328
+ '<td>'+s.port+'</td>'
231-
+ '<td><span class="status-badge '+s.status+'"><span class="dot '+s.status+'" style="box-shadow:none;width:8px;height:8px;"></span>'+s.status.toUpperCase()+'</span></td>'
329+
+ '<td><span class="status-badge '+s.status+'"><span class="dot '+s.status+'" style="box-shadow:none;width:8px;height:8px;"></span>'+s.status.toUpperCase()+'</span>' + processInfo + '</td>'
232330
+ '<td><span class="path">'+(s.path || 'services/'+s.name)+'</span></td>'
233-
+ '<td>'+new Date(s.lastChecked).toLocaleTimeString()+'</td>'
331+
+ '<td><div class="service-controls">'
332+
+ '<button class="btn-sm btn-start" data-service="'+s.name+'" '+(s.processStatus === 'running' ? 'disabled' : '')+'>Start</button>'
333+
+ '<button class="btn-sm btn-stop" data-service="'+s.name+'" '+(s.processStatus === 'stopped' ? 'disabled' : '')+'>Stop</button>'
334+
+ '<button class="btn-sm btn-restart" data-service="'+s.name+'">Restart</button>'
335+
+ '</div></td>'
234336
+ '<td><a href="http://localhost:'+s.port+'" target="_blank">Open</a></td>'
235-
+ '</tr>'
236-
)).join('');
337+
+ '</tr>';
338+
}).join('');
237339
tbody.innerHTML = rows;
238340
239341
// Update sidebar dots
@@ -264,6 +366,22 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
264366
scheduleCountdown();
265367
setTimeout(fetchStatus, REFRESH_MS); // initial schedule
266368
initializeLogs();
369+
370+
// Event delegation for service control buttons
371+
document.addEventListener('click', function(event) {
372+
var target = event.target;
373+
var serviceName = target.getAttribute('data-service');
374+
375+
if (!serviceName || target.disabled) return;
376+
377+
if (target.classList.contains('btn-start')) {
378+
startService(serviceName);
379+
} else if (target.classList.contains('btn-stop')) {
380+
stopService(serviceName);
381+
} else if (target.classList.contains('btn-restart')) {
382+
restartService(serviceName);
383+
}
384+
});
267385
});
268386
269387
// Logs functionality (auto-stream)
@@ -470,6 +588,33 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
470588
}
471589
}, delay);
472590
}
591+
592+
function showServiceAction(message, type) {
593+
// Create or update action message element
594+
let actionMsg = document.querySelector('#service-action-msg');
595+
if (!actionMsg) {
596+
actionMsg = document.createElement('div');
597+
actionMsg.id = 'service-action-msg';
598+
actionMsg.style.cssText = 'position:fixed;top:70px;right:20px;padding:10px 15px;border-radius:4px;font-weight:500;z-index:1000;transition:opacity 0.3s;';
599+
document.body.appendChild(actionMsg);
600+
}
601+
602+
// Set message and styling
603+
actionMsg.textContent = message;
604+
actionMsg.style.backgroundColor = type === 'success' ? '#10b981' : '#ef4444';
605+
actionMsg.style.color = 'white';
606+
actionMsg.style.opacity = '1';
607+
608+
// Auto-hide after 3 seconds
609+
setTimeout(() => {
610+
actionMsg.style.opacity = '0';
611+
setTimeout(() => {
612+
if (actionMsg.parentNode) {
613+
actionMsg.parentNode.removeChild(actionMsg);
614+
}
615+
}, 300);
616+
}, 3000);
617+
}
473618
</script>
474619
</head>
475620
<body>
@@ -508,7 +653,7 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
508653
<th>Port</th>
509654
<th>Status</th>
510655
<th>Path</th>
511-
<th>Last Checked</th>
656+
<th>Controls</th>
512657
<th>Link</th>
513658
</tr>
514659
</thead>
@@ -518,9 +663,33 @@ function generateDashboardHTML(servicesWithStatus, refreshInterval = 5000) {
518663
<td><strong>${s.name}</strong></td>
519664
<td>${s.type}</td>
520665
<td>${s.port}</td>
521-
<td><span class="status-badge ${s.status}"><span class="dot ${s.status}" style="box-shadow:none;width:8px;height:8px;"></span>${s.status.toUpperCase()}</span></td>
666+
<td>
667+
<span class="status-badge ${s.status}">
668+
<span class="dot ${s.status}" style="box-shadow:none;width:8px;height:8px;"></span>
669+
${s.status.toUpperCase()}
670+
</span>
671+
${s.processStatus && s.processStatus !== 'stopped' ? `
672+
<div style="font-size:0.6rem;color:#6b7280;margin-top:2px;">
673+
PID: ${s.pid || 'N/A'} | Uptime: ${s.uptime ? Math.floor(s.uptime / 60) + 'm' : '0m'}
674+
</div>
675+
` : ''}
676+
</td>
522677
<td><span class="path">${s.path || `services/${s.name}`}</span></td>
523-
<td>${new Date(s.lastChecked).toLocaleTimeString()}</td>
678+
<td>
679+
<div class="service-controls">
680+
<button class="btn-sm btn-start" data-service="${s.name}"
681+
${s.processStatus === 'running' ? 'disabled' : ''}>
682+
Start
683+
</button>
684+
<button class="btn-sm btn-stop" data-service="${s.name}"
685+
${s.processStatus === 'stopped' ? 'disabled' : ''}>
686+
Stop
687+
</button>
688+
<button class="btn-sm btn-restart" data-service="${s.name}">
689+
Restart
690+
</button>
691+
</div>
692+
</td>
524693
<td><a href="http://localhost:${s.port}" target="_blank">Open</a></td>
525694
</tr>`).join('')}
526695
</tbody>
@@ -646,6 +815,100 @@ export async function startAdminDashboard(options = {}) {
646815
return;
647816
}
648817

818+
// Service management endpoints
819+
if (url.pathname === '/api/services/start' && req.method === 'POST') {
820+
try {
821+
let body = '';
822+
req.on('data', chunk => body += chunk);
823+
req.on('end', async () => {
824+
const { serviceName } = JSON.parse(body);
825+
const service = cfg.services.find(s => s.name === serviceName);
826+
827+
if (!service) {
828+
res.writeHead(404, { 'Content-Type': 'application/json' });
829+
res.end(JSON.stringify({ error: 'Service not found' }));
830+
return;
831+
}
832+
833+
try {
834+
const result = await startService(service);
835+
res.writeHead(200, { 'Content-Type': 'application/json' });
836+
res.end(JSON.stringify(result));
837+
} catch (error) {
838+
res.writeHead(500, { 'Content-Type': 'application/json' });
839+
res.end(JSON.stringify({ error: error.message }));
840+
}
841+
});
842+
} catch (error) {
843+
res.writeHead(500, { 'Content-Type': 'application/json' });
844+
res.end(JSON.stringify({ error: 'Failed to parse request' }));
845+
}
846+
return;
847+
}
848+
849+
if (url.pathname === '/api/services/stop' && req.method === 'POST') {
850+
try {
851+
let body = '';
852+
req.on('data', chunk => body += chunk);
853+
req.on('end', async () => {
854+
const { serviceName } = JSON.parse(body);
855+
856+
try {
857+
const result = await stopService(serviceName);
858+
res.writeHead(200, { 'Content-Type': 'application/json' });
859+
res.end(JSON.stringify(result));
860+
} catch (error) {
861+
res.writeHead(500, { 'Content-Type': 'application/json' });
862+
res.end(JSON.stringify({ error: error.message }));
863+
}
864+
});
865+
} catch (error) {
866+
res.writeHead(500, { 'Content-Type': 'application/json' });
867+
res.end(JSON.stringify({ error: 'Failed to parse request' }));
868+
}
869+
return;
870+
}
871+
872+
if (url.pathname === '/api/services/restart' && req.method === 'POST') {
873+
try {
874+
let body = '';
875+
req.on('data', chunk => body += chunk);
876+
req.on('end', async () => {
877+
const { serviceName } = JSON.parse(body);
878+
const service = cfg.services.find(s => s.name === serviceName);
879+
880+
if (!service) {
881+
res.writeHead(404, { 'Content-Type': 'application/json' });
882+
res.end(JSON.stringify({ error: 'Service not found' }));
883+
return;
884+
}
885+
886+
try {
887+
const result = await restartService(service);
888+
res.writeHead(200, { 'Content-Type': 'application/json' });
889+
res.end(JSON.stringify(result));
890+
} catch (error) {
891+
res.writeHead(500, { 'Content-Type': 'application/json' });
892+
res.end(JSON.stringify({ error: error.message }));
893+
}
894+
});
895+
} catch (error) {
896+
res.writeHead(500, { 'Content-Type': 'application/json' });
897+
res.end(JSON.stringify({ error: 'Failed to parse request' }));
898+
}
899+
return;
900+
}
901+
902+
if (url.pathname === '/api/services/status') {
903+
const statuses = getAllServiceStatuses();
904+
res.writeHead(200, {
905+
'Content-Type': 'application/json',
906+
'Access-Control-Allow-Origin': '*'
907+
});
908+
res.end(JSON.stringify(statuses, null, 2));
909+
return;
910+
}
911+
649912
// Serve dashboard HTML
650913
const servicesWithStatus = await getServicesStatus(cfg.services);
651914
const html = generateDashboardHTML(servicesWithStatus, refreshInterval);

0 commit comments

Comments
 (0)