Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
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 ->
|
||||||
FilterChip(
|
val isSelected = selected == option.value
|
||||||
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
|
|
||||||
}
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected == severity,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = { onSelected(option.value) },
|
||||||
onSelected(if (selected == severity) null else severity)
|
label = { Text(option.label) },
|
||||||
},
|
|
||||||
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user