feat/fcm-notifications #67

Closed
rafaga21 wants to merge 28 commits from feat/fcm-notifications into master
45 changed files with 2073 additions and 186 deletions

View File

@@ -38,12 +38,22 @@ Repo-specific guidance for OpenCode sessions. Verified against code.
- Admin group routes (`/admin/*`) already include `AuthMiddleware` + `RoleMiddleware`; individual routes add `CSRFMiddleware`. - Admin group routes (`/admin/*`) already include `AuthMiddleware` + `RoleMiddleware`; individual routes add `CSRFMiddleware`.
- Rate limiting on `POST /login` only (via `RateLimitMiddleware`) - Rate limiting on `POST /login` only (via `RateLimitMiddleware`)
- Every credential access must be logged at `warning` level via `AuditService` - Every credential access must be logged at `warning` level via `AuditService`
- Notification read receipts tracked in `notification_reads` table: `(notification_id, user_id)` unique pair, with `read_at` timestamp
## Permission validation on server create/edit
- When creating or editing a server, required SSH permissions can be specified and are validated via SSH before saving.
- `PermissionValidator::validate(array $serverConfig, array $permissions)` connects to the remote server and runs check commands for each permission.
- Permission types: `sudo` (checks passwordless sudo), `command` (checks `command -v`), `file_read` (`test -r`), `file_write` (`test -w`), `custom` (arbitrary command, exit code 0 = pass).
- `ServerPermission` model handles CRUD for the `server_permissions` table.
- Both web (`ServerController::store/update`) and API (`ApiController::serverAdd/serverUpdate`) flows include permission validation.
- On validation failure, the web flow preserves form input via `$_SESSION['_form_input']` and redirects back with error details.
## Database ## Database
- Migrations run manually: `mysql servermanager < database/migrations/NNN_name.sql` - Migrations run manually: `mysql servermanager < database/migrations/NNN_name.sql`
- 5 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications` - 8 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks`, `008_notification_reads`
- Users table has `role` ENUM: `super_admin`, `admin`, `operator` - Users table has `role` ENUM: `super_admin`, `admin`, `operator`
- `api_token` column on users for Bearer token auth - `api_token` column on users for Bearer token auth
- `server_permissions` table stores required SSH permissions per server (FK to `servers.id` CASCADE)
## Testing / CI ## Testing / CI
- **No tests, no CI, no linter, no formatter, no typechecker** exist in this repo - **No tests, no CI, no linter, no formatter, no typechecker** exist in this repo

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
} }
android { android {
@@ -12,8 +13,8 @@ android {
applicationId = "com.devlab.app" applicationId = "com.devlab.app"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 9 versionCode = 13
versionName = "1.7.0" versionName = "1.8.1"
} }
buildTypes { buildTypes {
@@ -65,6 +66,11 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.work:work-runtime-ktx:2.10.0") implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation(platform("com.google.firebase:firebase-bom:34.14.1"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-messaging-ktx:24.1.0")
implementation("com.google.firebase:firebase-common-ktx:21.0.0")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
} }

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "402296861916",
"project_id": "servermanager-4cf7a",
"storage_bucket": "servermanager-4cf7a.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:402296861916:android:37b5195fc3d5cbcd7eb019",
"android_client_info": {
"package_name": "com.devlab.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBvcAfrLKY9XYHbc8Pv2dWTj-U4neSZ6nY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" /> <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
@@ -35,6 +37,19 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<service
android:name=".service.NotificationForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".service.ServerManagerFirebaseService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View File

@@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -58,7 +61,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermission() requestNotificationPermission()
checkForUpdates() checkForUpdates()
NotificationWorker.checkNow(this)
lifecycle.addObserver(LifecycleEventObserver { _, event -> lifecycle.addObserver(LifecycleEventObserver { _, event ->
when (event) { when (event) {
@@ -133,12 +135,6 @@ fun AppRoot() {
var isLoggedIn by remember { mutableStateOf(false) } var isLoggedIn by remember { mutableStateOf(false) }
var unreadCount by remember { mutableIntStateOf(0) } var unreadCount by remember { mutableIntStateOf(0) }
LaunchedEffect(isLoggedIn) {
if (isLoggedIn) {
NotificationWorker.checkNow(context)
}
}
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
@@ -158,7 +154,6 @@ fun AppRoot() {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
SessionManager.consumeLogoutRequest() SessionManager.consumeLogoutRequest()
SessionManager.restoreSession()
} }
} }
} }
@@ -229,7 +224,14 @@ fun AppRoot() {
startDestination = if (isLoggedIn) Screen.Dashboard.route else Screen.Login.route, startDestination = if (isLoggedIn) Screen.Dashboard.route else Screen.Login.route,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(
PaddingValues(
start = 0.dp,
top = innerPadding.calculateTopPadding(),
end = 0.dp,
bottom = if (inLoggedInArea) innerPadding.calculateBottomPadding() else 0.dp
)
),
enterTransition = { slideInHorizontally(initialOffsetX = { it / 4 }) + fadeIn(animationSpec = tween(300)) }, enterTransition = { slideInHorizontally(initialOffsetX = { it / 4 }) + fadeIn(animationSpec = tween(300)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) },
popEnterTransition = { fadeIn(animationSpec = tween(200)) }, popEnterTransition = { fadeIn(animationSpec = tween(200)) },

View File

@@ -1,6 +1,7 @@
package com.devlab.app package com.devlab.app
import android.app.Application import android.app.Application
import com.devlab.app.service.NotificationForegroundService
import com.devlab.app.util.NotificationHelper import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.NotificationPoller import com.devlab.app.util.NotificationPoller
import com.devlab.app.worker.NotificationWorker import com.devlab.app.worker.NotificationWorker
@@ -14,6 +15,7 @@ class ServerManagerApp : Application() {
instance = this instance = this
NotificationHelper.createChannels(this) NotificationHelper.createChannels(this)
NotificationWorker.schedule(this) NotificationWorker.schedule(this)
NotificationForegroundService.start(this)
notificationPoller = NotificationPoller(this) notificationPoller = NotificationPoller(this)
} }

View File

@@ -79,4 +79,7 @@ interface ApiService {
@GET("api/notifications/unread-count") @GET("api/notifications/unread-count")
suspend fun getUnreadCount(): UnreadCountResponse suspend fun getUnreadCount(): UnreadCountResponse
@POST("api/fcm/register")
suspend fun registerFcmToken(@Body request: FcmRegisterRequest): ApiResponse<Map<String, Boolean>>
} }

View File

@@ -11,7 +11,7 @@ class AuthInterceptor(private val token: String) : Interceptor {
.build() .build()
val response = chain.proceed(request) val response = chain.proceed(request)
if (response.code == 401 && token.isNotBlank()) { if (response.code == 401 && token.isNotBlank() && SessionManager.isSessionValid.value) {
SessionManager.invalidateSession() SessionManager.invalidateSession()
} }

View File

@@ -12,15 +12,20 @@ object SessionManager {
private val _onLogoutRequest = MutableStateFlow(false) private val _onLogoutRequest = MutableStateFlow(false)
val onLogoutRequest: StateFlow<Boolean> = _onLogoutRequest.asStateFlow() val onLogoutRequest: StateFlow<Boolean> = _onLogoutRequest.asStateFlow()
fun invalidateSession() { private val _invalidating = MutableStateFlow(false)
fun invalidateSession(): Boolean {
if (_invalidating.value) return false
_invalidating.value = true
_isSessionValid.value = false _isSessionValid.value = false
_onLogoutRequest.value = true _onLogoutRequest.value = true
RetrofitClient.setToken("") RetrofitClient.setToken("")
return true
} }
fun restoreSession() { fun restoreSession() {
_isSessionValid.value = true _isSessionValid.value = true
_onLogoutRequest.value = false _invalidating.value = false
} }
fun consumeLogoutRequest() { fun consumeLogoutRequest() {

View File

@@ -0,0 +1,6 @@
package com.devlab.app.data.model
@kotlinx.serialization.Serializable
data class FcmRegisterRequest(
val token: String
)

View File

@@ -0,0 +1,148 @@
package com.devlab.app.service
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.devlab.app.MainActivity
import com.devlab.app.R
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
class NotificationForegroundService : android.app.Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pollingJob: Job? = null
private var lastCheck: Long = 0L
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand")
val notification = buildPersistentNotification()
startForeground(NOTIFICATION_ID, notification)
if (pollingJob?.isActive != true) {
lastCheck = System.currentTimeMillis()
pollingJob = scope.launch {
while (isActive) {
try {
pollNotifications()
} catch (_: Exception) { }
delay(POLL_INTERVAL_MS)
}
}
Log.d(TAG, "Polling started every ${POLL_INTERVAL_MS / 1000}s")
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
pollingJob?.cancel()
scope.cancel()
Log.d(TAG, "Service destroyed")
}
private fun buildPersistentNotification(): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingOpen = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_SERVICE)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(getString(R.string.app_name))
.setContentText("Monitoring for notifications")
.setContentIntent(pendingOpen)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private suspend fun pollNotifications() {
val prefs = PreferencesManager(this)
val token = prefs.getToken()
if (token.isBlank()) return
val now = System.currentTimeMillis()
RetrofitClient.setToken(token)
val service = RetrofitClient.getApiService()
val response = service.getNotifications(page = 1, perPage = 10)
if (!response.success) return
val newNotifications = response.data.filter { note ->
val noteTime = parseTime(note.created_at)
noteTime > lastCheck && note.is_read == 0
}
if (newNotifications.isNotEmpty()) {
Log.d(TAG, "Found ${newNotifications.size} new notifications")
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
}
}
companion object {
private const val TAG = "NotificationFgSvc"
private const val NOTIFICATION_ID = 1001
const val CHANNEL_SERVICE = "service_status"
private const val POLL_INTERVAL_MS = 30_000L
fun start(context: Context) {
val intent = Intent(context, NotificationForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
Log.d(TAG, "Start command sent")
}
fun stop(context: Context) {
context.stopService(Intent(context, NotificationForegroundService::class.java))
Log.d(TAG, "Stop command sent")
}
}
}

View File

@@ -0,0 +1,76 @@
package com.devlab.app.service
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.model.AppNotification
import com.devlab.app.data.model.FcmRegisterRequest
import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ServerManagerFirebaseService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "New FCM token: $token")
CoroutineScope(Dispatchers.IO).launch {
val prefs = PreferencesManager(this@ServerManagerFirebaseService)
prefs.saveFcmToken(token)
val apiToken = prefs.getToken()
if (apiToken.isNotBlank()) {
registerTokenWithBackend(token, apiToken)
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
Log.d(TAG, "FCM message received: ${message.data}")
val title = message.notification?.title
?: message.data["title"]
?: "Notification"
val body = message.notification?.body
?: message.data["message"]
?: ""
val type = message.data["type"] ?: "info"
val noteId = (message.data["notification_id"] ?: "0").toIntOrNull() ?: 0
showNotification(title, body, type, noteId)
}
private fun showNotification(title: String, body: String, type: String, noteId: Int) {
val notification = AppNotification(
id = noteId,
title = title,
message = body,
type = type,
is_read = 0,
created_at = "",
)
NotificationHelper.showNotification(this, notification)
}
private suspend fun registerTokenWithBackend(token: String, apiToken: String) {
try {
RetrofitClient.setToken(apiToken)
val service = RetrofitClient.getApiService()
service.registerFcmToken(FcmRegisterRequest(token))
Log.d(TAG, "FCM token registered with backend")
} catch (e: Exception) {
Log.e(TAG, "Failed to register FCM token: ${e.message}")
}
}
companion object {
private const val TAG = "ServerManagerFCM"
}
}

View File

@@ -1,15 +1,22 @@
package com.devlab.app.ui.login package com.devlab.app.ui.login
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.devlab.app.ServerManagerApp import com.devlab.app.ServerManagerApp
import com.devlab.app.data.api.RetrofitClient import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.api.SessionManager
import com.devlab.app.data.repository.AuthRepository import com.devlab.app.data.repository.AuthRepository
import com.devlab.app.data.model.FcmRegisterRequest
import com.devlab.app.util.PreferencesManager import com.devlab.app.util.PreferencesManager
import com.google.android.gms.tasks.Tasks
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.Dispatchers
data class LoginUiState( data class LoginUiState(
val username: String = "", val username: String = "",
@@ -37,6 +44,7 @@ class LoginViewModel : ViewModel() {
isConnected = true, isConnected = true,
username = savedUsername username = savedUsername
) )
registerFcmToken()
} }
} }
} }
@@ -70,11 +78,13 @@ class LoginViewModel : ViewModel() {
loginResult.token, loginResult.userId, loginResult.token, loginResult.userId,
loginResult.username, loginResult.email, loginResult.role loginResult.username, loginResult.email, loginResult.role
) )
SessionManager.restoreSession()
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
isConnected = true, isConnected = true,
error = null error = null
) )
registerFcmToken()
}, },
onFailure = { e -> onFailure = { e ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -87,10 +97,42 @@ class LoginViewModel : ViewModel() {
} }
fun logout() { fun logout() {
_uiState.value = LoginUiState()
RetrofitClient.setToken("")
viewModelScope.launch { viewModelScope.launch {
prefs.clear() prefs.clear()
RetrofitClient.setToken("")
_uiState.value = LoginUiState()
} }
} }
}
private suspend fun registerFcmToken() {
try {
var fcmToken = prefs.getFcmToken()
if (fcmToken.isBlank()) {
fcmToken = withContext(Dispatchers.IO) {
try {
Tasks.await(FirebaseMessaging.getInstance().token)
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch FCM token from Firebase: ${e.message}")
""
}
}
if (fcmToken.isNotBlank()) {
prefs.saveFcmToken(fcmToken)
}
}
if (fcmToken.isNotBlank()) {
val service = RetrofitClient.getApiService()
service.registerFcmToken(FcmRegisterRequest(fcmToken))
Log.d(TAG, "FCM token registered with backend")
} else {
Log.w(TAG, "FCM token is blank, skipping registration")
}
} catch (e: Exception) {
Log.e(TAG, "FCM token registration failed: ${e.message}")
}
}
companion object {
private const val TAG = "LoginViewModel"
}
}

