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
+48
-19
No files matched your search
@@ -346,6 +346,8 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
val bottomNavItems = resolveBottomNavItems(bottomNavItemsState)
|
val bottomNavItems = resolveBottomNavItems(bottomNavItemsState)
|
||||||
val bottomNavKeys = remember(bottomNavItems) { bottomNavItems.mapTo(mutableSetOf()) { it.key } }
|
val bottomNavKeys = remember(bottomNavItems) { bottomNavItems.mapTo(mutableSetOf()) { it.key } }
|
||||||
|
|
||||||
|
val drawerEntriesBySection = remember { DRAWER_ENTRIES.groupBy { it.section } }
|
||||||
|
|
||||||
// Sync primary screens to NavigationController
|
// Sync primary screens to NavigationController
|
||||||
LaunchedEffect(bottomNavKeys) {
|
LaunchedEffect(bottomNavKeys) {
|
||||||
NavigationController.updatePrimaryScreens(bottomNavKeys)
|
NavigationController.updatePrimaryScreens(bottomNavKeys)
|
||||||
@@ -419,9 +421,7 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
DRAWER_ENTRIES
|
drawerEntriesBySection[section]?.forEach { entry ->
|
||||||
.filter { it.section == section }
|
|
||||||
.forEach { entry ->
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(entry.icon, contentDescription = null) },
|
icon = { Icon(entry.icon, contentDescription = null) },
|
||||||
label = { Text(stringResource(entry.labelRes)) },
|
label = { Text(stringResource(entry.labelRes)) },
|
||||||
|
|||||||
@@ -212,7 +212,10 @@ interface HermesApiService {
|
|||||||
suspend fun getModelOptions(): Response<ModelOptionsResponse>
|
suspend fun getModelOptions(): Response<ModelOptionsResponse>
|
||||||
|
|
||||||
@GET("api/logs")
|
@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")
|
@GET("api/dashboard/plugins/hub")
|
||||||
suspend fun getPlugins(): Response<PluginsHubResponse>
|
suspend fun getPlugins(): Response<PluginsHubResponse>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ suspend inline fun <reified T> safeApiCall(
|
|||||||
} else {
|
} else {
|
||||||
val code = response.code()
|
val code = response.code()
|
||||||
if (code in 500..599 && attempt < retries) {
|
if (code in 500..599 && attempt < retries) {
|
||||||
|
val backoff = 500L * (1 shl attempt)
|
||||||
|
delay(backoff)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return NetworkResult.Failure(mapHttpError(code))
|
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) {
|
} catch (e: Exception) {
|
||||||
return NetworkResult.Failure(
|
return NetworkResult.Failure(
|
||||||
NetworkError.Unknown(
|
NetworkError.Unknown(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import okhttp3.Request
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
@@ -64,6 +65,7 @@ object HermesWsClient {
|
|||||||
private val requestId = AtomicInteger(0)
|
private val requestId = AtomicInteger(0)
|
||||||
private val connected = AtomicBoolean(false)
|
private val connected = AtomicBoolean(false)
|
||||||
private val intentionalClose = AtomicBoolean(false)
|
private val intentionalClose = AtomicBoolean(false)
|
||||||
|
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
@@ -170,7 +172,13 @@ object HermesWsClient {
|
|||||||
// prompts (PromptSubmit params include the `text` field) — never
|
// prompts (PromptSubmit params include the `text` field) — never
|
||||||
// stream to logcat in release builds.
|
// stream to logcat in release builds.
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
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
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,17 +251,25 @@ object HermesWsClient {
|
|||||||
|
|
||||||
private class WsListenerImpl : WebSocketListener() {
|
private class WsListenerImpl : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
webSocket: WebSocket,
|
||||||
response: Response,
|
response: Response,
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "WebSocket opened")
|
Log.i(TAG, "WebSocket opened")
|
||||||
connected.set(true)
|
connected.set(true)
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
currentBackoff = INITIAL_BACKOFF_MS
|
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(
|
override fun onMessage(
|
||||||
ws: WebSocket,
|
webSocket: WebSocket,
|
||||||
text: String,
|
text: String,
|
||||||
) {
|
) {
|
||||||
// B2 (Jun 18 2026, kanban t_8884db16): incoming WS text contains
|
// B2 (Jun 18 2026, kanban t_8884db16): incoming WS text contains
|
||||||
@@ -264,16 +280,16 @@ object HermesWsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosing(
|
override fun onClosing(
|
||||||
ws: WebSocket,
|
webSocket: WebSocket,
|
||||||
code: Int,
|
code: Int,
|
||||||
reason: String,
|
reason: String,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "WebSocket closing: $code $reason")
|
Log.d(TAG, "WebSocket closing: $code $reason")
|
||||||
ws.close(code, reason)
|
webSocket.close(code, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(
|
override fun onClosed(
|
||||||
ws: WebSocket,
|
webSocket: WebSocket,
|
||||||
code: Int,
|
code: Int,
|
||||||
reason: String,
|
reason: String,
|
||||||
) {
|
) {
|
||||||
@@ -284,7 +300,7 @@ object HermesWsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(
|
override fun onFailure(
|
||||||
ws: WebSocket,
|
webSocket: WebSocket,
|
||||||
t: Throwable,
|
t: Throwable,
|
||||||
response: Response?,
|
response: Response?,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ fun KanbanScreen(
|
|||||||
task.assignedTo?.contains(query, ignoreCase = true) == true
|
task.assignedTo?.contains(query, ignoreCase = true) == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val tasksByColumn =
|
||||||
|
remember(filteredTasks) {
|
||||||
|
filteredTasks.groupBy { it.status.lowercase() }
|
||||||
|
}
|
||||||
var showAddTaskDialog by remember { mutableStateOf(false) }
|
var showAddTaskDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -149,13 +154,13 @@ fun KanbanScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(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 colName = column.name
|
||||||
val colTasks =
|
val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList()
|
||||||
filteredTasks.filter {
|
|
||||||
it.status.equals(colName, ignoreCase = true)
|
|
||||||
}
|
|
||||||
val columnIndex = state.columns.indexOf(column)
|
|
||||||
val prevColumn = state.columns.getOrNull(columnIndex - 1)
|
val prevColumn = state.columns.getOrNull(columnIndex - 1)
|
||||||
val nextColumn = state.columns.getOrNull(columnIndex + 1)
|
val nextColumn = state.columns.getOrNull(columnIndex + 1)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class LogsViewModel : ViewModel(), ToastHost {
|
|||||||
|
|
||||||
fun loadLogs() {
|
fun loadLogs() {
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getLogs() } },
|
apiCall = { safeApiCall { ApiClient.hermesApi.getLogs(lines = 1000) } },
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
val body = data
|
val body = data
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ class E2eIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testLogsListing_success() =
|
fun testLogsListing_success() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { mockApiService.getLogs() } returns
|
coEvery { mockApiService.getLogs(lines = 1000) } returns
|
||||||
Response.success(LogResponse(listOf("Log line 1", "Log line 2")))
|
Response.success(LogResponse(listOf("Log line 1", "Log line 2")))
|
||||||
|
|
||||||
val viewModel = LogsViewModel()
|
val viewModel = LogsViewModel()
|
||||||
|
|||||||
Reference in new issue
Block a user