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