diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 8e4e01a0..8ff67dbf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 @GET("api/dashboard/plugins/hub") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index 01d1d680..32ca974f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -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(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, + 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), - ), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index c1f85356..e50663d6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -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 = 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 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bbd07f62..1f2839a1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -250,10 +250,16 @@ 공유 맨 아래로 전체 + 에이전트 + 오류 + 게이트웨이 + 경고 에러 - 경고 정보 디버그 + 도구 + CLI + 크론 시스템 데이터 없음 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08a81ee3..03a691b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -299,10 +299,16 @@ Share Scroll to bottom All + Agent + Errors + Gateway + Warning Error - Warn Info Debug + Tools + CLI + Cron No system data diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index e0ddd831..5f6b50db 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -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() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt new file mode 100644 index 00000000..fcf60d5c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt @@ -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(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 = 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) + } +}