Compare commits

..

16 Commits

Author SHA1 Message Date
a499a1ed8b bump to 1.8.4 (versionCode 16) and rebuild APK 2026-06-13 17:31:32 -04:00
ec38afe3e9 feat: add notifications_enabled toggle for users (web + Android) 2026-06-13 17:31:07 -04:00
81f61ff96e Merge pull request 'feat: notify all users on app version update' (#79) from feat/version-update-notification into master
Reviewed-on: #79
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 17:13:36 -04:00
5691d8b862 Merge pull request 'feat/server-thresholds' (#78) from feat/server-thresholds into master
Reviewed-on: #78
2026-06-13 17:05:25 -04:00
a2cbb28851 chore: ignore all dotfiles/dotdirs via .gitignore 2026-06-13 17:05:00 -04:00
585d3e2e76 fix: use created_at_timestamp for Android notification dates; bump to 1.8.3 2026-06-13 17:02:22 -04:00
06d31122b8 Merge pull request 'fix: use server-side time_ago for notification timestamps' (#77) from feat/server-thresholds into master
Reviewed-on: #77
2026-06-13 16:57:20 -04:00
045f33bbc1 fix: use server-side time_ago for notification timestamps 2026-06-13 16:56:43 -04:00
020990c603 Merge pull request 'feat: notification bell with modal in top bar' (#76) from feat/server-thresholds into master
Reviewed-on: #76
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 16:52:44 -04:00
edd9933430 fix: move script block outside foreach to prevent const redeclaration error 2026-06-13 16:51:44 -04:00
19bae94f4c remove plan file from tracking 2026-06-13 16:48:11 -04:00
e1b6650454 feat: notification dropdown panel and /notifications page 2026-06-13 16:48:05 -04:00
25dfb517ee feat: notification bell with modal showing 10 latest notifications 2026-06-13 16:41:56 -04:00
7ed584d5d2 Merge pull request 'feat: threshold modal with range sliders and card-based layout' (#75) from feat/server-thresholds into master
Reviewed-on: #75
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 16:33:41 -04:00
e92f51e36b replace number inputs with range sliders in threshold modal 2026-06-13 16:32:53 -04:00
dc34f29169 improve threshold modal design with card-based layout and color-coded inputs 2026-06-13 16:32:53 -04:00
18 changed files with 872 additions and 117 deletions

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ 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

@@ -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 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 = 15 versionCode = 16
versionName = "1.8.3" versionName = "1.8.4"
} }
buildTypes { buildTypes {

View File

@@ -8,4 +8,5 @@ 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 = parseTime(note.created_at) val noteTime = note.created_at_timestamp * 1000L
noteTime > lastCheck && note.is_read == 0 noteTime > lastCheck && note.is_read == 0
} }
@@ -108,19 +108,7 @@ class NotificationForegroundService : android.app.Service() {
for (notification in newNotifications) { for (notification in newNotifications) {
NotificationHelper.showNotification(this, notification) NotificationHelper.showNotification(this, notification)
} }
lastCheck = newNotifications.maxOf { parseTime(it.created_at) } lastCheck = newNotifications.maxOf { it.created_at_timestamp * 1000L }
}
}
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), text = formatTime(notification.created_at_timestamp * 1000L),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Grey500 color = Grey500
) )
@@ -240,27 +240,17 @@ private fun NotificationItem(
} }
} }
private fun formatTime(timestamp: String?): String { private fun formatTime(millis: Long): String {
if (timestamp == null) return "" if (millis <= 0) return ""
return try { val now = System.currentTimeMillis()
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { val diff = now - millis
timeZone = TimeZone.getTimeZone("UTC") 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 }
} }

View File

@@ -113,19 +113,8 @@ object NotificationHelper {
markReadPendingIntent markReadPendingIntent
) )
.setAutoCancel(true) .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()) 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,7 +3,6 @@ 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) {
@@ -56,7 +55,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 = parseTime(note.created_at) val noteTime = note.created_at_timestamp * 1000L
noteTime > lastCheck && note.is_read == 0 noteTime > lastCheck && note.is_read == 0
} }
@@ -65,20 +64,10 @@ class NotificationPoller(private val context: Context) {
} }
if (newNotes.isNotEmpty()) { if (newNotes.isNotEmpty()) {
lastCheck = newNotes.maxOf { parseTime(it.created_at) } lastCheck = newNotes.maxOf { it.created_at_timestamp * 1000L }
} }
} }
} 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,7 +4,6 @@ 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
@@ -40,7 +39,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 = parseTime(note.created_at) val noteTime = note.created_at_timestamp * 1000L
noteTime > lastCheck && note.is_read == 0 noteTime > lastCheck && note.is_read == 0
} }
@@ -51,7 +50,7 @@ class NotificationWorker(
} }
val maxTime = newNotifications.maxOfOrNull { val maxTime = newNotifications.maxOfOrNull {
parseTime(it.created_at) it.created_at_timestamp * 1000L
} ?: now } ?: now
prefs.setLastNotificationCheck( 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 { companion object {
private const val WORK_NAME = "notification_check" private const val WORK_NAME = "notification_check"

View File

@@ -3134,3 +3134,215 @@ 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,6 +22,7 @@ $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,9 +713,15 @@ 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' => $result['data'], 'data' => $data,
'pagination' => [ 'pagination' => [
'page' => $result['page'], 'page' => $result['page'],
'per_page' => $result['per_page'], 'per_page' => $result['per_page'],

View File

@@ -7,6 +7,7 @@ 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;
@@ -80,4 +81,22 @@ 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

@@ -154,6 +154,12 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
<?= htmlspecialchars($activity['actor_name'] ?? $activity['username'] ?? 'System') ?> <?= htmlspecialchars($activity['actor_name'] ?? $activity['username'] ?? 'System') ?>
&middot; <?= date('H:i', strtotime($activity['created_at'] ?? '')) ?> &middot; <?= date('H:i', strtotime($activity['created_at'] ?? '')) ?>
</span> </span>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
@@ -298,9 +304,4 @@ if (aggregatedData.length > 0) {
setInterval(refreshAggregatedChart, 30000); setInterval(refreshAggregatedChart, 30000);
</script> </script>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>

View File

@@ -4,6 +4,17 @@
<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">
@@ -29,6 +40,12 @@
<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>
@@ -135,6 +152,34 @@
<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>
@@ -169,6 +214,171 @@
<?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

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