1package com.parseable.android.ui.screens.logviewer
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import com.parseable.android.data.escapeIdentifier
6import com.parseable.android.data.escapeSql
7import com.parseable.android.data.model.ApiResult
8import com.parseable.android.data.repository.ParseableRepository
9import dagger.hilt.android.lifecycle.HiltViewModel
10import kotlinx.coroutines.Job
11import kotlinx.coroutines.delay
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.isActive
17import kotlinx.coroutines.launch
18import kotlinx.serialization.json.JsonObject
19import kotlinx.serialization.json.jsonPrimitive
20import java.time.ZoneOffset
21import java.time.ZonedDateTime
22import java.time.format.DateTimeFormatter
23import javax.inject.Inject
25enum class TimeRange(val label: String, val minutes: Long) {
31 LAST_24H("24h", 1440),
33 LAST_30D("30d", 43200),
37data class FilterState(
38 val searchQuery: String = "",
39 val activeFilters: List<String> = emptyList(),
40 val filterClauses: List<String> = emptyList(),
41 val customSql: String = "",
42 val isSearching: Boolean = false,
45data class StreamingState(
46 val isStreaming: Boolean = false,
47 val streamingNewCount: Int = 0,
48 val streamingError: String? = null,
49 val currentIntervalMs: Long = 3000L,
52data class LogViewerState(
53 val streamName: String = "",
54 val logs: List<JsonObject> = emptyList(),
55 val columns: List<String> = emptyList(),
56 val isLoading: Boolean = false,
57 val error: String? = null,
58 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
59 val filters: FilterState = FilterState(),
60 val streaming: StreamingState = StreamingState(),
61 val currentLimit: Int = 500,
62 val hasMore: Boolean = false,
63 val customStartTime: Long? = null,
64 val customEndTime: Long? = null,
66 // Convenience accessors for backward compatibility with Screen
67 val searchQuery: String get() = filters.searchQuery
68 val activeFilters: List<String> get() = filters.activeFilters
69 val filterClauses: List<String> get() = filters.filterClauses
70 val customSql: String get() = filters.customSql
71 val isStreaming: Boolean get() = streaming.isStreaming
72 val streamingNewCount: Int get() = streaming.streamingNewCount
73 val streamingError: String? get() = streaming.streamingError
74 val currentIntervalMs: Long get() = streaming.currentIntervalMs
75 val isSearching: Boolean get() = filters.isSearching
79class LogViewerViewModel @Inject constructor(
80 private val repository: ParseableRepository,
83 private val _state = MutableStateFlow(LogViewerState())
84 val state: StateFlow<LogViewerState> = _state.asStateFlow()
86 private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'+00:00'")
88 private var streamingJob: Job? = null
89 @Volatile private var streamingGeneration: Int = 0
90 @Volatile private var lastSeenTimestamp: String? = null
91 @Volatile private var consecutiveStreamingErrors: Int = 0
92 private var searchJob: Job? = null
93 private var schemaJob: Job? = null
96 private const val STREAMING_BASE_INTERVAL_MS = 3000L
97 private const val STREAMING_MAX_INTERVAL_MS = 30000L
98 private const val STREAMING_MAX_LOGS = 1000
99 private const val MAX_LOAD_LIMIT = 5000
100 private const val LOAD_MORE_INCREMENT = 500
101 private const val MAX_STREAMING_ERRORS = 5
102 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
105 fun initialize(streamName: String) {
106 if (_state.value.streamName == streamName) return
108 _state.update { it.copy(streamName = streamName) }
109 loadSchema(streamName)
113 private fun loadSchema(streamName: String) {
115 schemaJob = viewModelScope.launch {
116 when (val result = repository.getStreamSchema(streamName)) {
117 is ApiResult.Success -> {
118 val columns = result.data.fields.map { it.name }
119 _state.update { it.copy(columns = columns) }
121 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
126 fun onTimeRangeChange(range: TimeRange) {
130 selectedTimeRange = range,
132 customStartTime = if (range != TimeRange.CUSTOM) null else it.customStartTime,
133 customEndTime = if (range != TimeRange.CUSTOM) null else it.customEndTime,
136 if (range != TimeRange.CUSTOM) refresh()
139 fun setCustomTimeRange(startMillis: Long, endMillis: Long) {
143 selectedTimeRange = TimeRange.CUSTOM,
144 customStartTime = startMillis,
145 customEndTime = endMillis,
152 fun onSearchQueryChange(query: String) {
153 _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
155 searchJob = viewModelScope.launch {
157 _state.update { it.copy(filters = it.filters.copy(isSearching = false)) }
162 fun addFilter(column: String, operator: String, value: String) {
163 if (operator !in ALLOWED_OPERATORS) return
164 val safeColumn = escapeIdentifier(column)
165 val safeValue = escapeSql(value)
166 val clause = when (operator) {
167 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
168 "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
169 else -> "\"$safeColumn\" $operator '$safeValue'"
171 val display = when (operator) {
172 "IS NULL", "IS NOT NULL" -> "$column $operator"
173 else -> "$column $operator $value"
177 filters = it.filters.copy(
178 filterClauses = it.filters.filterClauses + clause,
179 activeFilters = it.filters.activeFilters + display,
186 fun removeFilter(display: String) {
189 val index = it.filters.activeFilters.indexOf(display)
190 if (index >= 0 && index < it.filters.filterClauses.size) {
193 filters = it.filters.copy(
194 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
195 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
202 if (removed) refresh()
207 it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
212 fun executeCustomSql(sql: String) {
213 val trimmed = sql.trim()
214 if (!trimmed.uppercase().startsWith("SELECT")) {
215 _state.update { it.copy(error = "Only SELECT queries are allowed") }
218 // Enforce a LIMIT to prevent OOM from unbounded queries
219 val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
220 "$trimmed LIMIT $MAX_LOAD_LIMIT"
224 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
225 viewModelScope.launch {
226 _state.update { it.copy(isLoading = true, error = null) }
228 val (startTime, endTime) = getTimeRange()
229 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
230 is ApiResult.Success -> {
235 hasMore = result.data.size >= it.currentLimit,
239 is ApiResult.Error -> {
241 it.copy(isLoading = false, error = result.userMessage)
248 private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
249 if (searchQuery.isBlank()) return null
250 val safeSearch = escapeSql(searchQuery)
251 // Search across ALL non-internal columns (not just 5)
252 val searchFields = columns.filter { !it.startsWith("p_") }
253 if (searchFields.isEmpty()) return null
254 return searchFields.joinToString(" OR ") {
255 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
260 if (_state.value.streamName.isEmpty()) return
262 viewModelScope.launch {
267 streaming = it.streaming.copy(streamingError = null),
271 // Snapshot state inside the coroutine so we always see the latest filters/columns
272 val current = _state.value
273 val (startTime, endTime) = getTimeRange()
275 // Build WHERE clause from filters + search
276 val clauses = current.filters.filterClauses.toMutableList()
277 if (current.filters.searchQuery.isNotBlank()) {
278 val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
279 if (searchClause != null) {
280 clauses.add("($searchClause)")
283 it.copy(isLoading = false, error = "Search unavailable: stream schema not loaded")
289 val filterSql = clauses.joinToString(" AND ")
291 when (val result = repository.queryLogs(
292 stream = current.streamName,
293 startTime = startTime,
295 filterSql = filterSql,
296 limit = current.currentLimit,
298 is ApiResult.Success -> {
303 hasMore = result.data.size >= it.currentLimit,
307 is ApiResult.Error -> {
309 it.copy(isLoading = false, error = result.userMessage)
317 val current = _state.value
318 if (current.currentLimit >= MAX_LOAD_LIMIT) {
319 _state.update { it.copy(hasMore = false) }
322 val newLimit = (current.currentLimit + LOAD_MORE_INCREMENT).coerceAtMost(MAX_LOAD_LIMIT)
323 _state.update { it.copy(currentLimit = newLimit) }
327 private fun getTimeRange(): Pair<String, String> {
328 val current = _state.value
329 if (current.selectedTimeRange == TimeRange.CUSTOM &&
330 current.customStartTime != null && current.customEndTime != null
332 val start = ZonedDateTime.ofInstant(
333 java.time.Instant.ofEpochMilli(current.customStartTime), ZoneOffset.UTC
335 val end = ZonedDateTime.ofInstant(
336 java.time.Instant.ofEpochMilli(current.customEndTime), ZoneOffset.UTC
338 return Pair(start.format(dateFormatter), end.format(dateFormatter))
340 val now = ZonedDateTime.now(ZoneOffset.UTC)
341 val start = now.minusMinutes(current.selectedTimeRange.minutes)
343 start.format(dateFormatter),
344 now.format(dateFormatter),
348 fun toggleStreaming() {
349 if (_state.value.streaming.isStreaming) {
356 private fun startStreaming() {
358 consecutiveStreamingErrors = 0
359 val generation = ++streamingGeneration
361 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
364 // Set the baseline timestamp to "now" so we only poll for new logs
365 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
367 streamingJob = viewModelScope.launch {
368 while (isActive && streamingGeneration == generation) {
370 // Exponential backoff on errors, normal interval on success
371 val intervalMs = if (consecutiveStreamingErrors > 0) {
372 (STREAMING_BASE_INTERVAL_MS * (1L shl consecutiveStreamingErrors.coerceAtMost(4)))
373 .coerceAtMost(STREAMING_MAX_INTERVAL_MS)
375 STREAMING_BASE_INTERVAL_MS
378 it.copy(streaming = it.streaming.copy(currentIntervalMs = intervalMs))
385 fun stopStreaming() {
386 streamingGeneration++
387 streamingJob?.cancel()
390 it.copy(streaming = it.streaming.copy(isStreaming = false))
394 fun dismissStreamingError() {
396 it.copy(streaming = it.streaming.copy(streamingError = null))
400 private suspend fun pollNewLogs() {
401 val current = _state.value
402 if (current.streamName.isEmpty()) return
404 val startTime = lastSeenTimestamp ?: return
405 val endTime = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
407 // Build WHERE clause from active filters + search
408 val clauses = current.filters.filterClauses.toMutableList()
409 if (current.filters.searchQuery.isNotBlank()) {
410 val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
411 if (searchClause != null) {
412 clauses.add(searchClause)
416 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
417 val safeName = escapeIdentifier(current.streamName)
418 val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
420 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
421 is ApiResult.Success -> {
422 val newLogs = result.data
423 if (newLogs.isNotEmpty()) {
424 // Update the last seen timestamp to the most recent log
425 val newestTimestamp = try {
426 newLogs.firstOrNull()
427 ?.get("p_timestamp")?.jsonPrimitive?.content
428 } catch (_: Exception) {
431 if (newestTimestamp != null) {
432 lastSeenTimestamp = newestTimestamp
435 _state.update { state ->
436 // Prepend new logs, cap total
437 val combined = newLogs + state.logs
438 val capped = if (combined.size > STREAMING_MAX_LOGS) {
439 combined.take(STREAMING_MAX_LOGS)
445 streaming = state.streaming.copy(
446 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
447 streamingError = null,
452 consecutiveStreamingErrors = 0
454 is ApiResult.Error -> {
455 consecutiveStreamingErrors++
456 if (consecutiveStreamingErrors >= MAX_STREAMING_ERRORS) {
459 it.copy(error = "Streaming stopped after repeated errors: ${result.userMessage}")
461 } else if (consecutiveStreamingErrors >= 3) {
464 streaming = it.streaming.copy(
465 streamingError = "Streaming interrupted: ${result.userMessage}. Retrying..."
474 override fun onCleared() {
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list