@@ -59,7 +59,9 @@ data class SavedFiltersState(
59data class LogViewerState(
59data class LogViewerState(
60 val streamName: String = "",
60 val streamName: String = "",
61 val logs: List<JsonObject> = emptyList(),
61 val logs: List<JsonObject> = emptyList(),
62+ val logKeys: List<String> = emptyList(),
62 val columns: List<String> = emptyList(),
63 val columns: List<String> = emptyList(),
64+ val searchableColumns: List<String> = emptyList(),
63 val isLoading: Boolean = false,
65 val isLoading: Boolean = false,
64 val error: String? = null,
66 val error: String? = null,
65 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
67 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -108,6 +110,23 @@ class LogViewerViewModel @Inject constructor(
108 private const val LOAD_MORE_INCREMENT = 500
110 private const val LOAD_MORE_INCREMENT = 500
109 private const val MAX_STREAMING_ERRORS = 5
111 private const val MAX_STREAMING_ERRORS = 5
110 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
112 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
115+ * Produces deterministic keys for a list of log entries so that expanded state
116+ * survives list mutations (e.g., new logs prepended during streaming).
118+ internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
119+ val seen = mutableMapOf<String, Int>()
120+ return logs.map { log ->
121+ val ts = log["p_timestamp"]?.toString()
122+ val meta = log["p_metadata"]?.toString()
123+ val tag = log["p_tags"]?.toString()
124+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
125+ val count = seen.getOrDefault(base, 0)
126+ seen[base] = count + 1
127+ if (count == 0) base else "$base|$count"
113 fun initialize(streamName: String) {
132 fun initialize(streamName: String) {
@@ -124,7 +143,8 @@ class LogViewerViewModel @Inject constructor(
124 when (val result = repository.getStreamSchema(streamName)) {
143 when (val result = repository.getStreamSchema(streamName)) {
125 is ApiResult.Success -> {
144 is ApiResult.Success -> {
126 val columns = result.data.fields.map { it.name }.sorted()
145 val columns = result.data.fields.map { it.name }.sorted()
146+ val searchable = columns.filter { !it.startsWith("p_") }
127− _state.update { it.copy(columns = columns) }
147+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
129 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
149 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
@@ -236,9 +256,11 @@ class LogViewerViewModel @Inject constructor(
236 val (startTime, endTime) = getTimeRange()
256 val (startTime, endTime) = getTimeRange()
237 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
257 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
238 is ApiResult.Success -> {
258 is ApiResult.Success -> {
259+ val keys = computeLogKeys(result.data)
239 _state.update {
260 _state.update {
241 logs = result.data,
262 logs = result.data,
242 isLoading = false,
264 isLoading = false,
243 hasMore = result.data.size >= it.currentLimit,
265 hasMore = result.data.size >= it.currentLimit,
@@ -253,13 +275,11 @@ class LogViewerViewModel @Inject constructor(
256− private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
278+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
257 if (searchQuery.isBlank()) return null
279 if (searchQuery.isBlank()) return null
280+ if (searchableColumns.isEmpty()) return null
258 val safeSearch = escapeSql(searchQuery)
281 val safeSearch = escapeSql(searchQuery)
259− // Search across ALL non-internal columns (not just 5)
260− val searchFields = columns.filter { !it.startsWith("p_") }
261− if (searchFields.isEmpty()) return null
262− return searchFields.joinToString(" OR ") {
282+ return searchableColumns.joinToString(" OR ") {
263 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
283 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
@@ -283,7 +303,7 @@ class LogViewerViewModel @Inject constructor(
283 // Build WHERE clause from filters + search
303 // Build WHERE clause from filters + search
284 val clauses = current.filters.filterClauses.toMutableList()
304 val clauses = current.filters.filterClauses.toMutableList()
285 if (current.filters.searchQuery.isNotBlank()) {
305 if (current.filters.searchQuery.isNotBlank()) {
286− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
306+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
287 if (searchClause != null) {
307 if (searchClause != null) {
288 clauses.add("($searchClause)")
308 clauses.add("($searchClause)")
@@ -304,9 +324,11 @@ class LogViewerViewModel @Inject constructor(
304 limit = current.currentLimit,
324 limit = current.currentLimit,
306 is ApiResult.Success -> {
326 is ApiResult.Success -> {
327+ val keys = computeLogKeys(result.data)
307 _state.update {
328 _state.update {
309 logs = result.data,
330 logs = result.data,
310 isLoading = false,
332 isLoading = false,
311 hasMore = result.data.size >= it.currentLimit,
333 hasMore = result.data.size >= it.currentLimit,
@@ -415,7 +437,7 @@ class LogViewerViewModel @Inject constructor(
415 // Build WHERE clause from active filters + search
437 // Build WHERE clause from active filters + search
416 val clauses = current.filters.filterClauses.toMutableList()
438 val clauses = current.filters.filterClauses.toMutableList()
417 if (current.filters.searchQuery.isNotBlank()) {
439 if (current.filters.searchQuery.isNotBlank()) {
418− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
440+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
419 if (searchClause != null) {
441 if (searchClause != null) {
420 clauses.add(searchClause)
442 clauses.add(searchClause)
@@ -441,15 +463,16 @@ class LogViewerViewModel @Inject constructor(
443 _state.update { state ->
465 _state.update { state ->
444− // Prepend new logs, cap total
466+ // Prepend new logs, cap total — avoid full intermediate list allocation
445− val combined = newLogs + state.logs
467+ val remaining = (STREAMING_MAX_LOGS - newLogs.size).coerceAtLeast(0)
446− val capped = if (combined.size > STREAMING_MAX_LOGS) {
468+ val capped = ArrayList<JsonObject>(newLogs.size + remaining).apply {
447− combined.take(STREAMING_MAX_LOGS)
469+ addAll(newLogs)
448− } else {
470+ val oldLogs = state.logs
449− combined
471+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
451 state.copy(
473 state.copy(
452 logs = capped,
474 logs = capped,
475+ logKeys = computeLogKeys(capped),
453 streaming = state.streaming.copy(
476 streaming = state.streaming.copy(
454 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
477 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455 streamingError = null,
478 streamingError = null,