From c63c5ffcfdb89ad6a7e5f8a4a549588f6cd4e69b Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 13 Jun 2026 16:12:36 -0400 Subject: [PATCH] feat: per-server notification thresholds with alert on agent metrics push --- AGENTS.md | 2 +- database/migrations/012_server_thresholds.sql | 7 ++ routes/web.php | 1 + src/Controllers/ApiController.php | 33 +++++++ src/Controllers/ServerController.php | 20 ++++ src/Core/Database.php | 5 + src/Models/Server.php | 22 +++++ views/servers/show.php | 92 +++++++++++++++++++ 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 database/migrations/012_server_thresholds.sql diff --git a/AGENTS.md b/AGENTS.md index 9b65dce..78aacfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Repo-specific guidance for OpenCode sessions. Verified against code. ## Database - Migrations run manually: `mysql servermanager < database/migrations/NNN_name.sql` -- 8 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks`, `008_notification_reads` +- 12 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks`, `008_notification_reads`, `009_remove_notification_obsolete_columns`, `010_user_fcm_tokens`, `011_cleanup_unused_tables`, `012_server_thresholds` - Users table has `role` ENUM: `super_admin`, `admin`, `operator` - `api_token` column on users for Bearer token auth - `server_permissions` table stores required SSH permissions per server (FK to `servers.id` CASCADE) diff --git a/database/migrations/012_server_thresholds.sql b/database/migrations/012_server_thresholds.sql new file mode 100644 index 0000000..44d1862 --- /dev/null +++ b/database/migrations/012_server_thresholds.sql @@ -0,0 +1,7 @@ +ALTER TABLE `servers` + ADD COLUMN `threshold_cpu_warning` DECIMAL(5,2) DEFAULT 80.00 AFTER `agent_last_seen_at`, + ADD COLUMN `threshold_cpu_critical` DECIMAL(5,2) DEFAULT 95.00 AFTER `threshold_cpu_warning`, + ADD COLUMN `threshold_ram_warning` DECIMAL(5,2) DEFAULT 80.00 AFTER `threshold_cpu_critical`, + ADD COLUMN `threshold_ram_critical` DECIMAL(5,2) DEFAULT 95.00 AFTER `threshold_ram_warning`, + ADD COLUMN `threshold_disk_warning` DECIMAL(5,2) DEFAULT 85.00 AFTER `threshold_ram_critical`, + ADD COLUMN `threshold_disk_critical` DECIMAL(5,2) DEFAULT 95.00 AFTER `threshold_disk_warning`; diff --git a/routes/web.php b/routes/web.php index e30e1d1..2113309 100755 --- a/routes/web.php +++ b/routes/web.php @@ -65,6 +65,7 @@ $router->post('/servers/:id/database', [ServerController::class, 'databaseAction $router->post('/servers/:id/nginx', [ServerController::class, 'nginxAction'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->get('/servers/:id/logs', [ServerController::class, 'logs'], [AuthMiddleware::class]); $router->get('/servers/:id/metrics', [ServerController::class, 'metrics'], [AuthMiddleware::class]); +$router->post('/servers/:id/thresholds', [ServerController::class, 'saveThresholds'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::class]); $router->get('/teams', [TeamController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index bc06230..ad72bd3 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -15,6 +15,7 @@ use ServerManager\Services\AuditService; use ServerManager\Services\PermissionValidator; use ServerManager\Services\FCMService; use ServerManager\Models\CommandHistory; +use ServerManager\Models\Notification; use ServerManager\Core\Validator; class ApiController @@ -434,6 +435,38 @@ class ApiController $serverModel->updateAgentLastSeen((int) $server['id']); + $checks = [ + 'cpu' => ['val' => $cpu, 'warn' => $server['threshold_cpu_warning'], 'crit' => $server['threshold_cpu_critical']], + 'ram' => ['val' => $ram, 'warn' => $server['threshold_ram_warning'], 'crit' => $server['threshold_ram_critical']], + 'disk' => ['val' => $disk, 'warn' => $server['threshold_disk_warning'], 'crit' => $server['threshold_disk_critical']], + ]; + + $breaches = []; + foreach ($checks as $metric => $c) { + if ($c['crit'] !== null && $c['val'] >= (float) $c['crit']) { + $breaches[] = ['metric' => $metric, 'level' => 'critical', 'current' => $c['val'], 'threshold' => $c['crit']]; + } elseif ($c['warn'] !== null && $c['val'] >= (float) $c['warn']) { + $breaches[] = ['metric' => $metric, 'level' => 'warning', 'current' => $c['val'], 'threshold' => $c['warn']]; + } + } + + if (!empty($breaches)) { + $userIds = $serverModel->getAccessibleUserIds((int) $server['id']); + $notificationModel = new Notification(); + foreach ($breaches as $b) { + $title = strtoupper($b['metric']) . " {$b['level']} on {$server['name']}"; + $message = "{$server['name']}: {$b['metric']} at {$b['current']}% ({$b['level']} threshold: {$b['threshold']}%)"; + foreach ($userIds as $uid) { + $notificationModel->create([ + 'user_id' => (int) $uid, + 'title' => $title, + 'message' => $message, + 'type' => $b['level'], + ]); + } + } + } + $this->view->json(['success' => true]); } diff --git a/src/Controllers/ServerController.php b/src/Controllers/ServerController.php index eaa027f..32b7e1b 100755 --- a/src/Controllers/ServerController.php +++ b/src/Controllers/ServerController.php @@ -1969,4 +1969,24 @@ class ServerController ]); } + public function saveThresholds(int $id): void + { + $server = $this->requireServerAccess($id, 'manager'); + $role = $this->serverModel->getUserRole($id, (int) Session::get('user_id')); + + if ($role !== 'owner') { + $this->view->json(['success' => false, 'error' => 'Only the server owner can modify notification thresholds.'], 403); + } + + $fields = ['cpu_warning', 'cpu_critical', 'ram_warning', 'ram_critical', 'disk_warning', 'disk_critical']; + $data = []; + foreach ($fields as $f) { + $key = 'threshold_' . $f; + $data[$key] = isset($_POST[$f]) ? max(0, min(100, (float) $_POST[$f])) : null; + } + + $this->serverModel->update($id, $data); + $this->auditService->log('thresholds_updated', 'server', $id, $data); + $this->view->json(['success' => true, 'message' => 'Notification thresholds updated.']); + } } diff --git a/src/Core/Database.php b/src/Core/Database.php index 685b2f8..f16b036 100755 --- a/src/Core/Database.php +++ b/src/Core/Database.php @@ -88,6 +88,11 @@ class Database return $this->query($sql, $params)->fetchAll(); } + public function fetchCol(string $sql, array $params = []): array + { + return $this->query($sql, $params)->fetchAll(\PDO::FETCH_COLUMN); + } + public function insert(string $table, array $data): int { $columns = implode(', ', array_keys($data)); diff --git a/src/Models/Server.php b/src/Models/Server.php index f29c8a5..395a7c6 100755 --- a/src/Models/Server.php +++ b/src/Models/Server.php @@ -410,6 +410,28 @@ class Server ); } + public function getAccessibleUserIds(int $serverId): array + { + $direct = $this->db->fetchCol( + 'SELECT user_id FROM server_user WHERE server_id = ? AND status = \'active\'', + [$serverId] + ); + + $team = $this->db->fetchCol( + 'SELECT DISTINCT tu.user_id FROM team_user tu + JOIN team_server ts ON tu.team_id = ts.team_id + WHERE ts.server_id = ? AND tu.status = \'active\' AND ts.status = \'active\'', + [$serverId] + ); + + $owner = $this->db->fetchCol( + 'SELECT created_by FROM servers WHERE id = ? AND status = \'active\'', + [$serverId] + ); + + return array_unique(array_merge($direct, $team, $owner)); + } + public function findByAgentKey(string $key): ?array { $server = $this->db->fetch( diff --git a/views/servers/show.php b/views/servers/show.php index de413bd..562558c 100755 --- a/views/servers/show.php +++ b/views/servers/show.php @@ -270,6 +270,12 @@ $canManage = $server_role === 'owner' || $server_role === 'manager'; Restart Service + + + @@ -297,6 +303,55 @@ $canManage = $server_role === 'owner' || $server_role === 'manager'; + + @@ -591,4 +646,41 @@ function refreshMetrics() { } setInterval(refreshMetrics, 30000); + +function showThresholdModal() { + document.getElementById('thresholdModal').style.display = 'flex'; + document.getElementById('thresholdModal').onclick = function(e) { + if (e.target === this) closeThresholdModal(); + }; +} + +function closeThresholdModal() { + document.getElementById('thresholdModal').style.display = 'none'; +} + +function saveThresholds() { + const form = document.getElementById('thresholdForm'); + const data = new URLSearchParams(new FormData(form)); + data.append('_csrf_token', getCsrfToken()); + + document.querySelector('#thresholdModal .btn-primary').disabled = true; + + fetch('/servers//thresholds', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': getCsrfToken(), + }, + body: data.toString(), + }) + .then(r => r.json()) + .then(d => { + showToast(d.success ? 'success' : 'error', d.message ?? d.error); + if (d.success) closeThresholdModal(); + }) + .catch(e => showToast('error', 'Request failed: ' + e.message)) + .finally(() => { + document.querySelector('#thresholdModal .btn-primary').disabled = false; + }); +} -- 2.43.0