View File

@@ -14,11 +14,13 @@ import androidx.core.content.ContextCompat
import com.devlab.app.MainActivity import com.devlab.app.MainActivity
import com.devlab.app.R import com.devlab.app.R
import com.devlab.app.data.model.AppNotification import com.devlab.app.data.model.AppNotification
import com.devlab.app.service.NotificationForegroundService
object NotificationHelper { object NotificationHelper {
private const val CHANNEL_GENERAL = "notifications_general" private const val CHANNEL_GENERAL = "notifications_general"
private const val CHANNEL_ALERTS = "notifications_alerts" private const val CHANNEL_ALERTS = "notifications_alerts"
private const val CHANNEL_SERVICE = "service_status"
fun createChannels(context: Context) { fun createChannels(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -37,8 +39,17 @@ object NotificationHelper {
description = "Important alerts requiring attention" description = "Important alerts requiring attention"
} }
val service = NotificationChannel(
CHANNEL_SERVICE, "Service Status",
NotificationManager.IMPORTANCE_MIN
).apply {
description = "Background monitoring service indicator"
setShowBadge(false)
}
manager.createNotificationChannel(general) manager.createNotificationChannel(general)
manager.createNotificationChannel(alerts) manager.createNotificationChannel(alerts)
manager.createNotificationChannel(service)
} }
fun showNotification(context: Context, notification: AppNotification) { fun showNotification(context: Context, notification: AppNotification) {

View File

@@ -20,6 +20,7 @@ class PreferencesManager(private val context: Context) {
private val KEY_EMAIL = stringPreferencesKey("email") private val KEY_EMAIL = stringPreferencesKey("email")
private val KEY_ROLE = stringPreferencesKey("role") private val KEY_ROLE = stringPreferencesKey("role")
private val KEY_USER_ID = intPreferencesKey("user_id") private val KEY_USER_ID = intPreferencesKey("user_id")
private val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token")
private val KEY_LAST_NOTIFICATION_CHECK = longPreferencesKey("last_notification_check") private val KEY_LAST_NOTIFICATION_CHECK = longPreferencesKey("last_notification_check")
} }
@@ -80,6 +81,14 @@ class PreferencesManager(private val context: Context) {
} }
} }
suspend fun saveFcmToken(token: String) {
context.dataStore.edit { prefs ->
prefs[KEY_FCM_TOKEN] = token
}
}
suspend fun getFcmToken(): String = context.dataStore.data.first()[KEY_FCM_TOKEN] ?: ""
suspend fun clear() { suspend fun clear() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }

View File

@@ -2,4 +2,5 @@ plugins {
id("com.android.application") version "9.2.1" apply false id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
} }

Binary file not shown.

View File

@@ -2,6 +2,8 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

