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")
suspend fun getLogs(
@Query("limit") limit: Int? = null,
@Query("lines") lines: Int? = null,
@Query("file") file: String = "agent",
@Query("lines") lines: Int = 100,
@Query("level") level: String = "ALL",
@Query("component") component: String = "all",
): Response<LogResponse>
@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.Share
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -49,7 +48,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
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 kotlinx.coroutines.launch
private enum class LogSeverity {
ERROR,
WARN,
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
}
private data class LogsFilterOption(
val value: String,
val label: String,
)
@Composable
fun LogsScreen(
@@ -112,23 +74,44 @@ fun LogsScreen(
val listState = rememberLazyListState()
var query by remember { mutableStateOf("") }
var severityFilter by remember { mutableStateOf<LogSeverity?>(null) }
var filters by remember { mutableStateOf(LogsFilters()) }
var pauseScroll by remember { mutableStateOf(false) }
// Build severity-indexed sets for fast lookup
val severityMap =
remember(state.logs) {
state.logs.map { line -> detectSeverity(line) }
}
// Server-side filter options, mirroring the desktop LogsPage toolbar
// (files: agent/errors/gateway, levels: ALL/DEBUG/INFO/WARNING/ERROR,
// components: gateway/agent/tools/cli/cron, lines: 50-500).
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 =
remember(query, severityFilter, state.logs) {
state.logs.filterIndexed { index, logLine ->
val matchesQuery = logLine.contains(query, ignoreCase = true)
val matchesSeverity =
severityFilter == null || severityMap.getOrNull(index) == severityFilter
matchesQuery && matchesSeverity
remember(query, state.logs) {
if (query.isBlank()) {
state.logs
} else {
state.logs.filter { it.contains(query, ignoreCase = true) }
}
}
@@ -233,11 +216,36 @@ fun LogsScreen(
)
}
// Severity filter chips
// Server-side filter chips
item {
SeverityFilterRow(
selected = severityFilter,
onSelected = { severityFilter = it },
FilterChipRow(
options = fileOptions,
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),
)
}
@@ -284,61 +292,33 @@ fun LogsScreen(
}
@Composable
private fun SeverityFilterRow(
selected: LogSeverity?,
onSelected: (LogSeverity?) -> Unit,
private fun FilterChipRow(
options: List<LogsFilterOption>,
selected: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val chipColors = FilterChipDefaults.filterChipColors()
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// "All" chip
FilterChip(
selected = selected == null,
onClick = { onSelected(null) },
label = { Text(stringResource(R.string.logs_filter_all)) },
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
}
options.forEach { option ->
val isSelected = selected == option.value
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,
)
},
selected = isSelected,
onClick = { onSelected(option.value) },
label = { Text(option.label) },
leadingIcon =
if (selected == severity) {
if (isSelected) {
{
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = color,
)
}
} else {
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.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(
val isLoading: Boolean = false,
val logs: List<String> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
val filters: LogsFilters = LogsFilters(),
)
class LogsViewModel :
@@ -36,8 +48,18 @@ class LogsViewModel :
fun loadLogs() {
if (loadInProgress) return
loadInProgress = true
val filters = _uiState.value.filters
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) } },
onSuccess = { 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. */
fun startAutoRefresh() {
if (autoRefreshJob?.isActive == true) return
+7 -1
View File
@@ -250,10 +250,16 @@
<string name="logs_action_export">공유</string>
<string name="logs_content_desc_jump_bottom">맨 아래로</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_warn">경고</string>
<string name="logs_filter_info">정보</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 -->
<string name="system_empty_title">시스템 데이터 없음</string>
+7 -1
View File
@@ -299,10 +299,16 @@
<string name="logs_action_export">Share</string>
<string name="logs_content_desc_jump_bottom">Scroll to bottom</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_warn">Warn</string>
<string name="logs_filter_info">Info</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 -->
<string name="system_empty_title">No system data</string>
@@ -1050,7 +1050,7 @@ class E2eIntegrationTest {
@Test
fun testLogsListing_success() =
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")))
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)
}
}