v1.5.0 — service actions + cleanup #29
@@ -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 {
|
||||
|
||||
@@ -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?>>
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -52,13 +52,6 @@ $recentActivity = $recentActivity ?? [];
|
||||
<span class="stat-label">Avg Disk</span>
|
||||
</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 class="dashboard-grid">
|
||||
|
||||
Reference in New Issue
Block a user