168
android/gradlew vendored
View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -31,25 +31,53 @@
# #
# ksh Gradle # ksh Gradle
# #
# Busybox and similar reduced functionality shells and target # Busybox and similar reduced shells will NOT work, because this script
# temporary focusing, current function. # requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
# #
# (2) You need a Java installation to run Gradle. If JAVA_HOME is set, it # Important for patching:
# will be used. Otherwise, java from PATH will be used. #
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
# #
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
app_path=$0 app_path=$0
# Need this for daisy-chained symlinks.
while while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ] [ -h "$app_path" ]
do do
ls=$( ls -ld -- "$app_path" ) ls=$( ls -ld "$app_path" )
link=${ls#*' -> '} link=${ls#*' -> '}
case $link in case $link in #(
/*) app_path=$link ;; /*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;; *) app_path=$APP_HOME$link ;;
esac esac
done done
@@ -79,18 +107,19 @@ cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "$( uname )" in case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; MSYS* | MINGW* ) msys=true ;; #(
NonStop* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java JAVACMD=$JAVA_HOME/jre/sh/java
else else
JAVACMD=$JAVA_HOME/bin/java JAVACMD=$JAVA_HOME/bin/java
@@ -114,41 +143,106 @@ fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
;;
esac esac
case $MAX_FD in case $MAX_FD in #(
'' | soft) :;; '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac esac
fi fi
# Collect all arguments for the java command, stracks://processed items. # Collect all arguments for the java command, stacking in reverse order:
# shellcheck disable=SC2153 # * args from the command line
case $TERM in # * the main class name
dumb | '' ) : ;; # * -classpath
* ) eval `resize 2>/dev/null` ;; # * -D...appname settings
esac # * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command; # For Cygwin or MSYS, switch paths to Windows format before running java
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of if "$cygwin" || "$msys" ; then
# shell script including quotes and/or backslashes, so put them in APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
# temporary files to avoid running into problems with process substitution.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xeli" is not available. JAVACMD=$( cygpath --unix "$JAVACMD" )
if ! "$cygwin" && ! "$msys" && ! "$nonstop" ; then
exec "$JAVACMD" "$@" # Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"

82
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@@ -60,6 +60,10 @@ return [
'rate_limit_window' => (int) ($_ENV['API_RATE_LIMIT_WINDOW'] ?? 60), 'rate_limit_window' => (int) ($_ENV['API_RATE_LIMIT_WINDOW'] ?? 60),
], ],
'fcm' => [
'server_key' => $_ENV['FCM_SERVER_KEY'] ?? '',
],
'log' => [ 'log' => [
'path' => $_ENV['LOG_PATH'] ?? __DIR__ . '/../logs/', 'path' => $_ENV['LOG_PATH'] ?? __DIR__ . '/../logs/',
'level' => $_ENV['LOG_LEVEL'] ?? 'warning', 'level' => $_ENV['LOG_LEVEL'] ?? 'warning',

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `server_permissions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`server_id` INT UNSIGNED NOT NULL,
`permission_type` ENUM('sudo', 'command', 'file_read', 'file_write', 'custom') NOT NULL,
`permission_value` VARCHAR(255) NOT NULL,
`description` VARCHAR(255) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_server_id` (`server_id`),
CONSTRAINT `fk_permissions_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `notification_reads` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`notification_id` INT UNSIGNED NOT NULL,
`user_id` INT UNSIGNED NOT NULL,
`read_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_notification_user` (`notification_id`, `user_id`),
KEY `idx_notification_id` (`notification_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Migrate existing read receipts for user-targeted notifications
INSERT IGNORE INTO `notification_reads` (`notification_id`, `user_id`, `read_at`)
SELECT n.id, u.id, COALESCE(n.read_at, n.created_at)
FROM notifications n
JOIN users u ON u.id = n.user_id AND u.status = 'active'
WHERE n.is_read = 1
AND n.user_id IS NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE `notifications`
DROP COLUMN `is_read`,
DROP COLUMN `read_at`;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `user_fcm_tokens` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`token` VARCHAR(255) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_token` (`user_id`, `token`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -11,7 +11,7 @@ if [ -z "$SERVER_URL" ] || [ -z "$AGENT_KEY" ]; then
exit 1 exit 1
fi fi
# Detect if we can run as root (try non-interactive sudo) # Detect if we can run as root
CAN_ROOT=false CAN_ROOT=false
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
CAN_ROOT=true CAN_ROOT=true
@@ -20,9 +20,7 @@ elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
fi fi
if [ "$CAN_ROOT" = true ]; then if [ "$CAN_ROOT" = true ]; then
# ── Root/system mode ──
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
# Re-exec with script PTY if available (handles requiretty)
if command -v script >/dev/null 2>&1; then if command -v script >/dev/null 2>&1; then
exec script -q -c "sudo bash '$0' '$1' '$2' '$3'" /dev/null exec script -q -c "sudo bash '$0' '$1' '$2' '$3'" /dev/null
fi fi
@@ -30,95 +28,76 @@ if [ "$CAN_ROOT" = true ]; then
fi fi
AGENT_BIN="/usr/local/bin/servermanager-agent" AGENT_BIN="/usr/local/bin/servermanager-agent"
CONFIG_DIR="/etc/servermanager"
CONFIG_FILE="$CONFIG_DIR/agent.conf"
SERVICE_FILE="/etc/systemd/system/servermanager-agent.service" SERVICE_FILE="/etc/systemd/system/servermanager-agent.service"
USE_SYSTEMD=true USE_SYSTEMD=true
INSTALL_MODE="system" INSTALL_MODE="system"
else else
# ── User/no-root mode ──
AGENT_BIN="$HOME/.local/bin/servermanager-agent" AGENT_BIN="$HOME/.local/bin/servermanager-agent"
CONFIG_DIR="$HOME/.config/servermanager"
CONFIG_FILE="$CONFIG_DIR/agent.conf"
USE_SYSTEMD=false USE_SYSTEMD=false
INSTALL_MODE="user" INSTALL_MODE="user"
fi fi
echo "[1/4] Creating directories..." echo "[1/3] Creating directory..."
mkdir -p "$(dirname "$AGENT_BIN")" mkdir -p "$(dirname "$AGENT_BIN")"
mkdir -p "$CONFIG_DIR"
echo "[2/4] Installing agent binary..." echo "[2/3] Installing agent binary..."
cat > "$AGENT_BIN" << 'AGENTSCRIPT' cat > "$AGENT_BIN" << EOF
#!/bin/bash #!/bin/bash
set -e
CONFIG_FILE="" SERVER_URL="$SERVER_URL"
if [ -f "/etc/servermanager/agent.conf" ]; then AGENT_KEY="$AGENT_KEY"
CONFIG_FILE="/etc/servermanager/agent.conf" INTERVAL=$INTERVAL
elif [ -f "$HOME/.config/servermanager/agent.conf" ]; then
CONFIG_FILE="$HOME/.config/servermanager/agent.conf"
fi
load_config() {
if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
SERVER_URL="${SERVER_URL:-}"
AGENT_KEY="${AGENT_KEY:-}"
INTERVAL="${INTERVAL:-60}"
}
collect_metrics() { collect_metrics() {
CPU=$(top -bn1 2>/dev/null | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1) CPU=\$(LC_ALL=C top -bn1 2>/dev/null | grep 'Cpu(s)' | awk '{print \$2}' | cut -d'%' -f1 || true)
[ -z "$CPU" ] && CPU=0 [ -z "\$CPU" ] && CPU=0
RAM=$(free 2>/dev/null | grep Mem | awk '{printf "%.1f", $3/$2 * 100}') RAM=\$(LC_ALL=C free 2>/dev/null | grep Mem | awk '{printf "%.1f", \$3/\$2 * 100}' || true)
[ -z "$RAM" ] && RAM=0 [ -z "\$RAM" ] && RAM=0
DISK=$(df / 2>/dev/null | tail -1 | awk '{print $5}' | sed 's/%//') DISK=\$(df / 2>/dev/null | tail -1 | awk '{print \$5}' | sed 's/%//' || true)
[ -z "$DISK" ] && DISK=0 [ -z "\$DISK" ] && DISK=0
LOAD=$(cat /proc/loadavg 2>/dev/null | awk '{print $1}') LOAD=\$(cat /proc/loadavg 2>/dev/null | awk '{print \$1}' || true)
[ -z "$LOAD" ] && LOAD=0 [ -z "\$LOAD" ] && LOAD=0
UPTIME=$(uptime -p 2>/dev/null | sed 's/^up //') UPTIME=\$(uptime -p 2>/dev/null | sed 's/^up //' || true)
[ -z "$UPTIME" ] && UPTIME="" [ -z "\$UPTIME" ] && UPTIME=""
} }
push_metrics() { push_metrics() {
payload=$(cat <<EOF payload=\$(cat << PAYLOAD
{ {
"agent_key": "$AGENT_KEY", "agent_key": "\$AGENT_KEY",
"cpu": $CPU, "cpu": \$CPU,
"ram": $RAM, "ram": \$RAM,
"disk": $DISK, "disk": \$DISK,
"load": $LOAD, "load": \$LOAD,
"uptime": "$UPTIME" "uptime": "\$UPTIME"
} }
EOF PAYLOAD
) )
curl -s -X POST "$SERVER_URL/api/metrics/push" \ local code
code=\$(curl -s -X POST "$SERVER_URL/api/metrics/push" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$payload" \ -d "\$payload" \
--connect-timeout 10 --max-time 30 \ --connect-timeout 10 --max-time 30 \
-o /dev/null -w "%{http_code}" 2>/dev/null || echo "000" -o /dev/null -w "%{http_code}" 2>/dev/null) || code=0
echo "\$code"
} }
run() { echo "ServerManager Agent started"
load_config echo " Server: $SERVER_URL/api/metrics/push"
if [ -z "$SERVER_URL" ] || [ -z "$AGENT_KEY" ]; then echo " Interval: \${INTERVAL}s"
echo "ERROR: SERVER_URL and AGENT_KEY not configured" echo " Host: \$(hostname)"
exit 1 sleep 2
while true; do
collect_metrics
http_code=\$(push_metrics)
echo "[\$(date)] HTTP \$http_code"
if [ "\$http_code" = "0" ]; then
echo " curl failed — check SERVER_URL=$SERVER_URL"
fi fi
echo "ServerManager Agent started (interval: ${INTERVAL}s)" sleep "\$INTERVAL"
sleep 2 done
while true; do EOF
collect_metrics
http_code=$(push_metrics)
echo "[$(date)] HTTP $http_code"
sleep "$INTERVAL"
done
}
run
AGENTSCRIPT
chmod +x "$AGENT_BIN" chmod +x "$AGENT_BIN"
if [ ! -f "$AGENT_BIN" ]; then if [ ! -f "$AGENT_BIN" ]; then
@@ -127,15 +106,7 @@ if [ ! -f "$AGENT_BIN" ]; then
fi fi
echo " Binary: $AGENT_BIN ($(wc -c < "$AGENT_BIN") bytes)" echo " Binary: $AGENT_BIN ($(wc -c < "$AGENT_BIN") bytes)"
echo "[3/4] Writing config..." echo "[3/3] Enabling auto-start..."
cat > "$CONFIG_FILE" << EOF
SERVER_URL="$SERVER_URL"
AGENT_KEY="$AGENT_KEY"
INTERVAL=$INTERVAL
EOF
chmod 600 "$CONFIG_FILE"
echo "[4/4] Enabling auto-start..."
if [ "$USE_SYSTEMD" = true ]; then if [ "$USE_SYSTEMD" = true ]; then
echo " Using systemd service..." echo " Using systemd service..."
@@ -157,15 +128,20 @@ UNIT
systemctl restart servermanager-agent 2>/dev/null || true systemctl restart servermanager-agent 2>/dev/null || true
else else
echo " Using crontab (@reboot)..." echo " Using crontab (@reboot)..."
CRON_JOB="@reboot $AGENT_BIN > $CONFIG_DIR/agent.log 2>&1" LOG_DIR="$HOME/.local/share/servermanager"
(crontab -l 2>/dev/null | grep -v '_sm_agent\|servermanager-agent'; echo "$CRON_JOB") | crontab - 2>/dev/null || { mkdir -p "$LOG_DIR"
echo "WARNING: Could not install crontab. Agent must be started manually." CRON_JOB="@reboot $AGENT_BIN > $LOG_DIR/agent.log 2>&1"
(crontab -l 2>/dev/null | grep -v 'servermanager-agent'; echo "$CRON_JOB") | crontab - 2>/dev/null || {
echo "WARNING: Could not install crontab."
echo " Run: $AGENT_BIN &" echo " Run: $AGENT_BIN &"
} }
echo " Starting agent in background..."
nohup "$AGENT_BIN" > "$LOG_DIR/agent.log" 2>&1 & disown
echo " Agent started (PID $!)"
fi fi
echo "" echo ""
echo "ServerManager Agent installed successfully!" echo "ServerManager Agent installed successfully!"
echo " Mode: $INSTALL_MODE" echo " Mode: $INSTALL_MODE"
echo " Binary: $AGENT_BIN" echo " Binary: $AGENT_BIN"
echo " Config: $CONFIG_FILE" echo " Config: embedded in binary (no external config file)"

46
install/firebase-setup.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Firebase Cloud Messaging setup script
# =======================================
#
# This script creates placeholder files for FCM integration.
# You must replace them with real values from your Firebase project.
#
# Steps to set up Firebase:
#
# 1. Go to https://console.firebase.google.com/ and create a project
# (or use an existing one)
#
# 2. Add an Android app to your Firebase project with package name:
# com.devlab.app
#
# 3. Download google-services.json and place it at:
# android/app/google-services.json
#
# 4. Go to Project Settings > Cloud Messaging and copy the
# Server key (legacy) or Cloud Messaging API key
#
# 5. Set the server key in .env:
# FCM_SERVER_KEY=your_server_key_here
#
# 6. OR place your Firebase Admin service account key at:
# config/firebase-service-account.json
# (for the OAuth-based API - more secure)
#
# The app will work without FCM configured; push notifications
# simply won't be sent until Firebase is set up.
#
# ---
echo "Firebase setup script"
echo "====================="
echo ""
echo "To enable push notifications:"
echo ""
echo "1. Create a Firebase project: https://console.firebase.google.com"
echo "2. Add Android app with package: com.devlab.app"
echo "3. Download google-services.json → android/app/google-services.json"
echo "4. Get Server Key from Cloud Messaging settings"
echo "5. Add to .env: FCM_SERVER_KEY=your_key_here"
echo ""
echo "Without these steps, push notifications are disabled."
echo "The app will continue to work using polling as fallback."

View File

@@ -1008,6 +1008,7 @@ textarea.form-input {
.badge-warning { background: var(--warning-bg); color: var(--warning); } .badge-warning { background: var(--warning-bg); color: var(--warning); }
.badge-info { background: var(--info-bg); color: var(--info); } .badge-info { background: var(--info-bg); color: var(--info); }
.badge-purple { background: var(--purple-bg); color: var(--purple); } .badge-purple { background: var(--purple-bg); color: var(--purple); }
.badge-secondary { background: rgba(156, 163, 175, 0.15); color: #9ca3af; }
/* ========================================================================== /* ==========================================================================
Mini Progress Mini Progress
@@ -3071,3 +3072,65 @@ textarea.form-input {
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
} }
/* --- Server permission rows --- */
.permission-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
padding: 0.5rem;
background: var(--bg-secondary, #f8f9fa);
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
}
.permission-row .permission-type {
flex: 0 0 160px;
}
.permission-row .permission-value {
flex: 1 1 auto;
}
.permission-row .permission-desc {
flex: 0 0 200px;
}
.permission-row .btn-danger {
flex: 0 0 auto;
}
.permission-actions {
margin-top: 0.5rem;
}
.permission-presets {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.permission-presets .preset-label {
font-size: 0.8rem;
color: var(--text-muted, #888);
margin-right: 0.25rem;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border-color, #ccc);
color: var(--text-primary, #333);
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.btn-outline:hover {
background: var(--bg-hover, #e9ecef);
border-color: var(--text-muted, #888);
}

Binary file not shown.

View File

@@ -21,6 +21,7 @@ $router->get('/', [DashboardController::class, 'index'], [AuthMiddleware::class]
$router->get('/dashboard', [DashboardController::class, 'index'], [AuthMiddleware::class]); $router->get('/dashboard', [DashboardController::class, 'index'], [AuthMiddleware::class]);
$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('/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]);
@@ -123,6 +124,7 @@ $router->get('/api/profile', [ApiController::class, 'profile']);
$router->put('/api/profile', [ApiController::class, 'updateProfile']); $router->put('/api/profile', [ApiController::class, 'updateProfile']);
$router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']); $router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']);
$router->post('/api/metrics/push', [ApiController::class, 'pushMetrics']); $router->post('/api/metrics/push', [ApiController::class, 'pushMetrics']);
$router->post('/api/fcm/register', [ApiController::class, 'registerFcmToken']);
$router->post('/servers/:id/agent/install', [ServerController::class, 'agentInstall'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->post('/servers/:id/agent/install', [ServerController::class, 'agentInstall'], [AuthMiddleware::class, CSRFMiddleware::class]);
$router->post('/servers/:id/agent/uninstall', [ServerController::class, 'agentUninstall'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->post('/servers/:id/agent/uninstall', [ServerController::class, 'agentUninstall'], [AuthMiddleware::class, CSRFMiddleware::class]);

View File

@@ -11,6 +11,7 @@ use ServerManager\Core\Validator;
use ServerManager\Models\Notification; use ServerManager\Models\Notification;
use ServerManager\Models\User; use ServerManager\Models\User;
use ServerManager\Services\AuditService; use ServerManager\Services\AuditService;
use ServerManager\Services\FCMService;
class AdminController class AdminController
{ {
@@ -255,10 +256,13 @@ class AdminController
$userModel = new User(); $userModel = new User();
$users = $userModel->getAll(1, 200); $users = $userModel->getAll(1, 200);
$totalActiveUsers = $notificationModel->getTotalActiveUsers();
$this->view->display('admin.notifications', [ $this->view->display('admin.notifications', [
'title' => 'Notifications - ServerManager', 'title' => 'Notifications - ServerManager',
'notifications' => $allNotifications, 'notifications' => $allNotifications,
'users' => $users, 'users' => $users,
'totalActiveUsers' => $totalActiveUsers,
]); ]);
} }
@@ -277,15 +281,29 @@ class AdminController
} }
$notificationModel = new Notification(); $notificationModel = new Notification();
$notificationModel->create([ $noteId = $notificationModel->create([
'user_id' => $userId, 'user_id' => $userId,
'title' => $title, 'title' => $title,
'message' => $message, 'message' => $message,
'type' => $type, 'type' => $type,
]); ]);
$this->auditService->log('notification_sent', 'notification', null, ['title' => $title, 'target' => $userId ? "user #{$userId}" : 'all users']); $fcm = new FCMService();
$pushResult = '';
if ($userId) {
$sent = $fcm->sendToUser($userId, $title, $message, $type, $noteId);
$pushResult = $sent ? ' (push sent)' : ($fcm->isConfigured() ? ' (push failed)' : '');
} else {
$count = $fcm->sendToAll($title, $message, $type, $noteId);
$pushResult = $fcm->isConfigured() ? " (push sent to {$count} devices)" : '';
}
$this->view->json(['success' => true, 'message' => 'Notification sent successfully.']); $this->auditService->log('notification_sent', 'notification', null, [
'title' => $title,
'target' => $userId ? "user #{$userId}" : 'all users',
'push' => $fcm->isConfigured() ? 'sent' : 'not_configured',
]);
$this->view->json(['success' => true, 'message' => 'Notification sent successfully.' . $pushResult]);
} }
} }

View File

@@ -7,10 +7,13 @@ 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\ServerPermission;
use ServerManager\Models\User; use ServerManager\Models\User;
use ServerManager\Services\MonitoringService; use ServerManager\Services\MonitoringService;
use ServerManager\Services\SSHService; use ServerManager\Services\SSHService;
use ServerManager\Services\AuditService; use ServerManager\Services\AuditService;
use ServerManager\Services\PermissionValidator;
use ServerManager\Services\FCMService;
use ServerManager\Models\CommandHistory; use ServerManager\Models\CommandHistory;
use ServerManager\Core\Validator; use ServerManager\Core\Validator;
@@ -143,9 +146,47 @@ class ApiController
$this->view->json(['error' => $validator->getFirstError()], 400); $this->view->json(['error' => $validator->getFirstError()], 400);
} }
$permissions = $this->extractPermissionsFromInput($input);
if (!empty($permissions)) {
$serverConfig = [
'ip_address' => $input['ip_address'],
'ssh_port' => (int) $input['ssh_port'],
'ssh_user' => $input['ssh_user'],
'ssh_password' => $input['ssh_password'] ?? null,
'ssh_key' => $input['ssh_key'] ?? null,
];
$permValidator = new PermissionValidator();
$permResult = $permValidator->validate($serverConfig, $permissions);
if (!$permResult['passed']) {
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
$errors = [];
foreach ($failedPermissions as $fp) {
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
$errors[] = $label . ' — ' . $fp['message'];
}
$this->view->json([
'error' => 'Permission validation failed',
'permission_errors' => $errors,
], 400);
}
}
$serverModel = new Server(); $serverModel = new Server();
$id = $serverModel->create($input); $id = $serverModel->create($input);
if (!empty($permissions)) {
try {
$permModel = new ServerPermission();
$permModel->save($id, $permissions);
} catch (\Throwable $e) {
// Table may not exist yet
}
}
$this->view->json([ $this->view->json([
'success' => true, 'success' => true,
'message' => 'Server created successfully.', 'message' => 'Server created successfully.',
@@ -170,14 +211,74 @@ class ApiController
$this->view->json(['error' => 'Forbidden'], 403); $this->view->json(['error' => 'Forbidden'], 403);
} }
$permissions = $this->extractPermissionsFromInput($input);
if (!empty($permissions)) {
$serverConfig = [
'ip_address' => $input['ip_address'] ?? $server['ip_address'],
'ssh_port' => (int) ($input['ssh_port'] ?? $server['ssh_port']),
'ssh_user' => $input['ssh_user'] ?? $server['ssh_user'],
'ssh_password' => $input['ssh_password'] ?? $server['ssh_password'],
'ssh_key' => $input['ssh_key'] ?? $server['ssh_key'],
];
$permValidator = new PermissionValidator();
$permResult = $permValidator->validate($serverConfig, $permissions);
if (!$permResult['passed']) {
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
$errors = [];
foreach ($failedPermissions as $fp) {
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
$errors[] = $label . ' — ' . $fp['message'];
}
$this->view->json([
'error' => 'Permission validation failed',
'permission_errors' => $errors,
], 400);
}
}
$serverModel->update($id, $input); $serverModel->update($id, $input);
if (!empty($permissions)) {
try {
$permModel = new ServerPermission();
$permModel->save($id, $permissions);
} catch (\Throwable $e) {
// Table may not exist yet
}
}
$this->view->json([ $this->view->json([
'success' => true, 'success' => true,
'message' => 'Server updated successfully.', 'message' => 'Server updated successfully.',
]); ]);
} }
private function extractPermissionsFromInput(array $input): array
{
$permissions = $input['permissions'] ?? [];
if (!is_array($permissions)) {
return [];
}
$filtered = [];
foreach ($permissions as $perm) {
if (!empty($perm['type']) && isset($perm['value']) && $perm['value'] !== '') {
$filtered[] = [
'type' => $perm['type'],
'value' => trim($perm['value']),
'description' => trim($perm['description'] ?? ''),
];
}
}
return $filtered;
}
public function serverDelete(int $id): void public function serverDelete(int $id): void
{ {
$user = $this->authenticateRequest(); $user = $this->authenticateRequest();
@@ -300,7 +401,8 @@ class ApiController
public function pushMetrics(): void public function pushMetrics(): void
{ {
$input = json_decode(file_get_contents('php://input'), true); $rawBody = file_get_contents('php://input');
$input = json_decode($rawBody, true);
if (!$input || empty($input['agent_key'])) { if (!$input || empty($input['agent_key'])) {
$this->view->json(['error' => 'agent_key is required'], 401); $this->view->json(['error' => 'agent_key is required'], 401);
@@ -595,6 +697,34 @@ class ApiController
$this->view->json(['success' => true]); $this->view->json(['success' => true]);
} }
public function registerFcmToken(): void
{
$user = $this->authenticateRequest();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$token = $input['token'] ?? '';
if (empty($token)) {
$this->view->json(['error' => 'Token is required'], 400);
}
$db = \ServerManager\Core\Database::getInstance();
$existing = $db->fetch(
'SELECT id FROM user_fcm_tokens WHERE user_id = ? AND token = ?',
[(int) $user['id'], $token]
);
if (!$existing) {
$db->insert('user_fcm_tokens', [
'user_id' => (int) $user['id'],
'token' => $token,
'created_at' => date('Y-m-d H:i:s'),
]);
}
$this->view->json(['success' => true]);
}
public function serverServices(int $id): void public function serverServices(int $id): void
{ {
$user = $this->authenticateRequest(); $user = $this->authenticateRequest();

View File

@@ -34,12 +34,15 @@ class DashboardController
return in_array((int) $s['id'], $accessibleIds, true); return in_array((int) $s['id'], $accessibleIds, true);
})) : []; })) : [];
$aggregatedMetrics = $this->monitoringService->getAggregatedHistoricalMetrics($accessibleIds ?? []);
$recentActivity = $this->auditService->getRecentActivity(10, $userId); $recentActivity = $this->auditService->getRecentActivity(10, $userId);
$this->view->display('dashboard.index', [ $this->view->display('dashboard.index', [
'title' => 'Dashboard - ServerManager', 'title' => 'Dashboard - ServerManager',
'stats' => $stats, 'stats' => $stats,
'servers' => $servers, 'servers' => $servers,
'aggregatedMetrics' => $aggregatedMetrics,
'recentActivity' => $recentActivity, 'recentActivity' => $recentActivity,
]); ]);
} }
@@ -65,4 +68,16 @@ class DashboardController
'message' => 'Metrics refreshed', 'message' => 'Metrics refreshed',
]); ]);
} }
public function chartData(): void
{
$serverModel = new Server();
$accessibleIds = $serverModel->getAccessibleServerIds((int) Session::get('user_id'));
$data = $this->monitoringService->getAggregatedHistoricalMetrics($accessibleIds ?? []);
$this->view->json([
'success' => true,
'data' => $data,
]);
}
} }

