diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8a6608..0ea1a3b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.devlab.app" minSdk = 26 targetSdk = 37 - versionCode = 5 - versionName = "1.4.0" + versionCode = 6 + versionName = "1.5.0" } buildTypes { diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt index faa455a..791d0c5 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -56,6 +56,9 @@ interface ApiService { @POST("api/servers/{id}/test-connection") suspend fun testServerConnection(@Path("id") id: Int): ApiResponse + @POST("api/servers/{id}/service-action") + suspend fun serviceAction(@Path("id") id: Int, @Body request: ServiceActionRequest): ApiResponse + @GET("api/refresh-metrics") suspend fun refreshMetrics(): ApiResponse> diff --git a/android/app/src/main/java/com/devlab/app/data/model/Server.kt b/android/app/src/main/java/com/devlab/app/data/model/Server.kt index ed436ff..9de3200 100644 --- a/android/app/src/main/java/com/devlab/app/data/model/Server.kt +++ b/android/app/src/main/java/com/devlab/app/data/model/Server.kt @@ -48,3 +48,15 @@ data class ConnectionResult( val connected: Boolean = false, val error: String? = null ) + +@Serializable +data class ServiceActionRequest( + val service: String, + val action: String +) + +@Serializable +data class ServiceActionResult( + val message: String? = null, + val output: String? = null +) diff --git a/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt b/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt index 5e50d2e..365255c 100644 --- a/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt +++ b/android/app/src/main/java/com/devlab/app/data/repository/ServerRepository.kt @@ -38,4 +38,8 @@ class ServerRepository { suspend fun testConnection(id: Int): ApiResponse { return api.testServerConnection(id) } + + suspend fun serviceAction(id: Int, service: String, action: String): ApiResponse { + return api.serviceAction(id, ServiceActionRequest(service, action)) + } } diff --git a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt index f07d7d4..61f1481 100644 --- a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailScreen.kt @@ -3,7 +3,6 @@ package com.devlab.app.ui.servers import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.ui.graphics.Brush import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -14,8 +13,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.devlab.app.data.model.ServiceInfo import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.devlab.app.ui.components.LoadingIndicator @@ -335,14 +336,42 @@ private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewMo @Composable private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { + var showServiceDialog by remember { mutableStateOf(false) } + var selectedService by remember { mutableStateOf(null) } + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = Icons.Default.MiscellaneousServices, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Spacer(modifier = Modifier.width(8.dp)) - Text(text = "Running Services", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(text = "Services", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = { viewModel.loadServices() }) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Refresh") + } } Spacer(modifier = Modifier.height(12.dp)) + if (state.serviceActionResult != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (state.serviceActionResult?.contains("ed.") == true) Green500.copy(alpha = 0.1f) else MaterialTheme.colorScheme.errorContainer + ) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Info, contentDescription = null, + tint = if (state.serviceActionResult?.contains("ed.") == true) Green500 else MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = state.serviceActionResult ?: "", style = MaterialTheme.typography.bodySmall) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + when { state.isLoadingServices -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -367,8 +396,15 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM Column(modifier = Modifier.verticalScroll(rememberScrollState())) { state.services.forEach { service -> val isActive = service.status == "active" + val isRunning = state.serviceActionRunning == "${service.name}:start" || + state.serviceActionRunning == "${service.name}:stop" || + state.serviceActionRunning == "${service.name}:restart" || + state.serviceActionRunning == "${service.name}:reload" + Card( + onClick = { selectedService = service; showServiceDialog = true }, modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + enabled = !isRunning, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { @@ -389,12 +425,16 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM Text(text = service.description, style = MaterialTheme.typography.labelSmall, color = Grey500) } } - Text( - text = if (isActive) "Active" else service.status, - color = if (isActive) Green500 else Grey500, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) + if (isRunning) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + text = if (isActive) "Active" else service.status, + color = if (isActive) Green500 else Grey500, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium + ) + } } } } @@ -402,6 +442,60 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM } } } + + if (showServiceDialog && selectedService != null) { + val service = selectedService!! + val isActive = service.status == "active" + AlertDialog( + onDismissRequest = { showServiceDialog = false }, + title = { Text(service.name, fontWeight = FontWeight.SemiBold) }, + text = { + Column { + Text("Status: ${if (isActive) "Active" else service.status}", style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(16.dp)) + Text("Choose an action:", style = MaterialTheme.typography.bodySmall, color = Grey500) + Spacer(modifier = Modifier.height(8.dp)) + if (isActive) { + ServiceActionChip("Restart", Icons.Default.Refresh, Orange500) { + viewModel.serviceAction(service.name, "restart") + showServiceDialog = false + } + ServiceActionChip("Stop", Icons.Default.Stop, Red500) { + viewModel.serviceAction(service.name, "stop") + showServiceDialog = false + } + ServiceActionChip("Reload", Icons.Default.Autorenew, Blue700) { + viewModel.serviceAction(service.name, "reload") + showServiceDialog = false + } + } else { + ServiceActionChip("Start", Icons.Default.PlayArrow, Green500) { + viewModel.serviceAction(service.name, "start") + showServiceDialog = false + } + } + } + }, + confirmButton = { + TextButton(onClick = { showServiceDialog = false }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +private fun ServiceActionChip(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = color) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(label, fontWeight = FontWeight.Medium) + } } @Composable diff --git a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt index 6224ec8..ced7215 100644 --- a/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt +++ b/android/app/src/main/java/com/devlab/app/ui/servers/ServerDetailViewModel.kt @@ -23,6 +23,8 @@ data class ServerDetailUiState( val services: List = emptyList(), val isLoadingServices: Boolean = false, val servicesError: String? = null, + val serviceActionRunning: String? = null, + val serviceActionResult: String? = null, val isTestingConnection: Boolean = false, val connectionResult: Boolean? = null, val actionMessage: String? = null, @@ -212,7 +214,40 @@ class ServerDetailViewModel : ViewModel() { } } + fun serviceAction(service: String, action: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + serviceActionRunning = "$action:$service", + serviceActionResult = null + ) + try { + val response = repository.serviceAction(serverId, service, action) + if (response.success) { + _uiState.value = _uiState.value.copy( + serviceActionRunning = null, + serviceActionResult = "Service '${service}' ${action}ed." + ) + loadServices() + } else { + _uiState.value = _uiState.value.copy( + serviceActionRunning = null, + serviceActionResult = response.error ?: "Action failed" + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + serviceActionRunning = null, + serviceActionResult = e.message ?: "Network error" + ) + } + } + } + fun clearActionMessage() { - _uiState.value = _uiState.value.copy(actionMessage = null, connectionResult = null) + _uiState.value = _uiState.value.copy( + actionMessage = null, + connectionResult = null, + serviceActionResult = null + ) } } diff --git a/public/sysadmin.apk b/public/sysadmin.apk index 424ed0d..e113a0d 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index 25ab178..9088f07 100755 --- a/routes/web.php +++ b/routes/web.php @@ -113,6 +113,7 @@ $router->get('/api/servers/:id/services', [ApiController::class, 'serverServices $router->post('/api/servers/:id/reboot', [ApiController::class, 'serverReboot']); $router->post('/api/servers/:id/shutdown', [ApiController::class, 'serverShutdown']); $router->post('/api/servers/:id/test-connection', [ApiController::class, 'serverTestConnection']); +$router->post('/api/servers/:id/service-action', [ApiController::class, 'serverServiceAction']); $router->get('/api/notifications', [ApiController::class, 'notifications']); $router->get('/api/notifications/unread-count', [ApiController::class, 'unreadCount']); $router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 13c7f23..1a8fd84 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -644,6 +644,68 @@ class ApiController } } + public function serverServiceAction(int $id): void + { + $user = $this->authenticateRequest(); + + $serverModel = new Server(); + if (!$serverModel->canManage($id, (int) $user['id'])) { + $this->view->json(['error' => 'Forbidden'], 403); + } + + $server = $serverModel->findById($id); + if (!$server) { + $this->view->json(['error' => 'Server not found'], 404); + } + + $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; + $service = $input['service'] ?? ''; + $action = $input['action'] ?? ''; + $allowed = ['start', 'stop', 'restart', 'reload']; + + if (empty($service) || !in_array($action, $allowed, true)) { + $this->view->json(['error' => 'Invalid service or action'], 400); + } + + $sanitized = preg_replace('/[^a-zA-Z0-9\-_]/', '', $service); + + try { + $ssh = new SSHService(); + if (!$ssh->connect($server)) { + throw new \RuntimeException('SSH connection failed'); + } + + $result = $ssh->exec("sudo systemctl {$action} {$sanitized} 2>&1"); + $ssh->disconnect(); + + $exitCode = (int) ($result['exit_status'] ?? -1); + + $auditService = new AuditService(); + $auditService->logServerAction($id, "service_{$action}", "{$sanitized} {$action}"); + + if ($exitCode === 0) { + $this->view->json([ + 'success' => true, + 'data' => [ + 'message' => "Service '{$sanitized}' {$action}ed.", + 'output' => trim($result['output'] ?? ''), + ], + ]); + } else { + $this->view->json([ + 'success' => false, + 'error' => "Failed to {$action} '{$sanitized}'.", + 'data' => ['output' => trim($result['output'] ?? '')], + ], 500); + } + } catch (\Throwable $e) { + $this->view->json([ + 'success' => false, + 'error' => $e->getMessage(), + ], 500); + } + } + private function executeServerAction(int $id, string $action, string $command): void { $user = $this->authenticateRequest(); diff --git a/views/dashboard/index.php b/views/dashboard/index.php index b236dd2..d1fed38 100755 --- a/views/dashboard/index.php +++ b/views/dashboard/index.php @@ -52,13 +52,6 @@ $recentActivity = $recentActivity ?? []; Avg Disk - -
-
- v1.0 - Android App -
-