Compare commits

..

2 Commits

Author SHA1 Message Date
95066d9aea remove plan file from tracking 2026-06-13 17:29:07 -04:00
2c6728be12 feat: add notifications_enabled toggle for users (web + Android) 2026-06-13 17:28:59 -04:00
18 changed files with 117 additions and 872 deletions

2
.gitignore vendored
View File

@@ -8,5 +8,3 @@ android/build/
android/app/build/ android/app/build/
android/local.properties android/local.properties
config/firebase-service-account.json config/firebase-service-account.json
.*
!.gitignore

View File

@@ -1,256 +0,0 @@
# 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 endif; ?>`):
```php
<?php if ($server_role === 'owner'): ?>
<button class="action-card" onclick="showThresholdModal()">
<i class="fas fa-bell"></i>
<span>Notification Thresholds</span>
</button>
<?php endif; ?>
```
**b) Modal** (después del `#agentConfirmModal`, antes de `<form id="csrfForm">`):
```php
<div id="thresholdModal" class="modal">
<div class="modal-dialog">
<div class="modal-header">
<h3><i class="fas fa-bell"></i> Notification Thresholds</h3>
<button type="button" class="modal-close" onclick="closeThresholdModal()">&times;</button>
</div>
<div class="modal-body">
<p class="text-muted" style="margin-bottom:20px">
Notifications are sent to all users with access to this server when the agent reports values
exceeding these thresholds. Only the server owner can modify them.
</p>
<form id="thresholdForm" onsubmit="return false">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Resource</th>
<th>Warning (%)</th>
<th>Critical (%)</th>
</tr>
</thead>
<tbody>
<?php foreach (['cpu' => 'CPU', 'ram' => 'RAM', 'disk' => 'Disk'] as $key => $label): ?>
<tr>
<td><strong><?= $label ?></strong></td>
<td>
<input type="number" name="<?= $key ?>_warning"
value="<?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>"
min="0" max="100" step="0.01" class="form-control" style="width:120px">
</td>
<td>
<input type="number" name="<?= $key ?>_critical"
value="<?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>"
min="0" max="100" step="0.01" class="form-control" style="width:120px">
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="saveThresholds()"><i class="fas fa-save"></i> Save</button>
<button class="btn btn-secondary" onclick="closeThresholdModal()">Cancel</button>
</div>
</div>
</div>
```
**c) JavaScript** (al final del bloque `<script>`, antes de `</script>`):
```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/<?= $server['id'] ?>/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()`.

View File

@@ -13,8 +13,8 @@ android {
applicationId = "com.devlab.app" applicationId = "com.devlab.app"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 16 versionCode = 15
versionName = "1.8.4" versionName = "1.8.3"
} }
buildTypes { buildTypes {

View File

@@ -8,5 +8,4 @@ data class AppNotification(
val type: String = "info", val type: String = "info",
val is_read: Int = 0, val is_read: Int = 0,
val created_at: String = "", val created_at: String = "",
val created_at_timestamp: Long = 0,
) )

View File

@@ -99,7 +99,7 @@ class NotificationForegroundService : android.app.Service() {
if (!response.success) return if (!response.success) return
val newNotifications = response.data.filter { note -> val newNotifications = response.data.filter { note ->
val noteTime = note.created_at_timestamp * 1000L val noteTime = parseTime(note.created_at)
noteTime > lastCheck && note.is_read == 0 noteTime > lastCheck && note.is_read == 0
} }
@@ -108,7 +108,19 @@ class NotificationForegroundService : android.app.Service() {
for (notification in newNotifications) { for (notification in newNotifications) {
NotificationHelper.showNotification(this, notification) NotificationHelper.showNotification(this, notification)
} }
lastCheck = newNotifications.maxOf { it.created_at_timestamp * 1000L } 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
} }
} }

View File

