feat: v1.5.0 + service actions + remove duplicate apk

- Services tab: start/stop/restart/reload via dialog
- Backend: POST /api/servers/:id/service-action
- Removed duplicate APK download from dashboard view
- Version bump to 1.5.0
This commit is contained in:
2026-06-09 19:02:41 -04:00
parent d4ab8c7b40
commit c4277c937f
10 changed files with 222 additions and 18 deletions

View File

@@ -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 {

View File

@@ -56,6 +56,9 @@ interface ApiService {
@POST("api/servers/{id}/test-connection")
suspend fun testServerConnection(@Path("id") id: Int): ApiResponse<ConnectionResult>
@POST("api/servers/{id}/service-action")
suspend fun serviceAction(@Path("id") id: Int, @Body request: ServiceActionRequest): ApiResponse<ServiceActionResult>
@GET("api/refresh-metrics")
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>

View File

@@ -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
)

View File

@@ -38,4 +38,8 @@ class ServerRepository {
suspend fun testConnection(id: Int): ApiResponse<ConnectionResult> {
return api.testServerConnection(id)
}
suspend fun serviceAction(id: Int, service: String, action: String): ApiResponse<ServiceActionResult> {
return api.serviceAction(id, ServiceActionRequest(service, action))
}
}

View File

@@ -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<ServiceInfo?>(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

View File

@@ -23,6 +23,8 @@ data class ServerDetailUiState(
val services: List<ServiceInfo> = 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
)
}
}