Fix Medium severity performance findings (PERF-17 to PERF-21) (#325)
* Fix Medium severity performance findings (PERF-17 to PERF-21) This commit addresses the following performance findings: - PERF-17: Implemented a message queue in `HermesWsClient` using `ConcurrentLinkedQueue`. If the WebSocket is disconnected, messages are queued and flushed upon a successful connection (`onOpen`). - PERF-18: Added exponential backoff to `safeApiCall` for 5xx and IO exceptions, reducing the load on a struggling server. - PERF-19: Optimized Kanban task filtering by performing `groupBy` upfront inside a `remember` block and passing item keys to `LazyColumn`, avoiding O(C×T) calculation per recomposition. - PERF-20: Updated `getLogs` API in `HermesApiService` to accept `lines` and `limit` query params to truncate logs early, avoiding potential JSON parsing OOMs with large log files. - PERF-21: Optimized drawer entry generation in `Navigation.kt` by grouping navigation items inside a `remember` block. Fixes #282 * Fix Medium severity performance findings (PERF-17 to PERF-21) This commit addresses the following performance findings: - PERF-17: Implemented a message queue in `HermesWsClient` using `ConcurrentLinkedQueue`. If the WebSocket is disconnected, messages are queued and flushed upon a successful connection (`onOpen`). - PERF-18: Added exponential backoff to `safeApiCall` for 5xx and IO exceptions, reducing the load on a struggling server. - PERF-19: Optimized Kanban task filtering by performing `groupBy` upfront inside a `remember` block and passing item keys to `LazyColumn`, avoiding O(C×T) calculation per recomposition. - PERF-20: Updated `getLogs` API in `HermesApiService` to accept `lines` and `limit` query params to truncate logs early, avoiding potential JSON parsing OOMs with large log files. - PERF-21: Optimized drawer entry generation in `Navigation.kt` by grouping navigation items inside a `remember` block. Fixes #282 * Fix Medium severity performance findings (PERF-17 to PERF-21) This commit addresses the following performance findings: - PERF-17: Implemented a message queue in `HermesWsClient` using `ConcurrentLinkedQueue`. If the WebSocket is disconnected, messages are queued and flushed upon a successful connection (`onOpen`). - PERF-18: Added exponential backoff to `safeApiCall` for 5xx and IO exceptions, reducing the load on a struggling server. - PERF-19: Optimized Kanban task filtering by performing `groupBy` upfront inside a `remember` block and passing item keys to `LazyColumn`, avoiding O(C×T) calculation per recomposition. - PERF-20: Updated `getLogs` API in `HermesApiService` to accept `lines` and `limit` query params to truncate logs early, avoiding potential JSON parsing OOMs with large log files. - PERF-21: Optimized drawer entry generation in `Navigation.kt` by grouping navigation items inside a `remember` block. Fixes #282 * fix: resolve ktlint formatting issues in KanbanScreen.kt and Navigation.kt * fix: fix ktlint import ordering in HermesWsClient.kt --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
42fcf2a14b
commit
d616c56176
7 files changed
+71
-42
No files matched your search
@@ -346,6 +346,8 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
val bottomNavItems = resolveBottomNavItems(bottomNavItemsState)
|
||||
val bottomNavKeys = remember(bottomNavItems) { bottomNavItems.mapTo(mutableSetOf()) { it.key } }
|
||||
|
||||
val drawerEntriesBySection = remember { DRAWER_ENTRIES.groupBy { it.section } }
|
||||
|
||||
// Sync primary screens to NavigationController
|
||||
LaunchedEffect(bottomNavKeys) {
|
||||
NavigationController.updatePrimaryScreens(bottomNavKeys)
|
||||
@@ -419,33 +421,31 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
DRAWER_ENTRIES
|
||||
.filter { it.section == section }
|
||||
.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(entry.icon, contentDescription = null) },
|
||||
label = { Text(stringResource(entry.labelRes)) },
|
||||
selected = currentScreen == entry.key,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(entry.key)
|
||||
},
|
||||
colors =
|
||||
NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
drawerEntriesBySection[section]?.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(entry.icon, contentDescription = null) },
|
||||
label = { Text(stringResource(entry.labelRes)) },
|
||||
selected = currentScreen == entry.key,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(entry.key)
|
||||
},
|
||||
colors =
|
||||
NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 1.dp)
|
||||
.testTag(
|
||||
"drawer_${entry.key::class.simpleName?.lowercase()?.removeSuffix(
|
||||
"screen",
|
||||
) ?: ""}",
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 1.dp)
|
||||
.testTag(
|
||||
"drawer_${entry.key::class.simpleName?.lowercase()?.removeSuffix(
|
||||
"screen",
|
||||
) ?: ""}",
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -212,7 +212,10 @@ interface HermesApiService {
|
||||
suspend fun getModelOptions(): Response<ModelOptionsResponse>
|
||||
|
||||
@GET("api/logs")
|
||||
suspend fun getLogs(): Response<LogResponse>
|
||||
suspend fun getLogs(
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("lines") lines: Int? = null,
|
||||
): Response<LogResponse>
|
||||
|
||||
@GET("api/dashboard/plugins/hub")
|
||||
suspend fun getPlugins(): Response<PluginsHubResponse>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
@@ -68,6 +69,8 @@ suspend inline fun <reified T> safeApiCall(
|
||||
} else {
|
||||
val code = response.code()
|
||||
if (code in 500..599 && attempt < retries) {
|
||||
val backoff = 500L * (1 shl attempt)
|
||||
delay(backoff)
|
||||
continue
|
||||
}
|
||||
return NetworkResult.Failure(mapHttpError(code))
|
||||
@@ -82,6 +85,8 @@ suspend inline fun <reified T> safeApiCall(
|
||||
),
|
||||
)
|
||||
}
|
||||
val backoff = 500L * (1 shl attempt)
|
||||
delay(backoff)
|
||||
} catch (e: Exception) {
|
||||
return NetworkResult.Failure(
|
||||
NetworkError.Unknown(
|
||||
|
||||
@@ -28,6 +28,7 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -64,6 +65,7 @@ object HermesWsClient {
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val connected = AtomicBoolean(false)
|
||||
private val intentionalClose = AtomicBoolean(false)
|
||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
@@ -170,7 +172,13 @@ object HermesWsClient {
|
||||
// prompts (PromptSubmit params include the `text` field) — never
|
||||
// stream to logcat in release builds.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
||||
webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected")
|
||||
val ws = webSocket
|
||||
if (ws != null && connected.get()) {
|
||||
ws.send(json)
|
||||
} else {
|
||||
Log.d(TAG, "WS disconnected — queuing message")
|
||||
messageQueue.add(json)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -243,17 +251,25 @@ object HermesWsClient {
|
||||
|
||||
private class WsListenerImpl : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
connected.set(true)
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
|
||||
while (messageQueue.isNotEmpty()) {
|
||||
val msg = messageQueue.poll()
|
||||
if (msg != null) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
||||
webSocket.send(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
ws: WebSocket,
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
// B2 (Jun 18 2026, kanban t_8884db16): incoming WS text contains
|
||||
@@ -264,16 +280,16 @@ object HermesWsClient {
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
ws: WebSocket,
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
Log.d(TAG, "WebSocket closing: $code $reason")
|
||||
ws.close(code, reason)
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
ws: WebSocket,
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
@@ -284,7 +300,7 @@ object HermesWsClient {
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
ws: WebSocket,
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
|
||||
@@ -72,6 +72,11 @@ fun KanbanScreen(
|
||||
task.assignedTo?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
val tasksByColumn =
|
||||
remember(filteredTasks) {
|
||||
filteredTasks.groupBy { it.status.lowercase() }
|
||||
}
|
||||
var showAddTaskDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -149,13 +154,13 @@ fun KanbanScreen(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(state.columns, key = { it.name }) { column ->
|
||||
items(state.columns.size, key = {
|
||||
index ->
|
||||
state.columns[index].name
|
||||
}) { columnIndex ->
|
||||
val column = state.columns[columnIndex]
|
||||
val colName = column.name
|
||||
val colTasks =
|
||||
filteredTasks.filter {
|
||||
it.status.equals(colName, ignoreCase = true)
|
||||
}
|
||||
val columnIndex = state.columns.indexOf(column)
|
||||
val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList()
|
||||
val prevColumn = state.columns.getOrNull(columnIndex - 1)
|
||||
val nextColumn = state.columns.getOrNull(columnIndex + 1)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class LogsViewModel : ViewModel(), ToastHost {
|
||||
|
||||
fun loadLogs() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getLogs() } },
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getLogs(lines = 1000) } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
val body = data
|
||||
|
||||
Reference in new issue
Block a user