@@ -219,7 +219,7 @@ private fun NotificationItem(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = formatTime(notification.created_at_timestamp * 1000L), text = formatTime(notification.created_at),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Grey500 color = Grey500
) )
@@ -240,17 +240,27 @@ private fun NotificationItem(
} }
} }
private fun formatTime(millis: Long): String { private fun formatTime(timestamp: String?): String {
if (millis <= 0) return "" if (timestamp == null) return ""
val now = System.currentTimeMillis() return try {
val diff = now - millis val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
return when { timeZone = TimeZone.getTimeZone("UTC")
}
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 < 60_000 -> "Just now"
diff < 3600_000 -> "${diff / 60_000}m ago" diff < 3600_000 -> "${diff / 60_000}m ago"
diff < 86400_000 -> "${diff / 3600_000}h ago" diff < 86400_000 -> "${diff / 3600_000}h ago"
else -> { else -> {
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.US) val out = SimpleDateFormat("MMM d, HH:mm", Locale.US)
sdf.format(Date(millis)) out.format(date)
} }
} }
} catch (_: Exception) { timestamp }
} }

View File

@@ -113,8 +113,19 @@ object NotificationHelper {
markReadPendingIntent markReadPendingIntent
) )
.setAutoCancel(true) .setAutoCancel(true)
.setWhen(if (notification.created_at_timestamp > 0) notification.created_at_timestamp * 1000L else System.currentTimeMillis()) .setWhen(parseTime(notification.created_at))
NotificationManagerCompat.from(context).notify(notification.id, built.build()) 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()
}
}
} }

View File

