diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0ea1a3b..d9a627b 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 = 6 - versionName = "1.5.0" + versionCode = 7 + versionName = "1.6.0" } buildTypes { 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 61f1481..7db32a7 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 @@ -338,6 +338,15 @@ private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewMo private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { var showServiceDialog by remember { mutableStateOf(false) } var selectedService by remember { mutableStateOf(null) } + var serviceQuery by remember { mutableStateOf("") } + + val filtered = remember(state.services, serviceQuery) { + if (serviceQuery.isBlank()) state.services + else state.services.filter { + it.name.contains(serviceQuery, ignoreCase = true) || + it.description.contains(serviceQuery, ignoreCase = true) + } + } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -351,7 +360,17 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM Text("Refresh") } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = serviceQuery, + onValueChange = { serviceQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search services...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) if (state.serviceActionResult != null) { Card( @@ -387,14 +406,14 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM } } } - state.services.isEmpty() -> { + filtered.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No services found", color = Grey500) + Text(if (serviceQuery.isNotBlank()) "No services match your search" else "No services found", color = Grey500) } } else -> { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - state.services.forEach { service -> + filtered.forEach { service -> val isActive = service.status == "active" val isRunning = state.serviceActionRunning == "${service.name}:start" || state.serviceActionRunning == "${service.name}:stop" || @@ -446,33 +465,82 @@ 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) }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.MiscellaneousServices, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text(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) + Surface( + color = (if (isActive) Green500 else Grey500).copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(if (isActive) Green500 else Grey500) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isActive) "Active" else service.status.replaceFirstChar { it.uppercase() }, + color = if (isActive) Green500 else Grey500, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Actions", + style = MaterialTheme.typography.labelMedium, + 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 - } + ServiceActionBtn( + label = "Restart", + icon = Icons.Default.Refresh, + color = Orange500, + onClick = { viewModel.serviceAction(service.name, "restart"); showServiceDialog = false } + ) + ServiceActionBtn( + label = "Stop", + icon = Icons.Default.Stop, + color = Red500, + onClick = { viewModel.serviceAction(service.name, "stop"); showServiceDialog = false } + ) + ServiceActionBtn( + label = "Reload", + icon = Icons.Default.Autorenew, + color = Blue700, + onClick = { viewModel.serviceAction(service.name, "reload"); showServiceDialog = false } + ) } else { - ServiceActionChip("Start", Icons.Default.PlayArrow, Green500) { - viewModel.serviceAction(service.name, "start") - showServiceDialog = false - } + ServiceActionBtn( + label = "Start", + icon = Icons.Default.PlayArrow, + color = Green500, + onClick = { viewModel.serviceAction(service.name, "start"); showServiceDialog = false } + ) } } }, @@ -480,21 +548,26 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM TextButton(onClick = { showServiceDialog = false }) { Text("Cancel") } - } + }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(16.dp) ) } } @Composable -private fun ServiceActionChip(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { - OutlinedButton( +private fun ServiceActionBtn(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { + FilledTonalButton( onClick = onClick, modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = color) + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = color.copy(alpha = 0.12f), + contentColor = color + ) ) { Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text(label, fontWeight = FontWeight.Medium) + Text(label, fontWeight = FontWeight.SemiBold) } } diff --git a/public/sysadmin.apk b/public/sysadmin.apk index e113a0d..2f4525a 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 1a8fd84..895754d 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -659,15 +659,18 @@ class ApiController } $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; - $service = $input['service'] ?? ''; - $action = $input['action'] ?? ''; + $service = (string) ($input['service'] ?? ''); + $action = (string) ($input['action'] ?? ''); $allowed = ['start', 'stop', 'restart', 'reload']; - if (empty($service) || !in_array($action, $allowed, true)) { + if ($service === '' || !in_array($action, $allowed, true)) { $this->view->json(['error' => 'Invalid service or action'], 400); } $sanitized = preg_replace('/[^a-zA-Z0-9\-_]/', '', $service); + if ($sanitized === '') { + $this->view->json(['error' => 'Invalid service name'], 400); + } try { $ssh = new SSHService(); @@ -678,7 +681,7 @@ class ApiController $result = $ssh->exec("sudo systemctl {$action} {$sanitized} 2>&1"); $ssh->disconnect(); - $exitCode = (int) ($result['exit_status'] ?? -1); + $exitCode = isset($result['exit_status']) ? (int) $result['exit_status'] : -1; $auditService = new AuditService(); $auditService->logServerAction($id, "service_{$action}", "{$sanitized} {$action}"); @@ -694,7 +697,7 @@ class ApiController } else { $this->view->json([ 'success' => false, - 'error' => "Failed to {$action} '{$sanitized}'.", + 'error' => "Failed to {$action} '{$sanitized}'. Exit code: {$exitCode}", 'data' => ['output' => trim($result['output'] ?? '')], ], 500); }