fix(#372): plugin install/uninstall 400 — enable instead of install for on-disk plugins, gate uninstall behind canRemove, parse FastAPI error bodies (#416)
- Replace installPlugin(name) with activatePlugin(plugin) that calls enable endpoint instead of the install endpoint for plugins already on disk (bundled/inactive). - Gate Uninstall button behind plugin.canRemove — bundled plugins don't show it. - Update PluginsScreen to call activatePlugin(plugin) instead of installPlugin(name). - Parse FastAPI 'detail' field from 400 response bodies in mapHttpError/safeApiCall so users see the actual error message instead of a generic placeholder. - Import ButtonDefaults to fix max-line-length lint warning. Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
b10495c6e2
commit
2ae6cce929
3 files changed
+36
-14
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -37,12 +38,28 @@ sealed interface NetworkError {
|
|||||||
) : NetworkError
|
) : NetworkError
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mapHttpError(code: Int): NetworkError {
|
fun mapHttpError(
|
||||||
|
code: Int,
|
||||||
|
errorBody: String? = null,
|
||||||
|
): NetworkError {
|
||||||
if (code == 401) {
|
if (code == 401) {
|
||||||
return NetworkError.AuthExpired()
|
return NetworkError.AuthExpired()
|
||||||
}
|
}
|
||||||
|
// Try to parse FastAPI-style error detail from response body
|
||||||
|
val detail =
|
||||||
|
if (errorBody != null) {
|
||||||
|
try {
|
||||||
|
val parsed = com.google.gson.JsonParser.parseString(errorBody).asJsonObject
|
||||||
|
parsed.get("detail")?.asString
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
val message =
|
val message =
|
||||||
when (code) {
|
detail ?: when (code) {
|
||||||
400 -> "Bad Request (HTTP 400): The server could not understand the request."
|
400 -> "Bad Request (HTTP 400): The server could not understand the request."
|
||||||
403 -> "Forbidden (HTTP 403): You do not have permission to access this resource."
|
403 -> "Forbidden (HTTP 403): You do not have permission to access this resource."
|
||||||
404 -> "Not Found (HTTP 404): The requested resource could not be found."
|
404 -> "Not Found (HTTP 404): The requested resource could not be found."
|
||||||
@@ -88,12 +105,19 @@ suspend inline fun <reified T> safeApiCall(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val code = response.code()
|
val code = response.code()
|
||||||
|
// Read error body for detail message
|
||||||
|
val errorBody =
|
||||||
|
try {
|
||||||
|
response.errorBody()?.string()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
if ((code in 500..599 || code == 429) && attempt < retries) {
|
if ((code in 500..599 || code == 429) && attempt < retries) {
|
||||||
val backoff = jitteredBackoff(500L * (1 shl attempt))
|
val backoff = jitteredBackoff(500L * (1 shl attempt))
|
||||||
delay(backoff)
|
delay(backoff)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return NetworkResult.Failure(mapHttpError(code))
|
return NetworkResult.Failure(mapHttpError(code, errorBody))
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
lastException = e
|
lastException = e
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -159,18 +160,20 @@ fun PluginsScreen(
|
|||||||
OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) {
|
OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) {
|
||||||
Text(stringResource(R.string.plugins_action_update))
|
Text(stringResource(R.string.plugins_action_update))
|
||||||
}
|
}
|
||||||
|
if (plugin.canRemove) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.uninstallPlugin(plugin.name) },
|
onClick = { viewModel.uninstallPlugin(plugin.name) },
|
||||||
colors =
|
colors =
|
||||||
androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
|
ButtonDefaults.outlinedButtonColors(
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
contentColor = MaterialTheme.colorScheme.error,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.plugins_action_uninstall))
|
Text(stringResource(R.string.plugins_action_uninstall))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(onClick = { viewModel.installPlugin(plugin.name) }) {
|
Button(onClick = { viewModel.activatePlugin(plugin) }) {
|
||||||
Text(stringResource(R.string.plugins_action_install))
|
Text(stringResource(R.string.plugins_action_install))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,25 +79,20 @@ class PluginsViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun installPlugin(name: String) {
|
fun activatePlugin(plugin: PluginInfo) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall {
|
safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
|
||||||
ApiClient.hermesApi.installPlugin(
|
|
||||||
com.m57.hermescontrol.data.model
|
|
||||||
.AgentPluginInstallBody(name),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Plugin installed successfully") }
|
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||||
loadPlugins()
|
loadPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to install plugin: ${result.error.message}") }
|
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user