@@ -3,6 +3,7 @@ package com.devlab.app.util
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.devlab.app.data.api.RetrofitClient import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.model.AppNotification
import kotlinx.coroutines.* import kotlinx.coroutines.*
class NotificationPoller(private val context: Context) { class NotificationPoller(private val context: Context) {
@@ -55,7 +56,7 @@ class NotificationPoller(private val context: Context) {
onCountChanged?.invoke(_unreadCount) onCountChanged?.invoke(_unreadCount)
val newNotes = response.data.filter { note -> val newNotes = response.data.filter { note ->
val noteTime = note.created_at_timestamp * 1000L val noteTime = parseTime(note.created_at)
noteTime > lastCheck && note.is_read == 0 noteTime > lastCheck && note.is_read == 0
} }
@@ -64,10 +65,20 @@ class NotificationPoller(private val context: Context) {
} }
if (newNotes.isNotEmpty()) { if (newNotes.isNotEmpty()) {
lastCheck = newNotes.maxOf { it.created_at_timestamp * 1000L } lastCheck = newNotes.maxOf { parseTime(it.created_at) }
} }
} }
} catch (_: Exception) { } } 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 }
}
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import androidx.work.* import androidx.work.*
import com.devlab.app.data.api.RetrofitClient 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.NotificationHelper
import com.devlab.app.util.PreferencesManager import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -39,7 +40,7 @@ class NotificationWorker(
Log.d("NotificationWorker", "Got ${response.data.size} notifications, unread: ${response.unread_count}") Log.d("NotificationWorker", "Got ${response.data.size} notifications, unread: ${response.unread_count}")
val newNotifications = response.data.filter { note -> val newNotifications = response.data.filter { note ->
val noteTime = note.created_at_timestamp * 1000L val noteTime = parseTime(note.created_at)
noteTime > lastCheck && note.is_read == 0 noteTime > lastCheck && note.is_read == 0
} }
@@ -50,7 +51,7 @@ class NotificationWorker(
} }
val maxTime = newNotifications.maxOfOrNull { val maxTime = newNotifications.maxOfOrNull {
it.created_at_timestamp * 1000L parseTime(it.created_at)
} ?: now } ?: now
prefs.setLastNotificationCheck( prefs.setLastNotificationCheck(
@@ -70,6 +71,18 @@ 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 { companion object {
private const val WORK_NAME = "notification_check" private const val WORK_NAME = "notification_check"

View File

@@ -3134,215 +3134,3 @@ textarea.form-input {
background: var(--bg-hover, #e9ecef); background: var(--bg-hover, #e9ecef);
border-color: var(--text-muted, #888); border-color: var(--text-muted, #888);
} }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-primary);
border: 2px solid var(--bg-card);
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
cursor: pointer;
transition: transform 0.1s;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-primary);
border: 2px solid var(--bg-card);
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
cursor: pointer;
}
input[type="range"]:focus {
outline: none;
}
input[type="range"]::-moz-range-track {
height: 6px;
border-radius: 3px;
}
/* ==========================================================================
Notification Bell & Modal
========================================================================== */
.notification-btn-wrapper {
position: relative;
display: inline-flex;
}
.notification-btn {
position: relative;
}
.notification-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 100px;
background: var(--danger);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 0 0 2px var(--bg-secondary);
pointer-events: none;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.15s;
}
.notif-item:hover {
background: var(--bg-hover);
}
.notif-item:last-child {
border-bottom: none;
}
.notif-unread {
background: rgba(99, 102, 241, 0.04);
}
.notif-unread:hover {
background: rgba(99, 102, 241, 0.08);
}
.notif-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 0.85em;
}
.notif-icon.notif-danger { background: var(--danger-bg); color: var(--danger); }
.notif-icon.notif-warning { background: var(--warning-bg); color: var(--warning); }
.notif-icon.notif-info { background: var(--info-bg); color: var(--info); }
.notif-content {
flex: 1;
min-width: 0;
}
.notif-title {
font-size: 0.88em;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.notif-message {
font-size: 0.82em;
color: var(--text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notif-time {
font-size: 0.72em;
color: var(--text-muted);
margin-top: 3px;
opacity: 0.7;
}
.notif-read .notif-title {
font-weight: 400;
opacity: 0.65;
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--info);
flex-shrink: 0;
margin-top: 6px;
}
.btn-sm {
padding: 0.3rem 0.6rem;
font-size: 0.82rem;
border-radius: var(--radius-sm, 6px);
}
.btn-text {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s;
}
.btn-text:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
/* ==========================================================================
Notification Dropdown Panel
========================================================================== */
.notification-dropdown {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 380px;
max-height: 520px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 300;
overflow: hidden;
}
.notification-dropdown.open {
display: block;
}
.notif-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.notif-dropdown-body {
max-height: 360px;
overflow-y: auto;
}
.notif-dropdown-footer {
border-top: 1px solid var(--border-color);
}

Binary file not shown.

View File

@@ -22,7 +22,6 @@ $router->get('/dashboard', [DashboardController::class, 'index'], [AuthMiddlewar
$router->get('/dashboard/stats', [DashboardController::class, 'stats'], [AuthMiddleware::class]); $router->get('/dashboard/stats', [DashboardController::class, 'stats'], [AuthMiddleware::class]);
$router->get('/dashboard/refresh-metrics', [DashboardController::class, 'refreshMetrics'], [AuthMiddleware::class]); $router->get('/dashboard/refresh-metrics', [DashboardController::class, 'refreshMetrics'], [AuthMiddleware::class]);
$router->get('/dashboard/chart-data', [DashboardController::class, 'chartData'], [AuthMiddleware::class]); $router->get('/dashboard/chart-data', [DashboardController::class, 'chartData'], [AuthMiddleware::class]);
$router->get('/notifications', [DashboardController::class, 'notifications'], [AuthMiddleware::class]);
$router->get('/login', [AuthController::class, 'loginForm']); $router->get('/login', [AuthController::class, 'loginForm']);
$router->post('/login', [AuthController::class, 'login'], [CSRFMiddleware::class, RateLimitMiddleware::class]); $router->post('/login', [AuthController::class, 'login'], [CSRFMiddleware::class, RateLimitMiddleware::class]);

View File

@@ -713,15 +713,9 @@ class ApiController
$result = $notificationModel->getForUser((int) $user['id'], $page, $perPage); $result = $notificationModel->getForUser((int) $user['id'], $page, $perPage);
$unreadCount = $notificationModel->getUnreadCount((int) $user['id']); $unreadCount = $notificationModel->getUnreadCount((int) $user['id']);
$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']);
$this->view->json([ $this->view->json([
'success' => true, 'success' => true,
'data' => $data, 'data' => $result['data'],
'pagination' => [ 'pagination' => [
'page' => $result['page'], 'page' => $result['page'],
'per_page' => $result['per_page'], 'per_page' => $result['per_page'],

View File

@@ -7,7 +7,6 @@ namespace ServerManager\Controllers;
use ServerManager\Core\App; use ServerManager\Core\App;
use ServerManager\Core\Session; use ServerManager\Core\Session;
use ServerManager\Models\Server; use ServerManager\Models\Server;
use ServerManager\Models\Notification;
use ServerManager\Services\MonitoringService; use ServerManager\Services\MonitoringService;
use ServerManager\Services\AuditService; use ServerManager\Services\AuditService;
@@ -81,22 +80,4 @@ class DashboardController
'data' => $data, 'data' => $data,
]); ]);
} }
public function notifications(): void
{
$userId = (int) Session::get('user_id');
$page = (int) ($_GET['page'] ?? 1);
$perPage = 20;
$notificationModel = new Notification();
$result = $notificationModel->getForUser($userId, $page, $perPage);
$unreadCount = $notificationModel->getUnreadCount($userId);
$this->view->display('notifications.index', [
'title' => 'Notifications - ServerManager',
'notifications' => $result['data'],
'pagination' => $result,
'unreadCount' => $unreadCount,
]);
}
} }

View File

@@ -156,12 +156,6 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
</span> </span>
</div> </div>
</div> </div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script> <script>
@@ -304,4 +298,9 @@ if (aggregatedData.length > 0) {
setInterval(refreshAggregatedChart, 30000); setInterval(refreshAggregatedChart, 30000);
</script> </script>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>

View File

@@ -4,17 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?= $_SESSION['csrf_token'] ?? '' ?>"> <meta name="csrf-token" content="<?= $_SESSION['csrf_token'] ?? '' ?>">
<meta name="api-token" content="<?php
$uid = $_SESSION['user_id'] ?? 0;
$token = '';
if ($uid) {
$u = \ServerManager\Core\Database::getInstance()->fetch(
'SELECT api_token FROM users WHERE id = ? AND status = \'active\'', [(int) $uid]
);
$token = $u['api_token'] ?? '';
}
echo htmlspecialchars($token);
?>">
<title><?= htmlspecialchars($title ?? 'ServerManager') ?></title> <title><?= htmlspecialchars($title ?? 'ServerManager') ?></title>
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg"> <link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
<link rel="stylesheet" href="/assets/css/style.css"> <link rel="stylesheet" href="/assets/css/style.css">
@@ -40,12 +29,6 @@
<span>Dashboard</span> <span>Dashboard</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a href="/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/notifications') ? 'active' : '' ?>">
<i class="fas fa-bell"></i>
<span>Notifications</span>
</a>
</li>
<li class="nav-item nav-item-accordion <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/servers') ? 'active' : '' ?>"> <li class="nav-item nav-item-accordion <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/servers') ? 'active' : '' ?>">
<a href="#" class="nav-link" onclick="toggleServerList(event)"> <a href="#" class="nav-link" onclick="toggleServerList(event)">
<i class="fas fa-hdd"></i> <i class="fas fa-hdd"></i>
@@ -152,34 +135,6 @@
<button class="btn-icon" id="themeToggle" title="Toggle theme"> <button class="btn-icon" id="themeToggle" title="Toggle theme">
<i class="fas fa-moon"></i> <i class="fas fa-moon"></i>
</button> </button>
<div class="notification-btn-wrapper">
<button class="btn-icon notification-btn" id="notificationBell" title="Notifications" onclick="toggleNotifications()">
<i class="fas fa-bell"></i>
<span class="notification-badge" id="notificationBadge" style="display:none">0</span>
</button>
<div class="notification-dropdown" id="notifDropdown">
<div class="notif-dropdown-header">
<span style="font-weight:600;font-size:0.9em">Notifications</span>
<div style="display:flex;align-items:center;gap:6px">
<button class="btn-text" id="markAllReadBtn" onclick="markAllRead()" style="display:none;font-size:0.78em;padding:2px 6px;border-radius:4px">
<i class="fas fa-check-double"></i> Mark all read
</button>
<button class="btn-text" onclick="closeNotifications()" style="font-size:0.78em;padding:2px 6px;border-radius:4px">&times;</button>
</div>
</div>
<div class="notif-dropdown-body" id="notifList">
<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">
<i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i>
<span>Loading notifications...</span>
</div>
</div>
<div class="notif-dropdown-footer">
<a href="/notifications" class="btn-text" style="font-size:0.82em;color:var(--info);width:100%;text-align:center;padding:8px;display:block">
<i class="fas fa-external-link-alt"></i> View all notifications
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -214,171 +169,6 @@
<?php endif; ?> <?php endif; ?>
<script> <script>
let serverListLoaded = false; let serverListLoaded = false;
function getApiToken() {
return document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
}
function toggleNotifications() {
const dd = document.getElementById('notifDropdown');
if (dd.classList.contains('open')) {
closeNotifications();
} else {
openNotifications();
}
}
function openNotifications() {
const dd = document.getElementById('notifDropdown');
dd.classList.add('open');
fetchNotifications();
document.addEventListener('click', notifOutsideClick);
}
function closeNotifications() {
const dd = document.getElementById('notifDropdown');
dd.classList.remove('open');
document.removeEventListener('click', notifOutsideClick);
}
function notifOutsideClick(e) {
const wrapper = document.querySelector('.notification-btn-wrapper');
if (!wrapper.contains(e.target)) {
closeNotifications();
}
}
function fetchNotifications() {
const list = document.getElementById('notifList');
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)"><i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i><span>Loading notifications...</span></div>';
const token = getApiToken();
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch('/api/notifications?per_page=10', { headers })
.then(r => r.json())
.then(d => {
if (!d.success) {
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">Could not load notifications.</div>';
return;
}
renderNotifications(d.data || [], d.unread_count || 0);
})
.catch(() => {
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--danger)">Failed to load notifications.</div>';
});
}
function renderNotifications(items, unreadCount) {
const list = document.getElementById('notifList');
if (!items.length) {
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">'
+ '<i class="fas fa-check-circle" style="font-size:1.8em;margin-bottom:10px;display:block;color:var(--success)"></i>'
+ '<span>No notifications</span></div>';
document.getElementById('markAllReadBtn').style.display = 'none';
return;
}
let html = '';
items.forEach(function(n) {
const typeColors = { error: 'danger', warning: 'warning', info: 'info' };
const typeColor = typeColors[n.type] || 'info';
const icons = { error: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
const icon = icons[n.type] || 'fa-info-circle';
html += '<div class="notif-item ' + (n.is_read ? 'notif-read' : 'notif-unread') + '"'
+ ' onclick="markRead(' + n.id + ', this)"'
+ ' data-id="' + n.id + '">'
+ '<div class="notif-icon notif-' + typeColor + '"><i class="fas ' + icon + '"></i></div>'
+ '<div class="notif-content">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-message">' + escapeHtml(n.message) + '</div>'
+ '<div class="notif-time">' + (n.time_ago || '') + '</div>'
+ '</div>'
+ (n.is_read ? '' : '<div class="notif-dot"></div>')
+ '</div>';
});
list.innerHTML = html;
document.getElementById('markAllReadBtn').style.display = unreadCount > 0 ? 'inline-flex' : 'none';
}
function markRead(id, el) {
if (el.classList.contains('notif-read')) return;
const token = getApiToken();
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch('/api/notifications/' + id + '/read', { method: 'POST', headers })
.then(r => r.json())
.then(d => {
if (d.success) {
el.classList.remove('notif-unread');
el.classList.add('notif-read');
const dot = el.querySelector('.notif-dot');
if (dot) dot.remove();
updateUnreadCount();
}
})
.catch(function(){});
}
function markAllRead() {
const token = getApiToken();
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch('/api/notifications/read-all', { method: 'POST', headers })
.then(r => r.json())
.then(d => {
if (d.success) {
document.querySelectorAll('.notif-item').forEach(function(el) {
el.classList.remove('notif-unread');
el.classList.add('notif-read');
const dot = el.querySelector('.notif-dot');
if (dot) dot.remove();
});
document.getElementById('markAllReadBtn').style.display = 'none';
updateUnreadCount();
}
})
.catch(function(){});
}
function updateUnreadCount() {
const token = getApiToken();
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch('/api/notifications/unread-count', { headers })
.then(r => r.json())
.then(d => {
const count = d.unread_count || 0;
const badge = document.getElementById('notificationBadge');
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline-flex';
} else {
badge.style.display = 'none';
}
})
.catch(function(){});
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// Poll unread count every 60s
setInterval(updateUnreadCount, 60000);
// Initial fetch
updateUnreadCount();
function toggleServerList(e) { function toggleServerList(e) {
e.preventDefault(); e.preventDefault();
const item = e.currentTarget.closest('.nav-item-accordion'); const item = e.currentTarget.closest('.nav-item-accordion');

View File

@@ -1,91 +0,0 @@
<?php
$notifications = $notifications ?? [];
$pagination = $pagination ?? [];
$unreadCount = $unreadCount ?? 0;
$data = $notifications ?? [];
?>
<div class="page-header">
<div class="page-header-left">
<div class="back-link">
<a href="/dashboard"><i class="fas fa-arrow-left"></i> Back to Dashboard</a>
</div>
<h1 class="page-title">Notifications</h1>
<p class="page-subtitle">
<?= $unreadCount ?> unread &middot; <?= $pagination['total'] ?? 0 ?> total
</p>
</div>
<div class="page-header-right">
<?php if ($unreadCount > 0): ?>
<button class="btn btn-sm btn-text" onclick="markAllRead()" style="font-size:0.85em">
<i class="fas fa-check-double"></i> Mark all read
</button>
<?php endif; ?>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:0">
<?php if (empty($data)): ?>
<div style="padding:60px 20px;text-align:center;color:var(--text-muted)">
<i class="fas fa-check-circle" style="font-size:2.5em;margin-bottom:12px;display:block;color:var(--success)"></i>
<p style="font-size:1.05em">All caught up!</p>
<p style="font-size:0.9em">No notifications to show.</p>
</div>
<?php else: ?>
<?php foreach ($data as $n): ?>
<?php
$typeColors = ['error' => 'danger', 'warning' => 'warning', 'info' => 'info'];
$icons = ['error' => 'fa-times-circle', 'warning' => 'fa-exclamation-triangle', 'info' => 'fa-info-circle'];
$tc = $typeColors[$n['type']] ?? 'info';
$icon = $icons[$n['type']] ?? 'fa-info-circle';
$isRead = !empty($n['is_read']);
?>
<div class="notif-item <?= $isRead ? 'notif-read' : 'notif-unread' ?>" data-id="<?= $n['id'] ?>">
<div class="notif-icon notif-<?= $tc ?>"><i class="fas <?= $icon ?>"></i></div>
<div class="notif-content">
<div class="notif-title"><?= htmlspecialchars($n['title'] ?? '') ?></div>
<div class="notif-message"><?= htmlspecialchars($n['message'] ?? '') ?></div>
<div class="notif-time"><?= time_ago($n['created_at'] ?? '') ?></div>
</div>
<?php if (!$isRead): ?>
<div class="notif-dot"></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php if (($pagination['total_pages'] ?? 1) > 1): ?>
<div class="pagination">
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
<a href="/notifications?page=<?= $i ?>" class="btn btn-sm <?= ($pagination['page'] ?? 1) === $i ? 'btn-primary' : 'btn-secondary' ?>">
<?= $i ?>
</a>
<?php endfor; ?>
</div>
<?php endif; ?>
<script>
function markAllRead() {
const token = document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
const headers = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch('/api/notifications/read-all', { method: 'POST', headers })
.then(r => r.json())
.then(d => {
if (d.success) {
document.querySelectorAll('.notif-item').forEach(function(el) {
el.classList.remove('notif-unread');
el.classList.add('notif-read');
const dot = el.querySelector('.notif-dot');
if (dot) dot.remove();
});
location.reload();
}
})
.catch(function(){});
}
</script>

View File

@@ -304,62 +304,49 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
</div> </div>
<div id="thresholdModal" class="modal"> <div id="thresholdModal" class="modal">
<div class="modal-dialog" style="max-width:560px"> <div class="modal-dialog">
<div class="modal-header"> <div class="modal-header">
<h3><i class="fas fa-bell"></i> Notification Thresholds</h3> <h3><i class="fas fa-bell"></i> Notification Thresholds</h3>
<button type="button" class="modal-close" onclick="closeThresholdModal()">&times;</button> <button type="button" class="modal-close" onclick="closeThresholdModal()">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="text-muted" style="margin-bottom:24px;font-size:0.9em;line-height:1.5"> <p class="text-muted" style="margin-bottom:20px">
When the agent reports values above these thresholds, all users with access to this server Notifications are sent to all users with access to this server when the agent reports values
receive a notification and push alert. Only the server owner can modify them. exceeding these thresholds. Only the server owner can modify them.
</p> </p>
<form id="thresholdForm" onsubmit="return false"> <form id="thresholdForm" onsubmit="return false">
<?php $resources = [ <div class="table-responsive">
'cpu' => ['label' => 'CPU', 'icon' => 'fas fa-microchip', 'color' => 'var(--info)'], <table class="table">
'ram' => ['label' => 'RAM', 'icon' => 'fas fa-memory', 'color' => 'var(--purple)'], <thead>
'disk' => ['label' => 'Disk', 'icon' => 'fas fa-hdd', 'color' => 'var(--warning)'], <tr>
]; ?> <th>Resource</th>
<?php foreach ($resources as $key => $res): ?> <th>Warning (%)</th>
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin-bottom:12px"> <th>Critical (%)</th>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px"> </tr>
<i class="<?= $res['icon'] ?>" style="color:<?= $res['color'] ?>;font-size:1.2em;width:20px;text-align:center"></i> </thead>
<strong style="font-size:1em"><?= $res['label'] ?></strong> <tbody>
</div> <?php foreach (['cpu' => 'CPU', 'ram' => 'RAM', 'disk' => 'Disk'] as $key => $label): ?>
<div style="display:flex;gap:24px;align-items:flex-start"> <tr>
<div style="flex:1"> <td><strong><?= $label ?></strong></td>
<label style="display:flex;align-items:center;gap:6px;font-size:0.82em;color:var(--warning);margin-bottom:6px;font-weight:600"> <td>
<i class="fas fa-exclamation-triangle" style="font-size:0.85em"></i> Warning <input type="number" name="<?= $key ?>_warning"
</label>
<div style="display:flex;align-items:center;gap:10px">
<input type="range" name="<?= $key ?>_warning"
value="<?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>" value="<?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>"
min="0" max="100" step="0.5" min="0" max="100" step="0.01" class="form-control" style="width:120px">
oninput="document.getElementById('<?= $key ?>_warn_val').textContent = this.value + '%'" </td>
style="flex:1;height:6px;-webkit-appearance:none;appearance:none;border-radius:3px;background:linear-gradient(to right, var(--success), var(--warning) 50%, var(--danger) 80%);outline:none;cursor:pointer"> <td>
<span id="<?= $key ?>_warn_val" style="min-width:52px;font-size:0.95em;font-weight:700;color:var(--warning);text-align:right"><?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>%</span> <input type="number" name="<?= $key ?>_critical"
</div>
</div>
<div style="flex:1">
<label style="display:flex;align-items:center;gap:6px;font-size:0.82em;color:var(--danger);margin-bottom:6px;font-weight:600">
<i class="fas fa-bolt" style="font-size:0.85em"></i> Critical
</label>
<div style="display:flex;align-items:center;gap:10px">
<input type="range" name="<?= $key ?>_critical"
value="<?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>" value="<?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>"
min="0" max="100" step="0.5" min="0" max="100" step="0.01" class="form-control" style="width:120px">
oninput="document.getElementById('<?= $key ?>_crit_val').textContent = this.value + '%'" </td>
style="flex:1;height:6px;-webkit-appearance:none;appearance:none;border-radius:3px;background:linear-gradient(to right, var(--success), var(--warning) 50%, var(--danger) 80%);outline:none;cursor:pointer"> </tr>
<span id="<?= $key ?>_crit_val" style="min-width:52px;font-size:0.95em;font-weight:700;color:var(--danger);text-align:right"><?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>%</span>
</div>
</div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</tbody>
</table>
</div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-primary" onclick="saveThresholds()"><i class="fas fa-save"></i> Save Thresholds</button> <button class="btn btn-primary" onclick="saveThresholds()"><i class="fas fa-save"></i> Save</button>
<button class="btn btn-secondary" onclick="closeThresholdModal()">Cancel</button> <button class="btn btn-secondary" onclick="closeThresholdModal()">Cancel</button>
</div> </div>
</div> </div>