Merge pull request 'Add services management, improve UI, fix sudoers' (#3) from fix/ssh-errors-and-ui-improvements into master
Reviewed-on: #3 Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #3.
This commit is contained in:
@@ -54,6 +54,8 @@ $router->get('/servers/:id/ssl', [ServerController::class, 'ssl'], [AuthMiddlewa
|
||||
$router->post('/servers/:id/ssl', [ServerController::class, 'sslAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/servers/:id/users', [ServerController::class, 'systemUsers'], [AuthMiddleware::class]);
|
||||
$router->post('/servers/:id/users', [ServerController::class, 'systemUsersAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/servers/:id/services', [ServerController::class, 'services'], [AuthMiddleware::class]);
|
||||
$router->post('/servers/:id/services', [ServerController::class, 'servicesAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/servers/:id/database', [ServerController::class, 'database'], [AuthMiddleware::class]);
|
||||
$router->post('/servers/:id/database', [ServerController::class, 'databaseAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->post('/servers/:id/nginx', [ServerController::class, 'nginxAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
|
||||
@@ -1475,6 +1475,87 @@ class ServerController
|
||||
$this->view->json(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function services(int $id): void
|
||||
{
|
||||
$server = $this->serverModel->findById($id);
|
||||
if (!$server) { $this->view->error(404); }
|
||||
|
||||
try {
|
||||
$ssh = new SSHService();
|
||||
if (!$ssh->connect($server)) {
|
||||
throw new \RuntimeException('Could not establish SSH connection');
|
||||
}
|
||||
$this->auditService->logCredentialAccess($id, 'View services');
|
||||
|
||||
$listResult = $ssh->exec("systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null | awk '{print \$1, \$3}'");
|
||||
$serviceNames = [];
|
||||
foreach (explode("\n", trim($listResult['output'] ?? '')) as $line) {
|
||||
if (preg_match('/^(\S+)\s+(\S+)/', $line, $m)) {
|
||||
$serviceNames[$m[1]] = $m[2] === 'active' ? 'active' : ($m[2] === 'inactive' ? 'inactive' : 'other');
|
||||
}
|
||||
}
|
||||
$enabledNames = [];
|
||||
$enabledResult = $ssh->exec("systemctl list-unit-files --type=service --no-pager --no-legend 2>/dev/null | awk '\$2==\"enabled\"{print \$1}'");
|
||||
foreach (explode("\n", trim($enabledResult['output'] ?? '')) as $line) {
|
||||
$enabledNames[trim($line)] = true;
|
||||
}
|
||||
$services = [];
|
||||
foreach ($serviceNames as $name => $status) {
|
||||
$services[] = [
|
||||
'name' => $name,
|
||||
'status' => $status,
|
||||
'enabled' => isset($enabledNames[$name]),
|
||||
];
|
||||
}
|
||||
$ssh->disconnect();
|
||||
|
||||
$this->view->display('servers.services', [
|
||||
'title' => 'Services - ' . $server['name'] . ' - ServerManager',
|
||||
'server' => $server,
|
||||
'services' => $services,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Session::setFlash('error', 'Failed to get services: ' . $e->getMessage());
|
||||
$this->view->redirect("/servers/{$id}");
|
||||
}
|
||||
}
|
||||
|
||||
public function servicesAction(int $id): void
|
||||
{
|
||||
$server = $this->serverModel->findById($id);
|
||||
if (!$server) {
|
||||
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
$service = $_POST['service'] ?? '';
|
||||
$allowed = ['start', 'stop', 'restart', 'reload', 'enable', 'disable'];
|
||||
|
||||
if (empty($service) || !in_array($action, $allowed, true)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Invalid action or service']);
|
||||
}
|
||||
|
||||
try {
|
||||
$ssh = new SSHService();
|
||||
if (!$ssh->connect($server)) {
|
||||
throw new \RuntimeException('Could not establish SSH connection');
|
||||
}
|
||||
$this->auditService->logServerAction($id, "service_{$action}", "{$service} {$action}");
|
||||
|
||||
$result = $ssh->exec("sudo systemctl {$action} {$service} 2>&1; echo EXIT:\$?");
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "Service '{$service}' {$action}ed." : "Failed to {$action} '{$service}'.",
|
||||
'output' => trim($result['output']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->view->json(['success' => false, 'message' => 'Failed: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function systemUsers(int $id): void
|
||||
{
|
||||
$server = $this->serverModel->findById($id);
|
||||
|
||||
140
views/servers/services.php
Normal file
140
views/servers/services.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
$server = $server ?? [];
|
||||
$services = $services ?? [];
|
||||
$perPage = 25;
|
||||
$totalPages = max(1, ceil(count($services) / $perPage));
|
||||
?>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="back-link"><a href="/servers/<?= $server['id'] ?>"><i class="fas fa-arrow-left"></i> Back to Server</a></div>
|
||||
<h1 class="page-title"><i class="fas fa-cogs"></i> Services</h1>
|
||||
<p class="page-subtitle"><?= htmlspecialchars($server['name']) ?> · <?= count($services) ?> services</p>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<button class="btn btn-secondary" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" id="servicesTable">
|
||||
<thead>
|
||||
<tr><th>Service</th><th style="width:100px">Status</th><th style="width:320px">Actions</th></tr>
|
||||
<tr class="pma-filter-row">
|
||||
<td><input type="text" id="filterName" class="form-input" placeholder="Filter service..." style="font-size:0.78rem;padding:0.25rem 0.4rem;width:100%;box-sizing:border-box" oninput="applyFilters()"></td>
|
||||
<td><select id="filterStatus" class="form-select" style="font-size:0.78rem;padding:0.25rem 0.4rem;width:100%;box-sizing:border-box" onchange="applyFilters()"><option value="">All</option><option value="active">active</option><option value="inactive">inactive</option><option value="other">other</option></select></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="servicesBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="servicesPagination" style="display:flex;gap:0.35rem;padding:0.75rem 1rem;border-top:1px solid var(--border-color);justify-content:center;flex-wrap:wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="serviceResultModal" class="modal">
|
||||
<div class="modal-dialog" style="max-width:600px">
|
||||
<div class="modal-header"><h3 id="srTitle">Result</h3><button class="modal-close" onclick="closeSrModal()">×</button></div>
|
||||
<div class="modal-body">
|
||||
<p id="srMessage"></p>
|
||||
<div id="srOutput" class="nginx-config-viewer nginx-test-output" style="display:none;margin-top:0.75rem"><pre><code id="srOutputText"></code></pre></div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeSrModal()">Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const allServices = <?= json_encode($services) ?>;
|
||||
const perPage = <?= $perPage ?>;
|
||||
|
||||
function applyFilters() {
|
||||
const nameQ = document.getElementById('filterName').value.toLowerCase();
|
||||
const statusQ = document.getElementById('filterStatus').value;
|
||||
const filtered = allServices.filter(s => {
|
||||
if (nameQ && !s.name.toLowerCase().includes(nameQ)) return false;
|
||||
if (statusQ && s.status !== statusQ) return false;
|
||||
return true;
|
||||
});
|
||||
renderPage(filtered, 1);
|
||||
}
|
||||
|
||||
function renderPage(list, page) {
|
||||
const totalPages = Math.ceil(list.length / perPage) || 1;
|
||||
const start = (page - 1) * perPage;
|
||||
const slice = list.slice(start, start + perPage);
|
||||
const tbody = document.getElementById('servicesBody');
|
||||
let html = '';
|
||||
slice.forEach(s => {
|
||||
const isActive = s.status === 'active';
|
||||
const isInactive = s.status === 'inactive';
|
||||
html += '<tr class="service-row">';
|
||||
html += '<td><code>' + escHtml(s.name) + '</code></td>';
|
||||
html += '<td><span class="badge ' + (isActive ? 'badge-success' : isInactive ? 'badge-secondary' : 'badge-warning') + '">' + s.status + '</span></td>';
|
||||
html += '<td class="actions-cell" style="white-space:nowrap">';
|
||||
if (!isActive) html += '<button class="btn btn-xs btn-success" onclick="serviceAction(\'start\',\'' + escHtml(s.name) + '\')" title="Start"><i class="fas fa-play"></i></button> ';
|
||||
if (!isInactive) html += '<button class="btn btn-xs btn-danger" onclick="serviceAction(\'stop\',\'' + escHtml(s.name) + '\')" title="Stop"><i class="fas fa-stop"></i></button> ';
|
||||
html += '<button class="btn btn-xs btn-warning" onclick="serviceAction(\'restart\',\'' + escHtml(s.name) + '\')" title="Restart"><i class="fas fa-redo"></i></button> ';
|
||||
html += '<button class="btn btn-xs btn-info" onclick="serviceAction(\'reload\',\'' + escHtml(s.name) + '\')" title="Reload"><i class="fas fa-sync-alt"></i></button> ';
|
||||
if (!s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'enable\',\'' + escHtml(s.name) + '\')" title="Enable"><i class="fas fa-power-off"></i></button> ';
|
||||
if (s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'disable\',\'' + escHtml(s.name) + '\')" title="Disable"><i class="fas fa-ban"></i></button> ';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
if (!html) html = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem">No services match the filter.</td></tr>';
|
||||
tbody.innerHTML = html;
|
||||
renderPagination(list, page);
|
||||
}
|
||||
|
||||
function renderPagination(list, page) {
|
||||
const totalPages = Math.ceil(list.length / perPage) || 1;
|
||||
const container = document.getElementById('servicesPagination');
|
||||
let html = '';
|
||||
for (let p = 1; p <= totalPages && p <= 15; p++) {
|
||||
html += '<button class="btn btn-xs ' + (p === page ? 'btn-primary' : 'btn-secondary') + '" onclick="renderPage(list,' + p + ')" data-list=\'' + JSON.stringify(list).replace(/'/g,"'") + '\'>' + p + '</button>';
|
||||
}
|
||||
if (totalPages > 15) html += '<span class="text-muted" style="font-size:0.8rem">...</span>';
|
||||
container.innerHTML = html + ' <span class="text-muted" style="font-size:0.8rem">' + list.length + ' services</span>';
|
||||
// Fix pagination onclick by rebinding
|
||||
container.querySelectorAll('button').forEach(btn => {
|
||||
const p = parseInt(btn.textContent);
|
||||
if (!isNaN(p)) {
|
||||
btn.onclick = function() { renderPage(list, p); };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function serviceAction(action, service) {
|
||||
fetch('/servers/<?= $server['id'] ?>/services', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action='+action+'&service='+encodeURIComponent(service)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
showToast(d.success?'success':'error', d.message);
|
||||
if (d.output) {
|
||||
document.getElementById('srTitle').textContent = d.success ? 'Success' : 'Error';
|
||||
document.getElementById('srMessage').textContent = d.message;
|
||||
document.getElementById('srOutputText').textContent = d.output;
|
||||
document.getElementById('srOutput').style.display = 'block';
|
||||
document.getElementById('serviceResultModal').style.display = 'flex';
|
||||
}
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed'));
|
||||
}
|
||||
|
||||
function closeSrModal() {
|
||||
document.getElementById('serviceResultModal').style.display = 'none';
|
||||
document.getElementById('srOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
||||
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSrModal(); });
|
||||
</script>
|
||||
@@ -172,6 +172,10 @@ $historicalMetrics = $historicalMetrics ?? [];
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>System Users</span>
|
||||
</a>
|
||||
<a href="/servers/<?= $server['id'] ?>/services" class="action-card">
|
||||
<i class="fas fa-cogs"></i>
|
||||
<span>Services</span>
|
||||
</a>
|
||||
<button class="action-card" onclick="restartService()">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span>Restart Service</span>
|
||||
|
||||
Reference in New Issue
Block a user