View File

@@ -7,11 +7,14 @@ namespace ServerManager\Controllers;
use ServerManager\Core\App; use ServerManager\Core\App;
use ServerManager\Core\Session; use ServerManager\Core\Session;
use ServerManager\Core\Validator; use ServerManager\Core\Validator;
use ServerManager\Core\View;
use ServerManager\Models\Server; use ServerManager\Models\Server;
use ServerManager\Models\User; use ServerManager\Models\ServerPermission;
use ServerManager\Services\SSHService; use ServerManager\Services\SSHService;
use ServerManager\Services\MonitoringService;
use ServerManager\Services\AuditService; use ServerManager\Services\AuditService;
use ServerManager\Services\AgentService;
use ServerManager\Services\MonitoringService;
use ServerManager\Services\PermissionValidator;
use ServerManager\Models\CommandHistory; use ServerManager\Models\CommandHistory;
class ServerController class ServerController
@@ -83,9 +86,13 @@ class ServerController
{ {
$groups = $this->serverModel->getGroups(); $groups = $this->serverModel->getGroups();
$oldInput = $_SESSION['_form_input'] ?? [];
unset($_SESSION['_form_input']);
$this->view->display('servers.create', [ $this->view->display('servers.create', [
'title' => 'Add Server - ServerManager', 'title' => 'Add Server - ServerManager',
'groups' => $groups, 'groups' => $groups,
'oldInput' => $oldInput,
]); ]);
} }
@@ -101,15 +108,46 @@ class ServerController
]; ];
if (!$validator->validate($_POST, $rules)) { if (!$validator->validate($_POST, $rules)) {
$this->preserveFormInput();
Session::setFlash('error', $validator->getFirstError()); Session::setFlash('error', $validator->getFirstError());
$this->view->redirect('/servers/create'); $this->view->redirect('/servers/create');
} }
if (empty($_POST['ssh_password']) && empty($_POST['ssh_key'])) { if (empty($_POST['ssh_password']) && empty($_POST['ssh_key'])) {
$this->preserveFormInput();
Session::setFlash('error', 'You must provide either an SSH password or a private key.'); Session::setFlash('error', 'You must provide either an SSH password or a private key.');
$this->view->redirect('/servers/create'); $this->view->redirect('/servers/create');
} }
$permissions = $this->extractPermissionsFromInput();
if (!empty($permissions)) {
$serverConfig = [
'ip_address' => $_POST['ip_address'],
'ssh_port' => (int) $_POST['ssh_port'],
'ssh_user' => $_POST['ssh_user'],
'ssh_password' => $_POST['ssh_password'] ?? null,
'ssh_key' => $_POST['ssh_key'] ?? null,
];
$permValidator = new PermissionValidator();
$permResult = $permValidator->validate($serverConfig, $permissions);
if (!$permResult['passed']) {
$this->preserveFormInput();
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
$messages = [];
foreach ($failedPermissions as $fp) {
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
$messages[] = htmlspecialchars($label) . ' — ' . htmlspecialchars($fp['message']);
}
Session::setFlash('error', 'Permission validation failed: ' . implode(' | ', $messages));
$this->view->redirect('/servers/create');
}
}
$data = [ $data = [
'name' => $_POST['name'], 'name' => $_POST['name'],
'description' => $_POST['description'] ?? '', 'description' => $_POST['description'] ?? '',
@@ -124,6 +162,21 @@ class ServerController
$id = $this->serverModel->create($data); $id = $this->serverModel->create($data);
if (!empty($permissions)) {
try {
$permModel = new ServerPermission();
$permModel->save($id, $permissions);
$this->auditService->log('server_permissions_set', 'server', $id, [
'count' => count($permissions),
]);
} catch (\Throwable $e) {
$this->auditService->log('server_permissions_set_failed', 'server', $id, [
'error' => $e->getMessage(),
]);
}
}
$this->auditService->log('server_created', 'server', $id, [ $this->auditService->log('server_created', 'server', $id, [
'name' => $data['name'], 'name' => $data['name'],
'ip_address' => $data['ip_address'], 'ip_address' => $data['ip_address'],
@@ -133,23 +186,33 @@ class ServerController
$this->view->redirect('/servers'); $this->view->redirect('/servers');
} }
public function show(int $id): void private function extractPermissionsFromInput(): array
{ {
$server = $this->requireServerAccess($id, 'viewer'); $permissions = $_POST['permissions'] ?? [];
$role = $this->serverModel->getUserRole($id, (int) Session::get('user_id')); if (!is_array($permissions)) {
return [];
}
$monitoringService = new MonitoringService(); $filtered = [];
$latestMetrics = $monitoringService->getLatestMetrics($id); foreach ($permissions as $perm) {
$historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24); if (!empty($perm['type']) && isset($perm['value']) && $perm['value'] !== '') {
$filtered[] = [
'type' => $perm['type'],
'value' => trim($perm['value']),
'description' => trim($perm['description'] ?? ''),
];
}
}
$this->view->display('servers.show', [ return $filtered;
'title' => $server['name'] . ' - ServerManager', }
'server' => $server,
'metrics' => $latestMetrics, private function preserveFormInput(): void
'historicalMetrics' => $historicalMetrics, {
'server_role' => $role, $input = $_POST;
]); unset($input['ssh_password'], $input['ssh_key'], $input['_csrf_token']);
$_SESSION['_form_input'] = $input;
} }
public function edit(int $id): void public function edit(int $id): void
@@ -158,10 +221,18 @@ class ServerController
$groups = $this->serverModel->getGroups(); $groups = $this->serverModel->getGroups();
try {
$permModel = new ServerPermission();
$permissions = $permModel->getByServerId($id);
} catch (\Throwable $e) {
$permissions = [];
}
$this->view->display('servers.edit', [ $this->view->display('servers.edit', [
'title' => 'Edit ' . $server['name'] . ' - ServerManager', 'title' => 'Edit ' . $server['name'] . ' - ServerManager',
'server' => $server, 'server' => $server,
'groups' => $groups, 'groups' => $groups,
'permissions' => $permissions,
]); ]);
} }
@@ -183,6 +254,33 @@ class ServerController
$this->view->redirect("/servers/{$id}/edit"); $this->view->redirect("/servers/{$id}/edit");
} }
$permissions = $this->extractPermissionsFromInput();
if (!empty($permissions)) {
$serverConfig = [
'ip_address' => $_POST['ip_address'],
'ssh_port' => (int) $_POST['ssh_port'],
'ssh_user' => $_POST['ssh_user'],
'ssh_password' => $_POST['ssh_password'] ?: ($server['ssh_password'] ?? null),
'ssh_key' => $_POST['ssh_key'] ?: ($server['ssh_key'] ?? null),
];
$permValidator = new PermissionValidator();
$permResult = $permValidator->validate($serverConfig, $permissions);
if (!$permResult['passed']) {
$failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']);
$messages = [];
foreach ($failedPermissions as $fp) {
$label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']);
$messages[] = htmlspecialchars($label) . ' — ' . htmlspecialchars($fp['message']);
}
Session::setFlash('error', 'Permission validation failed: ' . implode(' | ', $messages));
$this->view->redirect("/servers/{$id}/edit");
}
}
$data = [ $data = [
'name' => $_POST['name'], 'name' => $_POST['name'],
'description' => $_POST['description'] ?? '', 'description' => $_POST['description'] ?? '',
@@ -203,6 +301,19 @@ class ServerController
$this->serverModel->update($id, $data); $this->serverModel->update($id, $data);
try {
$permModel = new ServerPermission();
$permModel->save($id, $permissions);
$this->auditService->log('server_permissions_set', 'server', $id, [
'count' => count($permissions),
]);
} catch (\Throwable $e) {
$this->auditService->log('server_permissions_set_failed', 'server', $id, [
'error' => $e->getMessage(),
]);
}
$this->auditService->log('server_updated', 'server', $id, [ $this->auditService->log('server_updated', 'server', $id, [
'name' => $data['name'], 'name' => $data['name'],
]); ]);
@@ -211,6 +322,33 @@ class ServerController
$this->view->redirect("/servers/{$id}"); $this->view->redirect("/servers/{$id}");
} }
public function show(int $id): void
{
$server = $this->requireServerAccess($id, 'viewer');
$role = $this->serverModel->getUserRole($id, (int) Session::get('user_id'));
$monitoringService = new MonitoringService();
$latestMetrics = $monitoringService->getLatestMetrics($id);
$historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24);
try {
$permModel = new ServerPermission();
$permissions = $permModel->getByServerId($id);
} catch (\Throwable $e) {
$permissions = [];
}
$this->view->display('servers.show', [
'title' => $server['name'] . ' - ServerManager',
'server' => $server,
'metrics' => $latestMetrics,
'historicalMetrics' => $historicalMetrics,
'server_role' => $role,
'permissions' => $permissions,
]);
}
public function delete(int $id): void public function delete(int $id): void
{ {
$server = $this->requireServerAccess($id, 'manager'); $server = $this->requireServerAccess($id, 'manager');

View File

@@ -29,15 +29,29 @@ class Notification
public function getForUser(int $userId, int $page = 1, int $perPage = 20): array public function getForUser(int $userId, int $page = 1, int $perPage = 20): array
{ {
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$total = $this->db->fetch( $total = $this->db->fetch(
"SELECT COUNT(*) as total FROM notifications WHERE user_id IS NULL OR user_id = ?", "SELECT COUNT(*) as total FROM notifications n
WHERE (n.user_id IS NULL OR n.user_id = ?)",
[$userId] [$userId]
); );
$items = $this->db->fetchAll( $items = $this->db->fetchAll(
"SELECT * FROM notifications WHERE user_id IS NULL OR user_id = ? "SELECT n.*,
ORDER BY created_at DESC LIMIT ? OFFSET ?", CASE
[$userId, $perPage, $offset] WHEN nr.id IS NOT NULL THEN 1
ELSE 0
END as is_read,
nr.read_at as read_at,
(SELECT COUNT(*) FROM notification_reads WHERE notification_id = n.id) as total_reads,
(SELECT COUNT(*) FROM users WHERE status = 'active') as total_users
FROM notifications n
LEFT JOIN notification_reads nr ON nr.notification_id = n.id AND nr.user_id = ?
WHERE n.user_id IS NULL OR n.user_id = ?
ORDER BY n.created_at DESC LIMIT ? OFFSET ?",
[$userId, $userId, $perPage, $offset]
); );
return [ return [
'data' => $items, 'data' => $items,
'total' => (int) ($total['total'] ?? 0), 'total' => (int) ($total['total'] ?? 0),
@@ -50,44 +64,71 @@ class Notification
public function getUnreadCount(int $userId): int public function getUnreadCount(int $userId): int
{ {
$result = $this->db->fetch( $result = $this->db->fetch(
"SELECT COUNT(*) as total FROM notifications "SELECT COUNT(*) as total FROM notifications n
WHERE (user_id IS NULL OR user_id = ?) AND is_read = 0", WHERE (n.user_id IS NULL OR n.user_id = ?)
[$userId] AND NOT EXISTS (
SELECT 1 FROM notification_reads nr
WHERE nr.notification_id = n.id AND nr.user_id = ?
)",
[$userId, $userId]
); );
return (int) ($result['total'] ?? 0); return (int) ($result['total'] ?? 0);
} }
public function markAsRead(int $id, int $userId): void public function markAsRead(int $id, int $userId): void
{ {
$this->db->update( try {
'notifications', $this->db->insert('notification_reads', [
['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], 'notification_id' => $id,
'id = ? AND (user_id IS NULL OR user_id = ?)', 'user_id' => $userId,
[$id, $userId] 'read_at' => date('Y-m-d H:i:s'),
); ]);
} catch (\Throwable $e) {
// Already read, ignore
}
} }
public function markAllAsRead(int $userId): void public function markAllAsRead(int $userId): void
{ {
$this->db->update( $unread = $this->db->fetchAll(
'notifications', "SELECT n.id FROM notifications n
['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], WHERE (n.user_id IS NULL OR n.user_id = ?)
'(user_id IS NULL OR user_id = ?) AND is_read = 0', AND NOT EXISTS (
[$userId] SELECT 1 FROM notification_reads nr
WHERE nr.notification_id = n.id AND nr.user_id = ?
)",
[$userId, $userId]
); );
$now = date('Y-m-d H:i:s');
foreach ($unread as $note) {
try {
$this->db->insert('notification_reads', [
'notification_id' => (int) $note['id'],
'user_id' => $userId,
'read_at' => $now,
]);
} catch (\Throwable $e) {
// Duplicate entry — already read, skip
}
}
} }
public function getAll(int $page = 1, int $perPage = 20): array public function getAll(int $page = 1, int $perPage = 20): array
{ {
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$total = $this->db->fetch("SELECT COUNT(*) as total FROM notifications"); $total = $this->db->fetch("SELECT COUNT(*) as total FROM notifications");
$items = $this->db->fetchAll( $items = $this->db->fetchAll(
"SELECT n.*, u.username as target_username "SELECT n.*, u.username as target_username,
(SELECT COUNT(*) FROM notification_reads nr WHERE nr.notification_id = n.id) as read_count
FROM notifications n FROM notifications n
LEFT JOIN users u ON n.user_id = u.id LEFT JOIN users u ON n.user_id = u.id
ORDER BY n.created_at DESC LIMIT ? OFFSET ?", ORDER BY n.created_at DESC LIMIT ? OFFSET ?",
[$perPage, $offset] [$perPage, $offset]
); );
return [ return [
'data' => $items, 'data' => $items,
'total' => (int) ($total['total'] ?? 0), 'total' => (int) ($total['total'] ?? 0),
@@ -96,4 +137,10 @@ class Notification
'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage), 'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage),
]; ];
} }
public function getTotalActiveUsers(): int
{
$result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'");
return (int) ($result['total'] ?? 0);
}
} }

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace ServerManager\Models;
use ServerManager\Core\Database;
class ServerPermission
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function save(int $serverId, array $permissions): void
{
$this->deleteByServerId($serverId);
foreach ($permissions as $perm) {
if (empty($perm['type']) || !isset($perm['value'])) {
continue;
}
$this->db->insert('server_permissions', [
'server_id' => $serverId,
'permission_type' => $perm['type'],
'permission_value' => $perm['value'],
'description' => $perm['description'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
public function getByServerId(int $serverId): array
{
return $this->db->fetchAll(
'SELECT * FROM server_permissions WHERE server_id = ? ORDER BY id ASC',
[$serverId]
);
}
public function deleteByServerId(int $serverId): void
{
$this->db->delete('server_permissions', 'server_id = ?', [$serverId]);
}
public static function formatForValidation(array $permissions): array
{
return array_map(fn($p) => [
'type' => $p['permission_type'],
'value' => $p['permission_value'],
'description' => $p['description'] ?? '',
], $permissions);
}
}

View File

@@ -38,7 +38,9 @@ class AgentService
$serverUrl = rtrim((App::getConfig()['app']['url'] ?? ''), '/'); $serverUrl = rtrim((App::getConfig()['app']['url'] ?? ''), '/');
if (empty($serverUrl)) { if (empty($serverUrl)) {
$serverUrl = ($_SERVER['HTTPS'] ?? '' === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost'); $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$serverUrl = $scheme . $host;
} }
$scriptPath = $this->basePath . '/install/agent-install.sh'; $scriptPath = $this->basePath . '/install/agent-install.sh';

308
src/Services/FCMService.php Normal file
View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
use ServerManager\Core\App;
use ServerManager\Core\Database;
use ServerManager\Core\Logger;
class FCMService
{
private Database $db;
private Logger $logger;
private string $mode = 'none';
private string $serverKey = '';
private array $serviceAccount = [];
private string $accessToken = '';
private int $tokenExpiry = 0;
public function __construct()
{
$this->db = Database::getInstance();
$this->logger = Logger::getInstance();
$keyFile = dirname(__DIR__, 2) . '/config/firebase-service-account.json';
if (file_exists($keyFile)) {
$account = json_decode(file_get_contents($keyFile), true);
if ($account && !empty($account['client_email']) && !empty($account['private_key'])) {
$this->serviceAccount = $account;
$this->mode = 'v1';
$this->logger->info('FCM mode: HTTP v1 (service account)');
return;
}
}
$config = App::getConfig();
$this->serverKey = $config['fcm']['server_key'] ?? $_ENV['FCM_SERVER_KEY'] ?? '';
if (!empty($this->serverKey)) {
$this->mode = 'legacy';
$this->logger->info('FCM mode: legacy HTTP API (server key)');
} else {
$this->logger->warning('FCM not configured — no service account or server key');
}
}
public function isConfigured(): bool
{
return $this->mode !== 'none';
}
public function sendToUser(int $userId, string $title, string $body, string $type = 'info', ?int $notificationId = null): bool
{
if (!$this->isConfigured()) return false;
$tokens = $this->db->fetchAll(
'SELECT id, token FROM user_fcm_tokens WHERE user_id = ?',
[$userId]
);
if (empty($tokens)) return false;
$success = true;
foreach ($tokens as $row) {
$result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId);
if ($result === false) {
$success = false;
} elseif ($result === 'invalid_token') {
$this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]);
$this->logger->info('FCM: removed invalid token', ['user_id' => $userId]);
}
}
return $success;
}
public function sendToAll(string $title, string $body, string $type = 'info', ?int $notificationId = null): int
{
if (!$this->isConfigured()) return 0;
$tokens = $this->db->fetchAll('SELECT id, token FROM user_fcm_tokens');
if (empty($tokens)) return 0;
$sent = 0;
foreach ($tokens as $row) {
$result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId);
if ($result === true) {
$sent++;
} elseif ($result === 'invalid_token') {
$this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]);
}
}
return $sent;
}
private function sendToDevice(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string
{
if ($this->mode === 'v1') {
return $this->sendV1($token, $title, $body, $type, $notificationId);
}
return $this->sendLegacy($token, $title, $body, $type, $notificationId);
}
private function sendV1(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string
{
$accessToken = $this->getAccessToken();
if (!$accessToken) return false;
$projectId = $this->serviceAccount['project_id'] ?? '';
if (empty($projectId)) return false;
$payload = [
'message' => [
'token' => $token,
'notification' => [
'title' => $title,
'body' => $body,
],
'data' => [
'type' => $type,
'notification_id' => (string) ($notificationId ?? 0),
'click_action' => 'OPEN_NOTIFICATIONS',
],
'android' => [
'priority' => 'HIGH',
'notification' => [
'channel_id' => match ($type) {
'error', 'warning' => 'notifications_alerts',
default => 'notifications_general',
},
'priority' => match ($type) {
'error' => 'HIGH',
default => 'NORMAL',
},
'sound' => 'default',
],
],
],
];
$ch = curl_init("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken,
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$this->logger->info('FCM v1: push sent', ['notification_id' => $notificationId]);
return true;
}
if ($httpCode === 404 || $httpCode === 400) {
$respData = json_decode($response, true);
$errorMsg = $respData['error']['message'] ?? $response;
if (str_contains($errorMsg, 'registration-token-not-registered') ||
str_contains($errorMsg, 'NOT_FOUND') ||
str_contains($errorMsg, 'INVALID_ARGUMENT')) {
$this->logger->warning('FCM v1: invalid token, will remove', ['error' => substr($errorMsg, 0, 200)]);
return 'invalid_token';
}
}
$this->logger->error('FCM v1: send failed', [
'http_code' => $httpCode,
'response' => substr($response, 0, 500),
]);
return false;
}
private function sendLegacy(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string
{
$payload = [
'to' => $token,
'priority' => 'high',
'notification' => [
'title' => $title,
'body' => $body,
'sound' => 'default',
],
'data' => [
'type' => $type,
'notification_id' => (string) ($notificationId ?? 0),
'click_action' => 'OPEN_NOTIFICATIONS',
],
'android' => [
'priority' => 'high',
'notification' => [
'channel_id' => match ($type) {
'error', 'warning' => 'notifications_alerts',
default => 'notifications_general',
},
'priority' => match ($type) {
'error' => 'high',
default => 'default',
},
'sound' => 'default',
],
],
];
$ch = curl_init('https://fcm.googleapis.com/fcm/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: key=' . $this->serverKey,
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$this->logger->info('FCM legacy: push sent', ['notification_id' => $notificationId]);
return true;
}
$respData = json_decode($response, true);
$fcmError = $respData['results'][0]['error'] ?? '';
if ($fcmError === 'NotRegistered' || $fcmError === 'InvalidRegistration') {
$this->logger->warning('FCM legacy: invalid token, will remove', ['error' => $fcmError]);
return 'invalid_token';
}
$this->logger->error('FCM legacy: send failed', [
'http_code' => $httpCode,
'fcm_error' => $fcmError,
'response' => substr($response, 0, 500),
]);
return false;
}
private function getAccessToken(): string
{
if ($this->accessToken && time() < $this->tokenExpiry) {
return $this->accessToken;
}
$jwt = $this->createJWT();
if (!$jwt) return '';
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->logger->error('FCM OAuth token request failed', ['http_code' => $httpCode, 'response' => substr($response, 0, 500)]);
return '';
}
$data = json_decode($response, true);
$this->accessToken = $data['access_token'] ?? '';
$this->tokenExpiry = time() + (int) ($data['expires_in'] ?? 3600) - 60;
return $this->accessToken;
}
private function createJWT(): string
{
if (empty($this->serviceAccount['client_email']) || empty($this->serviceAccount['private_key'])) {
return '';
}
$now = time();
$header = self::base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$payload = self::base64UrlEncode(json_encode([
'iss' => $this->serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
]));
$signature = '';
openssl_sign("$header.$payload", $signature, $this->serviceAccount['private_key'], 'sha256WithRSAEncryption');
return "$header.$payload." . self::base64UrlEncode($signature);
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}

View File

@@ -173,6 +173,29 @@ class MonitoringService
); );
} }
public function getAggregatedHistoricalMetrics(array $serverIds, int $hours = 24): array
{
if (empty($serverIds)) {
return [];
}
$placeholders = implode(',', array_fill(0, count($serverIds), '?'));
$params = $serverIds;
$params[] = $hours;
return $this->db->fetchAll(
"SELECT
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00') as time_bucket,
AVG(cpu_usage) as avg_cpu,
AVG(ram_usage) as avg_ram,
AVG(disk_usage) as avg_disk
FROM monitoring_history
WHERE server_id IN ({$placeholders})
AND created_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)
GROUP BY time_bucket
ORDER BY time_bucket ASC",
$params
);
}
public function getDashboardStats(?array $serverIds = null): array public function getDashboardStats(?array $serverIds = null): array
{ {
$where = ''; $where = '';

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
class PermissionValidator
{
private SSHService $ssh;
public function __construct()
{
$this->ssh = new SSHService();
}
public function validate(array $serverConfig, array $permissions): array
{
try {
if (!$this->ssh->connect($serverConfig, true)) {
return [
'passed' => false,
'results' => [],
'error' => 'Could not connect to the remote server. Please check the connection details.',
];
}
} catch (\Throwable $e) {
return [
'passed' => false,
'results' => [],
'error' => 'SSH connection failed: ' . $e->getMessage(),
];
}
$results = [];
foreach ($permissions as $index => $perm) {
if (empty($perm['type']) || !isset($perm['value'])) {
continue;
}
$type = $perm['type'];
$value = $perm['value'];
$description = $perm['description'] ?? '';
$command = $this->buildCheckCommand($type, $value);
try {
$output = $this->ssh->exec($command);
$passed = $this->evaluateResult($type, $output);
$results[] = [
'type' => $type,
'value' => $value,
'description' => $description,
'passed' => $passed,
'message' => $passed ? 'Permission verified' : ($type === 'sudo' ? 'Passwordless sudo is not available' : 'Permission not granted: ' . trim($output['output'] ?? '')),
];
} catch (\Throwable $e) {
$results[] = [
'type' => $type,
'value' => $value,
'description' => $description,
'passed' => false,
'message' => 'Check failed: ' . $e->getMessage(),
];
}
}
$this->ssh->disconnect();
$allPassed = !empty($results) && empty(array_filter($results, fn($r) => !$r['passed']));
return [
'passed' => $allPassed,
'results' => $results,
'error' => null,
];
}
private function buildCheckCommand(string $type, string $value): string
{
return match ($type) {
'sudo' => 'sudo -n true 2>&1 && echo "OK" || echo "FAIL"',
'command' => sprintf('command -v %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)),
'file_read' => sprintf('test -r %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)),
'file_write' => sprintf('test -w %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)),
'custom' => sprintf('%s 2>&1; echo "EXIT:$?"', $value),
default => 'echo "FAIL"',
};
}
private function evaluateResult(string $type, array $output): bool
{
$exitCode = $output['exit_status'] ?? 1;
$stdout = trim($output['output'] ?? '');
if ($type === 'custom') {
return $exitCode === 0;
}
return $exitCode === 0 && str_contains($stdout, 'OK');
}
}

View File

@@ -1,6 +1,7 @@
<?php <?php
$notifications = $notifications ?? []; $notifications = $notifications ?? [];
$users = $users ?? []; $users = $users ?? [];
$totalActiveUsers = $totalActiveUsers ?? 0;
$data = $notifications['data'] ?? []; $data = $notifications['data'] ?? [];
?> ?>
@@ -27,7 +28,7 @@ $data = $notifications['data'] ?? [];
<th>Title</th> <th>Title</th>
<th>Message</th> <th>Message</th>
<th>Target</th> <th>Target</th>
<th>Status</th> <th>Read by</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,9 +54,16 @@ $data = $notifications['data'] ?? [];
<td><?= htmlspecialchars(mb_substr($note['message'] ?? '', 0, 80)) ?><?= mb_strlen($note['message'] ?? '') > 80 ? '...' : '' ?></td> <td><?= htmlspecialchars(mb_substr($note['message'] ?? '', 0, 80)) ?><?= mb_strlen($note['message'] ?? '') > 80 ? '...' : '' ?></td>
<td><?= $note['user_id'] ? htmlspecialchars($note['target_username'] ?? "User #{$note['user_id']}") : '<em>All Users</em>' ?></td> <td><?= $note['user_id'] ? htmlspecialchars($note['target_username'] ?? "User #{$note['user_id']}") : '<em>All Users</em>' ?></td>
<td> <td>
<span class="status-badge <?= $note['is_read'] ? 'status-online' : 'status-offline' ?>"> <?php if ($note['user_id']): ?>
<?= $note['is_read'] ? 'Read' : 'Unread' ?> <span class="status-badge <?= ($note['read_count'] ?? 0) > 0 ? 'status-online' : 'status-offline' ?>">
</span> <?= ($note['read_count'] ?? 0) > 0 ? 'Read' : 'Unread' ?>
</span>
<?php else: ?>
<?php $rc = (int) ($note['read_count'] ?? 0); ?>
<span class="badge badge-<?= $rc >= $totalActiveUsers ? 'success' : ($rc > 0 ? 'warning' : 'secondary') ?>">
<?= $rc ?> / <?= $totalActiveUsers ?>
</span>
<?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View File

@@ -2,6 +2,7 @@
$stats = $stats ?? []; $stats = $stats ?? [];
$servers = $servers ?? []; $servers = $servers ?? [];
$recentActivity = $recentActivity ?? []; $recentActivity = $recentActivity ?? [];
$aggregatedMetrics = $aggregatedMetrics ?? [];
?> ?>
<div class="page-header"> <div class="page-header">
@@ -54,6 +55,15 @@ $recentActivity = $recentActivity ?? [];
</div> </div>
</div> </div>
<div class="card" style="margin-bottom:1.5rem">
<div class="card-header">
<h2><i class="fas fa-chart-line"></i> Recursos Agregados (24h)</h2>
</div>
<div class="card-body">
<canvas id="aggregatedChart" height="300"></canvas>
</div>
</div>
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="dashboard-card"> <div class="dashboard-card">
<div class="card-header"> <div class="card-header">
@@ -144,8 +154,150 @@ $recentActivity = $recentActivity ?? [];
<?= 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>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
const aggregatedData = <?= json_encode($aggregatedMetrics) ?>;
let aggregatedChart = null;
function initAggregatedChart(data) {
const container = document.getElementById('aggregatedChart').parentNode;
const oldCanvas = document.getElementById('aggregatedChart');
const newCanvas = document.createElement('canvas');
newCanvas.id = 'aggregatedChart';
newCanvas.height = 300;
container.replaceChild(newCanvas, oldCanvas);
const ctx = newCanvas.getContext('2d');
const labels = data.map(m => {
const d = new Date(m.time_bucket);
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
});
aggregatedChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'CPU',
data: data.map(m => parseFloat(m.avg_cpu)),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 1,
},
{
label: 'RAM',
data: data.map(m => parseFloat(m.avg_ram)),
borderColor: '#22c55e',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 1,
},
{
label: 'Disk',
data: data.map(m => parseFloat(m.avg_disk)),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 1,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: {
labels: {
color: '#9ca3af',
font: { family: "'Inter', sans-serif" },
boxWidth: 12,
padding: 16,
},
},
tooltip: {
backgroundColor: '#1a1d2b',
borderColor: '#2a2d3d',
borderWidth: 1,
titleColor: '#e1e4ed',
bodyColor: '#9ca3af',
padding: 10,
cornerRadius: 8,
callbacks: {
label: function(ctx) {
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
},
},
},
},
scales: {
x: {
ticks: {
color: '#6b7280',
maxTicksLimit: 12,
font: { size: 11 },
},
grid: {
color: 'rgba(255,255,255,0.04)',
},
},
y: {
min: 0,
max: 100,
ticks: {
color: '#6b7280',
font: { size: 11 },
callback: function(v) { return v + '%'; },
},
grid: {
color: 'rgba(255,255,255,0.04)',
},
},
},
},
});
}
function refreshAggregatedChart() {
fetch('/dashboard/chart-data')
.then(r => r.json())
.then(response => {
if (response.success && response.data) {
const data = response.data;
const labels = data.map(m => {
const d = new Date(m.time_bucket);
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
});
if (aggregatedChart) {
aggregatedChart.data.labels = labels;
aggregatedChart.data.datasets[0].data = data.map(m => parseFloat(m.avg_cpu));
aggregatedChart.data.datasets[1].data = data.map(m => parseFloat(m.avg_ram));
aggregatedChart.data.datasets[2].data = data.map(m => parseFloat(m.avg_disk));
aggregatedChart.update('none');
} else if (data.length > 0) {
initAggregatedChart(data);
}
}
});
}
if (aggregatedData.length > 0) {
initAggregatedChart(aggregatedData);
}
setInterval(refreshAggregatedChart, 30000);
</script>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -1,4 +1,7 @@
<?php $groups = $groups ?? []; ?> <?php
$groups = $groups ?? [];
$old = $oldInput ?? [];
?>
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Add Server</h1> <h1 class="page-title">Add Server</h1>
@@ -16,13 +19,14 @@
<div class="form-group"> <div class="form-group">
<label for="name">Server Name *</label> <label for="name">Server Name *</label>
<input type="text" id="name" name="name" class="form-input" <input type="text" id="name" name="name" class="form-input"
placeholder="e.g., Production Web Server" required> placeholder="e.g., Production Web Server" required
value="<?= htmlspecialchars($old['name'] ?? '') ?>">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="group_name">Group / Category</label> <label for="group_name">Group / Category</label>
<input type="text" id="group_name" name="group_name" class="form-input" <input type="text" id="group_name" name="group_name" class="form-input"
placeholder="e.g., Production, Staging" placeholder="e.g., Production, Staging"
list="groupList"> list="groupList" value="<?= htmlspecialchars($old['group_name'] ?? '') ?>">
<datalist id="groupList"> <datalist id="groupList">
<?php foreach ($groups as $group): ?> <?php foreach ($groups as $group): ?>
<option value="<?= htmlspecialchars($group) ?>"> <option value="<?= htmlspecialchars($group) ?>">
@@ -33,7 +37,7 @@
<div class="form-group"> <div class="form-group">
<label for="description">Description</label> <label for="description">Description</label>
<textarea id="description" name="description" class="form-input" rows="3" <textarea id="description" name="description" class="form-input" rows="3"
placeholder="Optional description of the server"></textarea> placeholder="Optional description of the server"><?= htmlspecialchars($old['description'] ?? '') ?></textarea>
</div> </div>
</div> </div>
@@ -43,17 +47,19 @@
<div class="form-group"> <div class="form-group">
<label for="ip_address">IP Address / Hostname *</label> <label for="ip_address">IP Address / Hostname *</label>
<input type="text" id="ip_address" name="ip_address" class="form-input" <input type="text" id="ip_address" name="ip_address" class="form-input"
placeholder="e.g., 192.168.1.100 or server.example.com" required> placeholder="e.g., 192.168.1.100 or server.example.com" required
value="<?= htmlspecialchars($old['ip_address'] ?? '') ?>">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ssh_port">SSH Port *</label> <label for="ssh_port">SSH Port *</label>
<input type="number" id="ssh_port" name="ssh_port" class="form-input" <input type="number" id="ssh_port" name="ssh_port" class="form-input"
value="22" min="1" max="65535" required> value="<?= htmlspecialchars($old['ssh_port'] ?? '22') ?>" min="1" max="65535" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ssh_user">SSH User *</label> <label for="ssh_user">SSH User *</label>
<input type="text" id="ssh_user" name="ssh_user" class="form-input" <input type="text" id="ssh_user" name="ssh_user" class="form-input"
placeholder="e.g., root or admin" required> placeholder="e.g., root or admin" required
value="<?= htmlspecialchars($old['ssh_user'] ?? '') ?>">
</div> </div>
</div> </div>
</div> </div>
@@ -80,13 +86,59 @@
<div class="auth-method-content" id="keyMethod" style="display: none;"> <div class="auth-method-content" id="keyMethod" style="display: none;">
<div class="form-group"> <div class="form-group">
<label for="ssh_key">SSH Private Key</label> <label for="ssh_key">SSH Private Key</label>
<textarea id="ssh_key" name="ssh_key" class="form-input font-mono" rows="6" <textarea id="ssh_key" name="ssh_key" class="form-input font-mono" rows="6"
placeholder="Paste the private key content (RSA/Ed25519)"></textarea> placeholder="Paste the private key content (RSA/Ed25519)"></textarea>
<small class="form-hint">Private key is encrypted before storage. Passphrase-protected keys supported.</small> <small class="form-hint">Private key is encrypted before storage. Passphrase-protected keys supported.</small>
</div> </div>
</div> </div>
</div> </div>
<div class="form-section">
<h3><i class="fas fa-shield-alt"></i> Required Permissions</h3>
<p class="form-hint">The system will connect to the remote server and verify each permission before saving.</p>
<div id="permissions-container">
<?php
$savedPerms = $old['permissions'] ?? [];
if (!empty($savedPerms) && is_array($savedPerms)):
foreach ($savedPerms as $i => $perm):
?>
<div class="permission-row" data-index="<?= $i ?>">
<select name="permissions[<?= $i ?>][type]" class="form-input permission-type">
<option value="sudo" <?= ($perm['type'] ?? '') === 'sudo' ? 'selected' : '' ?>>Sudo (passwordless)</option>
<option value="command" <?= ($perm['type'] ?? '') === 'command' ? 'selected' : '' ?>>Command</option>
<option value="file_read" <?= ($perm['type'] ?? '') === 'file_read' ? 'selected' : '' ?>>File Read</option>
<option value="file_write" <?= ($perm['type'] ?? '') === 'file_write' ? 'selected' : '' ?>>File Write</option>
<option value="custom" <?= ($perm['type'] ?? '') === 'custom' ? 'selected' : '' ?>>Custom Command</option>
</select>
<input type="text" name="permissions[<?= $i ?>][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="<?= htmlspecialchars($perm['value'] ?? '') ?>">
<input type="text" name="permissions[<?= $i ?>][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="<?= htmlspecialchars($perm['description'] ?? '') ?>">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
</div>
<?php
endforeach;
endif;
?>
</div>
<div class="permission-actions">
<button type="button" id="addPermissionBtn" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Permission
</button>
</div>
<div class="permission-presets">
<span class="preset-label">Quick add:</span>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('sudo', 'sudo -n true', 'Passwordless sudo for system management')">Sudo</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'systemctl', 'Service management')">systemctl</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'df', 'Disk usage monitoring')">df</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'free', 'Memory monitoring')">free</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'top', 'CPU monitoring')">top</button>
</div>
</div>
<div class="form-section"> <div class="form-section">
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
@@ -116,4 +168,47 @@ document.querySelectorAll('.auth-method-tabs .tab').forEach(tab => {
document.getElementById('keyMethod').style.display = method === 'key' ? 'block' : 'none'; document.getElementById('keyMethod').style.display = method === 'key' ? 'block' : 'none';
}); });
}); });
let permIndex = <?= !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>;
function createPermissionRow(type, value, description) {
const idx = permIndex++;
const container = document.getElementById('permissions-container');
const div = document.createElement('div');
div.className = 'permission-row';
div.dataset.index = idx;
div.innerHTML = `
<select name="permissions[${idx}][type]" class="form-input permission-type">
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
</select>
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(div);
}
function removePermissionRow(btn) {
btn.closest('.permission-row').remove();
}
function addPresetPermission(type, value, description) {
createPermissionRow(type, value, description);
}
document.getElementById('addPermissionBtn').addEventListener('click', function() {
createPermissionRow('command', '', '');
});
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script> </script>

View File

@@ -1,6 +1,7 @@
<?php <?php
$server = $server ?? []; $server = $server ?? [];
$groups = $groups ?? []; $groups = $groups ?? [];
$permissions = $permissions ?? [];
$hasPassword = !empty($server['ssh_password']); $hasPassword = !empty($server['ssh_password']);
$hasKey = !empty($server['ssh_key']); $hasKey = !empty($server['ssh_key']);
?> ?>
@@ -92,6 +93,47 @@ $hasKey = !empty($server['ssh_key']);
</div> </div>
</div> </div>
<div class="form-section">
<h3><i class="fas fa-shield-alt"></i> Required Permissions</h3>
<p class="form-hint">The system will verify these permissions on the remote server before saving changes.</p>
<div id="permissions-container">
<?php if (!empty($permissions)): ?>
<?php foreach ($permissions as $i => $perm): ?>
<div class="permission-row" data-index="<?= $i ?>">
<select name="permissions[<?= $i ?>][type]" class="form-input permission-type">
<option value="sudo" <?= ($perm['permission_type'] ?? '') === 'sudo' ? 'selected' : '' ?>>Sudo (passwordless)</option>
<option value="command" <?= ($perm['permission_type'] ?? '') === 'command' ? 'selected' : '' ?>>Command</option>
<option value="file_read" <?= ($perm['permission_type'] ?? '') === 'file_read' ? 'selected' : '' ?>>File Read</option>
<option value="file_write" <?= ($perm['permission_type'] ?? '') === 'file_write' ? 'selected' : '' ?>>File Write</option>
<option value="custom" <?= ($perm['permission_type'] ?? '') === 'custom' ? 'selected' : '' ?>>Custom Command</option>
</select>
<input type="text" name="permissions[<?= $i ?>][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="<?= htmlspecialchars($perm['permission_value'] ?? '') ?>">
<input type="text" name="permissions[<?= $i ?>][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="<?= htmlspecialchars($perm['description'] ?? '') ?>">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="permission-actions">
<button type="button" id="addPermissionBtn" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Permission
</button>
</div>
<div class="permission-presets">
<span class="preset-label">Quick add:</span>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('sudo', 'sudo -n true', 'Passwordless sudo for system management')">Sudo</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'systemctl', 'Service management')">systemctl</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'df', 'Disk usage monitoring')">df</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'free', 'Memory monitoring')">free</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'top', 'CPU monitoring')">top</button>
</div>
</div>
<div class="form-section"> <div class="form-section">
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
@@ -110,3 +152,48 @@ $hasKey = !empty($server['ssh_key']);
</form> </form>
</div> </div>
</div> </div>
<script>
let permIndex = <?= count($permissions) ?>;
function createPermissionRow(type, value, description) {
const idx = permIndex++;
const container = document.getElementById('permissions-container');
const div = document.createElement('div');
div.className = 'permission-row';
div.dataset.index = idx;
div.innerHTML = `
<select name="permissions[${idx}][type]" class="form-input permission-type">
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
</select>
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
`;
container.appendChild(div);
}
function removePermissionRow(btn) {
btn.closest('.permission-row').remove();
}
function addPresetPermission(type, value, description) {
createPermissionRow(type, value, description);
}
document.getElementById('addPermissionBtn').addEventListener('click', function() {
createPermissionRow('command', '', '');
});
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>

View File

@@ -191,6 +191,34 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<?php if (!empty($permissions)): ?>
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-shield-alt"></i> Required Permissions</h2>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>Type</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<?php foreach ($permissions as $perm): ?>
<tr>
<td><span class="badge badge-info"><?= htmlspecialchars($perm['permission_type'] ?? '') ?></span></td>
<td><code><?= htmlspecialchars($perm['permission_value'] ?? '') ?></code></td>
<td><?= htmlspecialchars($perm['description'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div> </div>
<div class="card"> <div class="card">