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" applicationId = "com.devlab.app"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 5 versionCode = 6
versionName = "1.4.0" versionName = "1.5.0"
} }
buildTypes { buildTypes {

View File

@@ -56,6 +56,9 @@ interface ApiService {
@POST("api/servers/{id}/test-connection") @POST("api/servers/{id}/test-connection")
suspend fun testServerConnection(@Path("id") id: Int): ApiResponse<ConnectionResult> 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") @GET("api/refresh-metrics")
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>> suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>

View File

@@ -48,3 +48,15 @@ data class ConnectionResult(
val connected: Boolean = false, val connected: Boolean = false,
val error: String? = null 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> { suspend fun testConnection(id: Int): ApiResponse<ConnectionResult> {
return api.testServerConnection(id) 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.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -14,8 +13,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.devlab.app.data.model.ServiceInfo
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.devlab.app.ui.components.LoadingIndicator import com.devlab.app.ui.components.LoadingIndicator
@@ -335,14 +336,42 @@ private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewMo
@Composable @Composable
private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) { 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)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.MiscellaneousServices, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Icon(imageVector = Icons.Default.MiscellaneousServices, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp)) 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)) 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 { when {
state.isLoadingServices -> { state.isLoadingServices -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -367,8 +396,15 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
state.services.forEach { service -> state.services.forEach { service ->
val isActive = service.status == "active" 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( Card(
onClick = { selectedService = service; showServiceDialog = true },
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
enabled = !isRunning,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) { ) {
@@ -389,6 +425,9 @@ private fun ServicesTab(state: ServerDetailUiState, viewModel: ServerDetailViewM
Text(text = service.description, style = MaterialTheme.typography.labelSmall, color = Grey500) Text(text = service.description, style = MaterialTheme.typography.labelSmall, color = Grey500)
} }
} }
if (isRunning) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text( Text(
text = if (isActive) "Active" else service.status, text = if (isActive) "Active" else service.status,
color = if (isActive) Green500 else Grey500, color = if (isActive) Green500 else Grey500,
@@ -402,6 +441,61 @@ 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 @Composable

View File

@@ -23,6 +23,8 @@ data class ServerDetailUiState(
val services: List<ServiceInfo> = emptyList(), val services: List<ServiceInfo> = emptyList(),
val isLoadingServices: Boolean = false, val isLoadingServices: Boolean = false,
val servicesError: String? = null, val servicesError: String? = null,
val serviceActionRunning: String? = null,
val serviceActionResult: String? = null,
val isTestingConnection: Boolean = false, val isTestingConnection: Boolean = false,
val connectionResult: Boolean? = null, val connectionResult: Boolean? = null,
val actionMessage: String? = 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() { fun clearActionMessage() {
_uiState.value = _uiState.value.copy(actionMessage = null, connectionResult = null) _uiState.value = _uiState.value.copy(
actionMessage = null,
connectionResult = null,
serviceActionResult = null
)
} }
} }

Binary file not shown.

View File

@@ -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/reboot', [ApiController::class, 'serverReboot']);
$router->post('/api/servers/:id/shutdown', [ApiController::class, 'serverShutdown']); $router->post('/api/servers/:id/shutdown', [ApiController::class, 'serverShutdown']);
$router->post('/api/servers/:id/test-connection', [ApiController::class, 'serverTestConnection']); $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', [ApiController::class, 'notifications']);
$router->get('/api/notifications/unread-count', [ApiController::class, 'unreadCount']); $router->get('/api/notifications/unread-count', [ApiController::class, 'unreadCount']);
$router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']); $router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']);

View File

@@ -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 private function executeServerAction(int $id, string $action, string $command): void
{ {
$user = $this->authenticateRequest(); $user = $this->authenticateRequest();

View File

@@ -52,13 +52,6 @@ $recentActivity = $recentActivity ?? [];
<span class="stat-label">Avg Disk</span> <span class="stat-label">Avg Disk</span>
</div> </div>
</div> </div>
<a href="/sysadmin.apk" class="stat-card stat-card-info" download style="text-decoration:none; cursor:pointer;">
<div class="stat-icon"><i class="fas fa-download"></i></div>
<div class="stat-info">
<span class="stat-value">v1.0</span>
<span class="stat-label">Android App</span>
</div>
</a>
</div> </div>
<div class="dashboard-grid"> <div class="dashboard-grid">