diff --git a/.opencode/plans/server-thresholds.md b/.opencode/plans/server-thresholds.md new file mode 100644 index 0000000..3c439f5 --- /dev/null +++ b/.opencode/plans/server-thresholds.md @@ -0,0 +1,256 @@ +# Plan: Umbrales de notificación por servidor + +## Archivos a modificar/crear + +### 1. `database/migrations/012_server_thresholds.sql` — CREAR + +```sql +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_disk_warning`, + ADD COLUMN `threshold_disk_critical` DECIMAL(5,2) DEFAULT 95.00 AFTER `threshold_disk_critical`; +``` + +### 2. `src/Core/Database.php` — MODIFICAR + +Agregar método `fetchCol()` después de `fetchAll()`: + +```php + public function fetchCol(string $sql, array $params = []): array + { + return $this->query($sql, $params)->fetchAll(\PDO::FETCH_COLUMN); + } +``` + +### 3. `src/Models/Server.php` — MODIFICAR + +Agregar método `getAccessibleUserIds()` antes de `findByAgentKey()`: + +```php + 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)); + } +``` + +### 4. `routes/web.php` — MODIFICAR + +Agregar después de línea 67 (`/servers/:id/metrics`): + +```php +$router->post('/servers/:id/thresholds', [ServerController::class, 'saveThresholds'], [AuthMiddleware::class, CSRFMiddleware::class]); +``` + +### 5. `src/Controllers/ServerController.php` — MODIFICAR + +Agregar en `use` imports (si no está ya) + nuevo método después de `metrics()`: + +```php + 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.']); + } +``` + +### 6. `src/Controllers/ApiController.php` — MODIFICAR + +En `pushMetrics()`, después de `updateAgentLastSeen()` (línea 435) y antes de `$this->view->json(['success' => true])` (línea 437): + +```php + // --- Verificar umbrales de notificación --- + $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 \ServerManager\Models\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'], + ]); + } + } + } +``` + +Agregar import de Notification en los `use` del ApiController (si no está): +```php +use ServerManager\Models\Notification; +``` + +### 7. `views/servers/show.php` — MODIFICAR + +**a) Botón en Management Actions** (después de línea 272, antes de ``): + +```php + + + +``` + +**b) Modal** (después del `#agentConfirmModal`, antes de `
`): + +```php +
+``` + +**c) JavaScript** (al final del bloque ``): + +```javascript +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; + }); +} +``` + +### 8. Ejecutar migración + +```bash +mysql -u root -p"servermanager2024" servermanager < /var/www/servermanager/database/migrations/012_server_thresholds.sql +``` + +### 9. Commit y push + +```bash +git add -A && git commit -m "feat: per-server notification thresholds with alert on agent metrics push" && git push origin feat/fcm-apk-v1.8.2 +``` + +## Variables de umbral en la vista + +Las columnas nuevas (`threshold_cpu_warning`, etc.) viajan automáticamente en `$server` porque `findById()` hace `SELECT *`. No requiere cambios en `ServerController::show()`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d77d34f..e2b756e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.devlab.app" minSdk = 26 targetSdk = 37 - versionCode = 14 - versionName = "1.8.2" + versionCode = 15 + versionName = "1.8.3" } buildTypes { diff --git a/android/app/src/main/java/com/devlab/app/data/model/Notification.kt b/android/app/src/main/java/com/devlab/app/data/model/Notification.kt index 1258ee0..2a8a564 100644 --- a/android/app/src/main/java/com/devlab/app/data/model/Notification.kt +++ b/android/app/src/main/java/com/devlab/app/data/model/Notification.kt @@ -8,4 +8,5 @@ data class AppNotification( val type: String = "info", val is_read: Int = 0, val created_at: String = "", + val created_at_timestamp: Long = 0, ) diff --git a/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt b/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt index d4e02b0..5866e36 100644 --- a/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt +++ b/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt @@ -99,7 +99,7 @@ class NotificationForegroundService : android.app.Service() { if (!response.success) return val newNotifications = response.data.filter { note -> - val noteTime = parseTime(note.created_at) + val noteTime = note.created_at_timestamp * 1000L noteTime > lastCheck && note.is_read == 0 } @@ -108,19 +108,7 @@ class NotificationForegroundService : android.app.Service() { for (notification in newNotifications) { NotificationHelper.showNotification(this, notification) } - lastCheck = newNotifications.maxOf { parseTime(it.created_at) } - } - } - - private fun parseTime(time: String?): Long { - if (time == null) return 0L - return try { - val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - } - sdf.parse(time)?.time ?: 0L - } catch (_: Exception) { - 0L + lastCheck = newNotifications.maxOf { it.created_at_timestamp * 1000L } } } diff --git a/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt b/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt index fe743e1..725d03d 100644 --- a/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt +++ b/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt @@ -219,7 +219,7 @@ private fun NotificationItem( Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = formatTime(notification.created_at), + text = formatTime(notification.created_at_timestamp * 1000L), style = MaterialTheme.typography.labelSmall, color = Grey500 ) @@ -240,27 +240,17 @@ private fun NotificationItem( } } -private fun formatTime(timestamp: String?): String { - if (timestamp == null) return "" - return try { - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") +private fun formatTime(millis: Long): String { + if (millis <= 0) return "" + val now = System.currentTimeMillis() + val diff = now - millis + return when { + diff < 60_000 -> "Just now" + diff < 3600_000 -> "${diff / 60_000}m ago" + diff < 86400_000 -> "${diff / 3600_000}h ago" + else -> { + val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.US) + sdf.format(Date(millis)) } - val date = sdf.parse(timestamp) ?: return timestamp - val now = Calendar.getInstance() - val then = Calendar.getInstance().apply { - this.time = date - } - - val diff = now.timeInMillis - then.timeInMillis - when { - diff < 60_000 -> "Just now" - diff < 3600_000 -> "${diff / 60_000}m ago" - diff < 86400_000 -> "${diff / 3600_000}h ago" - else -> { - val out = SimpleDateFormat("MMM d, HH:mm", Locale.US) - out.format(date) - } - } - } catch (_: Exception) { timestamp } + } } diff --git a/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt index ae44543..95cecf0 100644 --- a/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt +++ b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt @@ -113,19 +113,8 @@ object NotificationHelper { markReadPendingIntent ) .setAutoCancel(true) - .setWhen(parseTime(notification.created_at)) + .setWhen(if (notification.created_at_timestamp > 0) notification.created_at_timestamp * 1000L else System.currentTimeMillis()) NotificationManagerCompat.from(context).notify(notification.id, built.build()) } - - private fun parseTime(time: String?): Long { - if (time == null) return System.currentTimeMillis() - return try { - java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - }.parse(time)?.time ?: System.currentTimeMillis() - } catch (_: Exception) { - System.currentTimeMillis() - } - } } diff --git a/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt b/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt index d76bf0b..82e6e6c 100644 --- a/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt +++ b/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt @@ -3,7 +3,6 @@ package com.devlab.app.util import android.content.Context import android.util.Log import com.devlab.app.data.api.RetrofitClient -import com.devlab.app.data.model.AppNotification import kotlinx.coroutines.* class NotificationPoller(private val context: Context) { @@ -56,7 +55,7 @@ class NotificationPoller(private val context: Context) { onCountChanged?.invoke(_unreadCount) val newNotes = response.data.filter { note -> - val noteTime = parseTime(note.created_at) + val noteTime = note.created_at_timestamp * 1000L noteTime > lastCheck && note.is_read == 0 } @@ -65,20 +64,10 @@ class NotificationPoller(private val context: Context) { } if (newNotes.isNotEmpty()) { - lastCheck = newNotes.maxOf { parseTime(it.created_at) } + lastCheck = newNotes.maxOf { it.created_at_timestamp * 1000L } } } } catch (_: Exception) { } } } - - private fun parseTime(time: String?): Long { - if (time == null) return 0L - return try { - val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - } - sdf.parse(time)?.time ?: 0L - } catch (_: Exception) { 0L } - } } diff --git a/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt b/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt index 1780a79..8450817 100644 --- a/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt +++ b/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt @@ -4,7 +4,6 @@ import android.content.Context import android.util.Log import androidx.work.* import com.devlab.app.data.api.RetrofitClient -import com.devlab.app.data.model.AppNotification import com.devlab.app.util.NotificationHelper import com.devlab.app.util.PreferencesManager import kotlinx.coroutines.Dispatchers @@ -40,7 +39,7 @@ class NotificationWorker( Log.d("NotificationWorker", "Got ${response.data.size} notifications, unread: ${response.unread_count}") val newNotifications = response.data.filter { note -> - val noteTime = parseTime(note.created_at) + val noteTime = note.created_at_timestamp * 1000L noteTime > lastCheck && note.is_read == 0 } @@ -51,7 +50,7 @@ class NotificationWorker( } val maxTime = newNotifications.maxOfOrNull { - parseTime(it.created_at) + it.created_at_timestamp * 1000L } ?: now prefs.setLastNotificationCheck( @@ -71,18 +70,6 @@ class NotificationWorker( } } - private fun parseTime(time: String?): Long { - if (time == null) return 0L - return try { - val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - } - sdf.parse(time)?.time ?: 0L - } catch (_: Exception) { - 0L - } - } - companion object { private const val WORK_NAME = "notification_check" diff --git a/public/sysadmin.apk b/public/sysadmin.apk index f7ee935..490a157 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 71a2579..0121573 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -701,6 +701,7 @@ class ApiController $data = array_map(function ($n) { $n['time_ago'] = time_ago($n['created_at'] ?? ''); + $n['created_at_timestamp'] = $n['created_at'] ? strtotime($n['created_at']) : 0; return $n; }, $result['data']);