feat(logs): server-side file/level/component/line filters (#777) (#792)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 20:28:30 +03:00
1 parent 5870b73397
commit 00a0eedc77
7 files changed
+236 -103

No files matched your search

@@ -495,8 +495,10 @@ interface HermesApiService {
@GET("api/logs") @GET("api/logs")
suspend fun getLogs( suspend fun getLogs(
@Query("limit") limit: Int? = null, @Query("file") file: String = "agent",
@Query("lines") lines: Int? = null, @Query("lines") lines: Int = 100,
@Query("level") level: String = "ALL",
@Query("component") component: String = "all",
): Response<LogResponse> ): Response<LogResponse>
@GET("api/dashboard/plugins/hub") @GET("api/dashboard/plugins/hub")
@@ -25,7 +25,6 @@ import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -49,7 +48,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
@@ -60,46 +58,10 @@ import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private enum class LogSeverity { private data class LogsFilterOption(
ERROR, val value: String,
WARN, val label: String,
INFO, )
DEBUG,
}
private fun detectSeverity(line: String): LogSeverity? {
val upper = line.uppercase()
return when {
Regex("\\[ERROR\\]|\\bERROR\\b|\\|ERROR\\||^ERROR[\\s:]").containsMatchIn(upper) -> {
LogSeverity.ERROR
}
Regex("\\[WARN(?:ING)?\\]|\\bWARN\\b|\\|WARN(?:ING)?\\||^WARN(?:ING)?[\\s:]").containsMatchIn(upper) -> {
LogSeverity.WARN
}
Regex("\\[INFO\\]|\\bINFO\\b|\\|INFO\\||^INFO[\\s:]").containsMatchIn(upper) -> {
LogSeverity.INFO
}
Regex("\\[DEBUG\\]|\\bDEBUG\\b|\\|DEBUG\\||^DEBUG[\\s:]").containsMatchIn(upper) -> {
LogSeverity.DEBUG
}
else -> {
null
}
}
}
private val LogSeverity.labelRes: Int
get() =
when (this) {
LogSeverity.ERROR -> R.string.logs_filter_error
LogSeverity.WARN -> R.string.logs_filter_warn
LogSeverity.INFO -> R.string.logs_filter_info
LogSeverity.DEBUG -> R.string.logs_filter_debug
}
@Composable @Composable
fun LogsScreen( fun LogsScreen(
@@ -112,23 +74,44 @@ fun LogsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var severityFilter by remember { mutableStateOf<LogSeverity?>(null) } var filters by remember { mutableStateOf(LogsFilters()) }
var pauseScroll by remember { mutableStateOf(false) } var pauseScroll by remember { mutableStateOf(false) }
// Build severity-indexed sets for fast lookup // Server-side filter options, mirroring the desktop LogsPage toolbar
val severityMap = // (files: agent/errors/gateway, levels: ALL/DEBUG/INFO/WARNING/ERROR,
remember(state.logs) { // components: gateway/agent/tools/cli/cron, lines: 50-500).
state.logs.map { line -> detectSeverity(line) } val fileOptions =
} listOf(
LogsFilterOption("agent", stringResource(R.string.logs_filter_agent)),
LogsFilterOption("errors", stringResource(R.string.logs_filter_errors)),
LogsFilterOption("gateway", stringResource(R.string.logs_filter_gateway)),
)
val levelOptions =
listOf(
LogsFilterOption("ALL", stringResource(R.string.logs_filter_all)),
LogsFilterOption("DEBUG", stringResource(R.string.logs_filter_debug)),
LogsFilterOption("INFO", stringResource(R.string.logs_filter_info)),
LogsFilterOption("WARNING", stringResource(R.string.logs_filter_warning)),
LogsFilterOption("ERROR", stringResource(R.string.logs_filter_error)),
)
val componentOptions =
listOf(
LogsFilterOption("all", stringResource(R.string.logs_filter_all)),
LogsFilterOption("gateway", stringResource(R.string.logs_filter_gateway)),
LogsFilterOption("agent", stringResource(R.string.logs_filter_agent)),
LogsFilterOption("tools", stringResource(R.string.logs_filter_tools)),
LogsFilterOption("cli", stringResource(R.string.logs_filter_cli)),
LogsFilterOption("cron", stringResource(R.string.logs_filter_cron)),
)
val lineOptions = listOf("50", "100", "200", "500").map { LogsFilterOption(it, it) }
// Filter by query + severity // Client-side free-text filter over the server-returned lines
val filteredLogs = val filteredLogs =
remember(query, severityFilter, state.logs) { remember(query, state.logs) {
state.logs.filterIndexed { index, logLine -> if (query.isBlank()) {
val matchesQuery = logLine.contains(query, ignoreCase = true) state.logs
val matchesSeverity = } else {
severityFilter == null || severityMap.getOrNull(index) == severityFilter state.logs.filter { it.contains(query, ignoreCase = true) }
matchesQuery && matchesSeverity
} }
} }
@@ -233,11 +216,36 @@ fun LogsScreen(
) )
} }
// Severity filter chips // Server-side filter chips
item { item {
SeverityFilterRow( FilterChipRow(
selected = severityFilter, options = fileOptions,
onSelected = { severityFilter = it }, selected = filters.file,
onSelected = { viewModel.setFilters(filters.copy(file = it)) },
modifier = Modifier.padding(bottom = spacing.xs),
)
}
item {
FilterChipRow(
options = levelOptions,
selected = filters.level,
onSelected = { viewModel.setFilters(filters.copy(level = it)) },
modifier = Modifier.padding(bottom = spacing.xs),
)
}
item {
FilterChipRow(
options = componentOptions,
selected = filters.component,
onSelected = { viewModel.setFilters(filters.copy(component = it)) },
modifier = Modifier.padding(bottom = spacing.xs),
)
}
item {
FilterChipRow(
options = lineOptions,
selected = filters.lines.toString(),
onSelected = { viewModel.setFilters(filters.copy(lines = it.toInt())) },
modifier = Modifier.padding(bottom = spacing.sm), modifier = Modifier.padding(bottom = spacing.sm),
) )
} }
@@ -284,61 +292,33 @@ fun LogsScreen(
} }
@Composable @Composable
private fun SeverityFilterRow( private fun FilterChipRow(
selected: LogSeverity?, options: List<LogsFilterOption>,
onSelected: (LogSeverity?) -> Unit, selected: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val statusColors = LocalHermesStatusColors.current
val chipColors = FilterChipDefaults.filterChipColors()
Row( Row(
modifier = modifier.horizontalScroll(rememberScrollState()), modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// "All" chip options.forEach { option ->
val isSelected = selected == option.value
FilterChip( FilterChip(
selected = selected == null, selected = isSelected,
onClick = { onSelected(null) }, onClick = { onSelected(option.value) },
label = { Text(stringResource(R.string.logs_filter_all)) }, label = { Text(option.label) },
colors = chipColors,
)
LogSeverity.entries.forEach { severity ->
val color =
when (severity) {
LogSeverity.ERROR -> statusColors.error
LogSeverity.WARN -> statusColors.warning
LogSeverity.INFO -> statusColors.info
LogSeverity.DEBUG -> statusColors.success
}
FilterChip(
selected = selected == severity,
onClick = {
onSelected(if (selected == severity) null else severity)
},
label = {
Text(
text = stringResource(severity.labelRes),
color = if (selected == severity) color else MaterialTheme.colorScheme.onSurface,
)
},
leadingIcon = leadingIcon =
if (selected == severity) { if (isSelected) {
{ {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = null, contentDescription = null,
tint = color,
) )
} }
} else { } else {
null null
}, },
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor = color.copy(alpha = 0.12f),
),
) )
} }
} }
@@ -15,11 +15,23 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/**
* Server-side log filters, mirroring the desktop dashboard's LogsPage
* (`GET /api/logs?file=&lines=&level=&component=`).
*/
data class LogsFilters(
val file: String = "agent",
val level: String = "ALL",
val component: String = "all",
val lines: Int = 100,
)
data class LogsUiState( data class LogsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val logs: List<String> = emptyList(), val logs: List<String> = emptyList(),
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
val filters: LogsFilters = LogsFilters(),
) )
class LogsViewModel : class LogsViewModel :
@@ -36,8 +48,18 @@ class LogsViewModel :
fun loadLogs() { fun loadLogs() {
if (loadInProgress) return if (loadInProgress) return
loadInProgress = true loadInProgress = true
val filters = _uiState.value.filters
safeLaunchLoad( safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getLogs(lines = 1000) } }, apiCall = {
safeApiCall {
ApiClient.hermesApi.getLogs(
file = filters.file,
lines = filters.lines,
level = filters.level,
component = filters.component,
)
}
},
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
@@ -57,6 +79,12 @@ class LogsViewModel :
) )
} }
/** Update the server-side filters and immediately reload. */
fun setFilters(filters: LogsFilters) {
_uiState.update { it.copy(filters = filters) }
loadLogs()
}
/** Start auto-refreshing logs every 5 seconds. */ /** Start auto-refreshing logs every 5 seconds. */
fun startAutoRefresh() { fun startAutoRefresh() {
if (autoRefreshJob?.isActive == true) return if (autoRefreshJob?.isActive == true) return
+7 -1
View File
@@ -250,10 +250,16 @@
<string name="logs_action_export">공유</string> <string name="logs_action_export">공유</string>
<string name="logs_content_desc_jump_bottom">맨 아래로</string> <string name="logs_content_desc_jump_bottom">맨 아래로</string>
<string name="logs_filter_all">전체</string> <string name="logs_filter_all">전체</string>
<string name="logs_filter_agent">에이전트</string>
<string name="logs_filter_errors">오류</string>
<string name="logs_filter_gateway">게이트웨이</string>
<string name="logs_filter_warning">경고</string>
<string name="logs_filter_error">에러</string> <string name="logs_filter_error">에러</string>
<string name="logs_filter_warn">경고</string>
<string name="logs_filter_info">정보</string> <string name="logs_filter_info">정보</string>
<string name="logs_filter_debug">디버그</string> <string name="logs_filter_debug">디버그</string>
<string name="logs_filter_tools">도구</string>
<string name="logs_filter_cli">CLI</string>
<string name="logs_filter_cron">크론</string>
<!-- System Screen --> <!-- System Screen -->
<string name="system_empty_title">시스템 데이터 없음</string> <string name="system_empty_title">시스템 데이터 없음</string>
+7 -1
View File
@@ -299,10 +299,16 @@
<string name="logs_action_export">Share</string> <string name="logs_action_export">Share</string>
<string name="logs_content_desc_jump_bottom">Scroll to bottom</string> <string name="logs_content_desc_jump_bottom">Scroll to bottom</string>
<string name="logs_filter_all">All</string> <string name="logs_filter_all">All</string>
<string name="logs_filter_agent">Agent</string>
<string name="logs_filter_errors">Errors</string>
<string name="logs_filter_gateway">Gateway</string>
<string name="logs_filter_warning">Warning</string>
<string name="logs_filter_error">Error</string> <string name="logs_filter_error">Error</string>
<string name="logs_filter_warn">Warn</string>
<string name="logs_filter_info">Info</string> <string name="logs_filter_info">Info</string>
<string name="logs_filter_debug">Debug</string> <string name="logs_filter_debug">Debug</string>
<string name="logs_filter_tools">Tools</string>
<string name="logs_filter_cli">CLI</string>
<string name="logs_filter_cron">Cron</string>
<!-- System Screen --> <!-- System Screen -->
<string name="system_empty_title">No system data</string> <string name="system_empty_title">No system data</string>
@@ -1050,7 +1050,7 @@ class E2eIntegrationTest {
@Test @Test
fun testLogsListing_success() = fun testLogsListing_success() =
runTest { runTest {
coEvery { mockApiService.getLogs(lines = 1000) } returns coEvery { mockApiService.getLogs(file = "agent", lines = 100, level = "ALL", component = "all") } 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()
@@ -0,0 +1,111 @@
package com.m57.hermescontrol.ui.logs
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class LogsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle()
}
private fun stubLogs(lines: List<String> = listOf("INFO line one", "ERROR line two")) {
coEvery { mockApi.getLogs(any(), any(), any(), any()) } returns
Response.success(LogResponse(lines = lines))
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `loadLogs requests default desktop-mirror filters`() {
stubLogs()
val vm = LogsViewModel()
vm.loadLogs()
settle()
coVerify {
mockApi.getLogs(
file = "agent",
lines = 100,
level = "ALL",
component = "all",
)
}
assertEquals(listOf("INFO line one", "ERROR line two"), vm.uiState.value.logs)
assertEquals(LogsFilters(), vm.uiState.value.filters)
}
@Test
fun `setFilters reloads with the new server-side filters`() {
stubLogs()
val vm = LogsViewModel()
vm.loadLogs()
settle()
val newFilters = LogsFilters(file = "errors", level = "ERROR", component = "gateway", lines = 50)
vm.setFilters(newFilters)
settle()
coVerify {
mockApi.getLogs(
file = "errors",
lines = 50,
level = "ERROR",
component = "gateway",
)
}
assertEquals(newFilters, vm.uiState.value.filters)
assertEquals(listOf("INFO line one", "ERROR line two"), vm.uiState.value.logs)
}
@Test
fun `loadLogs falls back to legacy logs field when lines is absent`() {
coEvery { mockApi.getLogs(any(), any(), any(), any()) } returns
Response.success(LogResponse(logs = listOf("legacy line")))
val vm = LogsViewModel()
vm.loadLogs()
settle()
assertEquals(listOf("legacy line"), vm.uiState.value.logs)
}
}