feat: v1.6.0 — fix service 500, search bar, modal redesign

- Fix 500 error: cast to string before preg_replace
- Search bar to filter services by name/description
- Redesigned service action modal with status badge + colored buttons
- Version 1.6.0
This commit is contained in:
2026-06-09 19:20:18 -04:00
parent c4277c937f
commit 3e13ac60f1
4 changed files with 112 additions and 36 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "com.devlab.app" applicationId = "com.devlab.app"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 6 versionCode = 7
versionName = "1.5.0" versionName = "1.6.0"
} }
buildTypes { buildTypes {

View File

@@ -338,6 +338,15 @@ private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewMo
private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) {
var showServiceDialog by remember { mutableStateOf(false) } var showServiceDialog by remember { mutableStateOf(false) }
var selectedService by remember { mutableStateOf<ServiceInfo?>(null) } var selectedService by remember { mutableStateOf<ServiceInfo?>(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)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -351,7 +360,17 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM
Text("Refresh") 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) { if (state.serviceActionResult != null) {
Card( Card(
@@ -387,14 +406,14 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM
} }
} }
} }
state.services.isEmpty() -> { filtered.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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 -> { else -> {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
state.services.forEach { service -> filtered.forEach { service ->
val isActive = service.status == "active" val isActive = service.status == "active"
val isRunning = state.serviceActionRunning == "${service.name}:start" || val isRunning = state.serviceActionRunning == "${service.name}:start" ||
state.serviceActionRunning == "${service.name}:stop" || state.serviceActionRunning == "${service.name}:stop" ||
@@ -446,33 +465,82 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM
if (showServiceDialog && selectedService != null) { if (showServiceDialog && selectedService != null) {
val service = selectedService!! val service = selectedService!!
val isActive = service.status == "active" val isActive = service.status == "active"
AlertDialog( AlertDialog(
onDismissRequest = { showServiceDialog = false }, 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 = { text = {
Column { Column {
Text("Status: ${if (isActive) "Active" else service.status}", style = MaterialTheme.typography.bodyMedium) Surface(
Spacer(modifier = Modifier.height(16.dp)) color = (if (isActive) Green500 else Grey500).copy(alpha = 0.15f),
Text("Choose an action:", style = MaterialTheme.typography.bodySmall, color = Grey500) 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)) Spacer(modifier = Modifier.height(8.dp))
if (isActive) { if (isActive) {
ServiceActionChip("Restart", Icons.Default.Refresh, Orange500) { ServiceActionBtn(
viewModel.serviceAction(service.name, "restart") label = "Restart",
showServiceDialog = false icon = Icons.Default.Refresh,
} color = Orange500,
ServiceActionChip("Stop", Icons.Default.Stop, Red500) { onClick = { viewModel.serviceAction(service.name, "restart"); showServiceDialog = false }
viewModel.serviceAction(service.name, "stop") )
showServiceDialog = false ServiceActionBtn(
} label = "Stop",
ServiceActionChip("Reload", Icons.Default.Autorenew, Blue700) { icon = Icons.Default.Stop,
viewModel.serviceAction(service.name, "reload") color = Red500,
showServiceDialog = false 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 { } else {
ServiceActionChip("Start", Icons.Default.PlayArrow, Green500) { ServiceActionBtn(
viewModel.serviceAction(service.name, "start") label = "Start",
showServiceDialog = false 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 }) { TextButton(onClick = { showServiceDialog = false }) {
Text("Cancel") Text("Cancel")
} }
} },
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) )
} }
} }
@Composable @Composable
private fun ServiceActionChip(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { private fun ServiceActionBtn(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
OutlinedButton( FilledTonalButton(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), 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)) Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(label, fontWeight = FontWeight.Medium) Text(label, fontWeight = FontWeight.SemiBold)
} }
} }

Binary file not shown.

View File

@@ -659,15 +659,18 @@ class ApiController
} }
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST; $input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$service = $input['service'] ?? ''; $service = (string) ($input['service'] ?? '');
$action = $input['action'] ?? ''; $action = (string) ($input['action'] ?? '');
$allowed = ['start', 'stop', 'restart', 'reload']; $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); $this->view->json(['error' => 'Invalid service or action'], 400);
} }
$sanitized = preg_replace('/[^a-zA-Z0-9\-_]/', '', $service); $sanitized = preg_replace('/[^a-zA-Z0-9\-_]/', '', $service);
if ($sanitized === '') {
$this->view->json(['error' => 'Invalid service name'], 400);
}
try { try {
$ssh = new SSHService(); $ssh = new SSHService();
@@ -678,7 +681,7 @@ class ApiController
$result = $ssh->exec("sudo systemctl {$action} {$sanitized} 2>&1"); $result = $ssh->exec("sudo systemctl {$action} {$sanitized} 2>&1");
$ssh->disconnect(); $ssh->disconnect();
$exitCode = (int) ($result['exit_status'] ?? -1); $exitCode = isset($result['exit_status']) ? (int) $result['exit_status'] : -1;
$auditService = new AuditService(); $auditService = new AuditService();
$auditService->logServerAction($id, "service_{$action}", "{$sanitized} {$action}"); $auditService->logServerAction($id, "service_{$action}", "{$sanitized} {$action}");
@@ -694,7 +697,7 @@ class ApiController
} else { } else {
$this->view->json([ $this->view->json([
'success' => false, 'success' => false,
'error' => "Failed to {$action} '{$sanitized}'.", 'error' => "Failed to {$action} '{$sanitized}'. Exit code: {$exitCode}",
'data' => ['output' => trim($result['output'] ?? '')], 'data' => ['output' => trim($result['output'] ?? '')],
], 500); ], 500);
} }