git.iko.soy/parseable-android
on 0da331f help sign in
1001 lines 44 kB history raw
1package com.parseable.android.ui.screens.logviewer
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.animateContentSize
5import androidx.compose.animation.core.*
6import androidx.compose.foundation.horizontalScroll
7import androidx.compose.foundation.layout.*
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.itemsIndexed
10import androidx.compose.foundation.lazy.rememberLazyListState
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.filled.*
17import androidx.compose.material3.*
18import androidx.compose.material3.MenuAnchorType
19import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20import androidx.compose.runtime.*
21import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.draw.alpha
24import androidx.compose.ui.platform.LocalContext
25import androidx.compose.ui.platform.LocalSoftwareKeyboardController
26import androidx.compose.ui.text.font.FontFamily
27import androidx.compose.ui.text.font.FontWeight
28import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.text.style.TextOverflow
30import androidx.compose.ui.unit.dp
31import androidx.compose.ui.unit.sp
32import android.content.Intent
33import androidx.core.content.FileProvider
34import com.parseable.android.data.escapeIdentifier
35import com.parseable.android.data.formatTimestamp
36import androidx.hilt.navigation.compose.hiltViewModel
37import androidx.lifecycle.compose.collectAsStateWithLifecycle
38import kotlinx.coroutines.launch
39import kotlinx.coroutines.withContext
40import kotlinx.serialization.json.*
42private val prettyJson = Json { prettyPrint = true }
44@OptIn(ExperimentalMaterial3Api::class)
45@Composable
46fun LogViewerScreen(
47 streamName: String,
48 onBack: () -> Unit,
49 onStreamInfo: (String) -> Unit,
50 viewModel: LogViewerViewModel = hiltViewModel(),
51) {
52 val state by viewModel.state.collectAsStateWithLifecycle()
53 val context = LocalContext.current
54 val scope = rememberCoroutineScope()
55 val snackbarHostState = remember { SnackbarHostState() }
56 val keyboardController = LocalSoftwareKeyboardController.current
57 var showFilterSheet by remember { mutableStateOf(false) }
58 var showSqlSheet by remember { mutableStateOf(false) }
59 var expandedLogKey by remember { mutableStateOf<String?>(null) }
60 var showDateRangePicker by remember { mutableStateOf(false) }
61 var showShareWarning by remember { mutableStateOf(false) }
63 LaunchedEffect(streamName) {
64 viewModel.initialize(streamName)
65 }
67 Scaffold(
68 snackbarHost = { SnackbarHost(snackbarHostState) },
69 topBar = {
70 TopAppBar(
71 title = {
72 Column {
73 Text(
74 text = streamName,
75 fontWeight = FontWeight.Bold,
76 maxLines = 1,
77 overflow = TextOverflow.Ellipsis,
78 )
79 Text(
80 text = if (state.isStreaming) {
81 "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
82 } else {
83 "${state.logs.size} results"
84 },
85 style = MaterialTheme.typography.bodySmall,
86 color = if (state.isStreaming) {
87 MaterialTheme.colorScheme.primary
88 } else {
89 MaterialTheme.colorScheme.onSurfaceVariant
90 },
91 )
92 }
93 },
94 navigationIcon = {
95 IconButton(onClick = onBack) {
96 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
97 }
98 },
99 actions = {
100 StreamingToggleButton(
101 isStreaming = state.isStreaming,
102 onClick = viewModel::toggleStreaming,
103 )
104 IconButton(onClick = { onStreamInfo(streamName) }) {
105 Icon(Icons.Filled.Info, contentDescription = "Stream Info")
106 }
107 IconButton(onClick = { showFilterSheet = true }) {
108 Icon(Icons.Filled.FilterList, contentDescription = "Filters")
109 }
110 IconButton(onClick = { showSqlSheet = true }) {
111 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
112 }
113 IconButton(
114 onClick = { showShareWarning = true },
115 enabled = state.logs.isNotEmpty(),
116 ) {
117 Icon(Icons.Filled.Share, contentDescription = "Share logs")
118 }
119 },
120 )
121 },
122 ) { padding ->
123 Column(
124 modifier = Modifier
125 .fillMaxSize()
126 .padding(padding),
127 ) {
128 // Time range chips
129 TimeRangeBar(
130 selectedRange = state.selectedTimeRange,
131 onRangeSelected = { range ->
132 if (range == TimeRange.CUSTOM) {
133 showDateRangePicker = true
134 } else {
135 viewModel.onTimeRangeChange(range)
136 }
137 },
138 )
140 // Quick search bar
141 OutlinedTextField(
142 value = state.searchQuery,
143 onValueChange = viewModel::onSearchQueryChange,
144 placeholder = { Text("Search logs...") },
145 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
146 trailingIcon = {
147 if (state.isSearching) {
148 CircularProgressIndicator(
149 modifier = Modifier.size(20.dp),
150 strokeWidth = 2.dp,
151 )
152 } else if (state.searchQuery.isNotEmpty()) {
153 IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
154 Icon(Icons.Filled.Clear, contentDescription = "Clear")
155 }
156 }
157 },
158 singleLine = true,
159 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
160 keyboardActions = KeyboardActions(onSearch = {
161 keyboardController?.hide()
162 viewModel.refresh()
163 }),
164 modifier = Modifier
165 .fillMaxWidth()
166 .padding(horizontal = 16.dp, vertical = 4.dp),
167 textStyle = MaterialTheme.typography.bodyMedium,
168 )
170 // Active filters indicator
171 if (state.activeFilters.isNotEmpty()) {
172 Row(
173 modifier = Modifier
174 .horizontalScroll(rememberScrollState())
175 .padding(horizontal = 16.dp, vertical = 4.dp)
176 .animateContentSize(),
177 horizontalArrangement = Arrangement.spacedBy(8.dp),
178 ) {
179 state.activeFilters.forEach { filter ->
180 FilterChip(
181 selected = true,
182 onClick = { viewModel.removeFilter(filter) },
183 label = { Text(filter, maxLines = 1) },
184 trailingIcon = {
185 Icon(
186 Icons.Filled.Close,
187 contentDescription = "Remove filter",
188 modifier = Modifier.size(16.dp),
189 )
190 },
191 )
192 }
193 TextButton(onClick = viewModel::clearFilters) {
194 Text("Clear all")
195 }
196 }
197 }
199 // Streaming indicator banner
200 AnimatedVisibility(visible = state.isStreaming) {
201 Surface(
202 color = MaterialTheme.colorScheme.primaryContainer,
203 modifier = Modifier.fillMaxWidth(),
204 ) {
205 Row(
206 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
207 verticalAlignment = Alignment.CenterVertically,
208 ) {
209 val infiniteTransition = rememberInfiniteTransition(label = "pulse")
210 val alpha by infiniteTransition.animateFloat(
211 initialValue = 1f,
212 targetValue = 0.3f,
213 animationSpec = infiniteRepeatable(
214 animation = tween(800, easing = EaseInOut),
215 repeatMode = RepeatMode.Reverse,
216 ),
217 label = "pulse_alpha",
218 )
219 Icon(
220 Icons.Filled.FiberManualRecord,
221 contentDescription = "Live streaming active",
222 tint = MaterialTheme.colorScheme.error,
223 modifier = Modifier
224 .size(12.dp)
225 .alpha(alpha),
226 )
227 Spacer(modifier = Modifier.width(8.dp))
228 Text(
229 text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
230 style = MaterialTheme.typography.labelMedium,
231 color = MaterialTheme.colorScheme.onPrimaryContainer,
232 modifier = Modifier.weight(1f),
233 )
234 TextButton(onClick = viewModel::stopStreaming) {
235 Text("Stop")
236 }
237 }
238 }
239 }
241 // Streaming error banner
242 AnimatedVisibility(visible = state.streamingError != null) {
243 Surface(
244 color = MaterialTheme.colorScheme.errorContainer,
245 modifier = Modifier.fillMaxWidth(),
246 ) {
247 Row(
248 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
249 verticalAlignment = Alignment.CenterVertically,
250 ) {
251 Icon(
252 Icons.Filled.ErrorOutline,
253 contentDescription = "Streaming error",
254 tint = MaterialTheme.colorScheme.onErrorContainer,
255 modifier = Modifier.size(16.dp),
256 )
257 Spacer(modifier = Modifier.width(8.dp))
258 Text(
259 text = state.streamingError ?: "",
260 style = MaterialTheme.typography.labelMedium,
261 color = MaterialTheme.colorScheme.onErrorContainer,
262 modifier = Modifier.weight(1f),
263 maxLines = 1,
264 overflow = TextOverflow.Ellipsis,
265 )
266 TextButton(onClick = viewModel::dismissStreamingError) {
267 Text("Dismiss")
268 }
269 }
270 }
271 }
273 // Log entries
274 PullToRefreshBox(
275 isRefreshing = state.isLoading,
276 onRefresh = viewModel::refresh,
277 modifier = Modifier.fillMaxSize(),
278 ) {
279 if (state.error != null && state.logs.isEmpty()) {
280 Box(
281 modifier = Modifier.fillMaxSize(),
282 contentAlignment = Alignment.Center,
283 ) {
284 Column(horizontalAlignment = Alignment.CenterHorizontally) {
285 Icon(
286 Icons.Filled.ErrorOutline,
287 contentDescription = "Error loading logs",
288 modifier = Modifier.size(48.dp),
289 tint = MaterialTheme.colorScheme.error,
290 )
291 Spacer(modifier = Modifier.height(16.dp))
292 Text(
293 text = state.error.orEmpty(),
294 style = MaterialTheme.typography.bodyMedium,
295 color = MaterialTheme.colorScheme.error,
296 modifier = Modifier.padding(horizontal = 32.dp),
297 )
298 Spacer(modifier = Modifier.height(16.dp))
299 OutlinedButton(onClick = viewModel::refresh) {
300 Text("Retry")
301 }
302 }
303 }
304 } else if (state.logs.isEmpty() && state.isLoading) {
305 Box(
306 modifier = Modifier.fillMaxSize(),
307 contentAlignment = Alignment.Center,
308 ) {
309 CircularProgressIndicator()
310 }
311 } else if (state.logs.isEmpty() && !state.isLoading) {
312 val emptyMessage = when {
313 state.isStreaming ->
314 "Waiting for new logs\u2026"
315 state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
316 "No logs match \"${state.searchQuery}\" with the active filters"
317 state.searchQuery.isNotBlank() ->
318 "No logs match \"${state.searchQuery}\""
319 state.activeFilters.isNotEmpty() ->
320 "No logs match the active filters"
321 else ->
322 "No logs found for the selected time range"
323 }
324 Box(
325 modifier = Modifier.fillMaxSize(),
326 contentAlignment = Alignment.Center,
327 ) {
328 Column(horizontalAlignment = Alignment.CenterHorizontally) {
329 Icon(
330 Icons.Filled.SearchOff,
331 contentDescription = "No logs found",
332 modifier = Modifier.size(48.dp),
333 tint = MaterialTheme.colorScheme.onSurfaceVariant,
334 )
335 Spacer(modifier = Modifier.height(16.dp))
336 Text(
337 text = emptyMessage,
338 style = MaterialTheme.typography.bodyLarge,
339 color = MaterialTheme.colorScheme.onSurfaceVariant,
340 modifier = Modifier.padding(horizontal = 32.dp),
341 )
342 }
343 }
344 } else {
345 val listState = rememberLazyListState()
346 val clipboard = remember {
347 context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
348 }
350 // Auto-load more when scrolling near the bottom
351 if (state.hasMore && !state.isLoading) {
352 val shouldLoadMore by remember {
353 derivedStateOf {
354 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
355 lastVisible >= listState.layoutInfo.totalItemsCount - 5
356 }
357 }
358 LaunchedEffect(shouldLoadMore) {
359 if (shouldLoadMore) viewModel.loadMore()
360 }
361 }
363 // Pre-compute stable keys so each log is assigned exactly one key
364 val logKeys = remember(state.logs) {
365 stableLogKeys(state.logs)
366 }
368 LazyColumn(
369 state = listState,
370 contentPadding = PaddingValues(8.dp),
371 verticalArrangement = Arrangement.spacedBy(4.dp),
372 ) {
373 itemsIndexed(
374 state.logs,
375 key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
376 ) { index, logEntry ->
377 val key = logKeys.getOrElse(index) { "log_$index" }
378 LogEntryCard(
379 logEntry = logEntry,
380 isExpanded = expandedLogKey == key,
381 onClick = {
382 expandedLogKey = if (expandedLogKey == key) null else key
383 },
384 clipboardManager = clipboard,
385 onCopied = {
386 scope.launch {
387 snackbarHostState.showSnackbar(
388 message = "Log entry copied to clipboard",
389 duration = SnackbarDuration.Short,
390 )
391 }
392 },
393 )
394 }
396 if (state.hasMore) {
397 item {
398 Box(
399 modifier = Modifier
400 .fillMaxWidth()
401 .padding(16.dp),
402 contentAlignment = Alignment.Center,
403 ) {
404 CircularProgressIndicator(
405 modifier = Modifier.size(24.dp),
406 strokeWidth = 2.dp,
407 )
408 }
409 }
410 } else if (state.logs.isNotEmpty()) {
411 item {
412 Box(
413 modifier = Modifier
414 .fillMaxWidth()
415 .padding(16.dp),
416 contentAlignment = Alignment.Center,
417 ) {
418 val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
419 Text(
420 text = if (atCap) {
421 "Showing max 5,000 results. Refine your query to see more."
422 } else {
423 "End of results"
424 },
425 style = MaterialTheme.typography.labelMedium,
426 color = if (atCap) {
427 MaterialTheme.colorScheme.primary
428 } else {
429 MaterialTheme.colorScheme.onSurfaceVariant
430 },
431 )
432 }
433 }
434 }
435 }
436 }
437 }
438 }
439 }
441 // Filter bottom sheet
442 if (showFilterSheet) {
443 FilterBottomSheet(
444 columns = state.columns,
445 onDismiss = { showFilterSheet = false },
446 onApplyFilter = { column, operator, value ->
447 viewModel.addFilter(column, operator, value)
448 showFilterSheet = false
449 },
450 )
451 }
453 // SQL query bottom sheet
454 if (showSqlSheet) {
455 SqlQueryBottomSheet(
456 streamName = streamName,
457 currentSql = state.customSql,
458 onDismiss = { showSqlSheet = false },
459 onExecute = { sql ->
460 viewModel.executeCustomSql(sql)
461 showSqlSheet = false
462 },
463 )
464 }
466 // Share confirmation dialog (logs may contain sensitive data)
467 if (showShareWarning) {
468 AlertDialog(
469 onDismissRequest = { showShareWarning = false },
470 title = { Text("Share Logs") },
471 text = {
472 Text("Logs may contain sensitive information (PII, secrets, tokens). " +
473 "They will be exported as unencrypted JSON. Continue?")
474 },
475 confirmButton = {
476 TextButton(onClick = {
477 showShareWarning = false
478 val logs = state.logs
479 if (logs.isNotEmpty()) {
480 scope.launch {
481 try {
482 val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
483 val dir = java.io.File(context.cacheDir, "shared_logs")
484 if (!dir.mkdirs() && !dir.isDirectory) {
485 throw java.io.IOException("Failed to create shared_logs directory")
486 }
487 // Clean up previous exports
488 dir.listFiles()?.forEach { old -> old.delete() }
489 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
490 val file = java.io.File(dir, "logs_$safeFileName.json")
491 file.bufferedWriter().use { writer ->
492 // Use compact JSON for large exports to reduce memory/file size
493 val encoder = if (logs.size > 1000) Json else prettyJson
494 writer.write("[")
495 logs.forEachIndexed { index, log ->
496 if (index > 0) writer.write(",")
497 writer.newLine()
498 writer.write(encoder.encodeToString(JsonObject.serializer(), log))
499 }
500 writer.newLine()
501 writer.write("]")
502 }
503 file
504 }
505 val uri = FileProvider.getUriForFile(
506 context,
507 "${context.packageName}.fileprovider",
508 file,
509 )
510 val sendIntent = Intent().apply {
511 action = Intent.ACTION_SEND
512 putExtra(Intent.EXTRA_STREAM, uri)
513 putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
514 type = "application/json"
515 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
516 }
517 context.startActivity(
518 Intent.createChooser(sendIntent, "Share logs")
519 )
520 } catch (e: Exception) {
521 snackbarHostState.showSnackbar(
522 message = "Failed to share logs: ${e.message}",
523 duration = SnackbarDuration.Short,
524 )
525 }
526 }
527 }
528 }) {
529 Text("Share")
530 }
531 },
532 dismissButton = {
533 TextButton(onClick = { showShareWarning = false }) {
534 Text("Cancel")
535 }
536 },
537 )
538 }
540 // Custom date range picker – state is created inside the block so it
541 // resets every time the dialog reopens (no stale selection carried over).
542 if (showDateRangePicker) {
543 val dateRangePickerState = rememberDateRangePickerState()
544 DatePickerDialog(
545 onDismissRequest = { showDateRangePicker = false },
546 confirmButton = {
547 TextButton(
548 onClick = {
549 val start = dateRangePickerState.selectedStartDateMillis
550 val end = dateRangePickerState.selectedEndDateMillis
551 if (start != null && end != null) {
552 // DateRangePicker returns midnight UTC for the selected date.
553 // Adjust to cover the full end day in the user's local timezone:
554 // Convert to local date, get end-of-day, convert back to UTC millis.
555 val localZone = java.time.ZoneId.systemDefault()
556 val endLocalDate = java.time.Instant.ofEpochMilli(end)
557 .atZone(localZone).toLocalDate()
558 val endOfDayUtc = endLocalDate.plusDays(1)
559 .atStartOfDay(localZone)
560 .toInstant().toEpochMilli() - 1
561 val startLocalDate = java.time.Instant.ofEpochMilli(start)
562 .atZone(localZone).toLocalDate()
563 val startUtc = startLocalDate
564 .atStartOfDay(localZone)
565 .toInstant().toEpochMilli()
566 viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
567 }
568 showDateRangePicker = false
569 },
570 enabled = dateRangePickerState.selectedStartDateMillis != null &&
571 dateRangePickerState.selectedEndDateMillis != null,
572 ) {
573 Text("Apply")
574 }
575 },
576 dismissButton = {
577 TextButton(onClick = { showDateRangePicker = false }) {
578 Text("Cancel")
579 }
580 },
581 ) {
582 DateRangePicker(
583 state = dateRangePickerState,
584 modifier = Modifier.weight(1f),
585 )
586 }
587 }
590@Composable
591private fun TimeRangeBar(
592 selectedRange: TimeRange,
593 onRangeSelected: (TimeRange) -> Unit,
594) {
595 Row(
596 modifier = Modifier
597 .horizontalScroll(rememberScrollState())
598 .padding(horizontal = 16.dp, vertical = 4.dp),
599 horizontalArrangement = Arrangement.spacedBy(8.dp),
600 ) {
601 TimeRange.entries.forEach { range ->
602 FilterChip(
603 selected = selectedRange == range,
604 onClick = { onRangeSelected(range) },
605 label = { Text(range.label) },
606 )
607 }
608 }
611@Composable
612private fun StreamingToggleButton(
613 isStreaming: Boolean,
614 onClick: () -> Unit,
615) {
616 IconButton(onClick = onClick) {
617 if (isStreaming) {
618 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
619 val alpha by infiniteTransition.animateFloat(
620 initialValue = 1f,
621 targetValue = 0.3f,
622 animationSpec = infiniteRepeatable(
623 animation = tween(600, easing = EaseInOut),
624 repeatMode = RepeatMode.Reverse,
625 ),
626 label = "stream_btn_alpha",
627 )
628 Icon(
629 Icons.Filled.Stop,
630 contentDescription = "Stop streaming",
631 tint = MaterialTheme.colorScheme.error,
632 modifier = Modifier.alpha(alpha),
633 )
634 } else {
635 Icon(
636 Icons.Filled.PlayArrow,
637 contentDescription = "Start live tail",
638 tint = MaterialTheme.colorScheme.primary,
639 )
640 }
641 }
644@Composable
645fun LogEntryCard(
646 logEntry: JsonObject,
647 isExpanded: Boolean,
648 onClick: () -> Unit,
649 onCopied: () -> Unit = {},
650 clipboardManager: android.content.ClipboardManager,
651) {
652 val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
653 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
654 Card(
655 onClick = onClick,
656 modifier = Modifier.fillMaxWidth(),
657 colors = cardColors,
658 ) {
659 Column(modifier = Modifier.padding(12.dp)) {
660 // Timestamp row
661 val rawTimestamp = remember(logEntry) {
662 try {
663 logEntry["p_timestamp"]?.jsonPrimitive?.content
664 ?: logEntry["datetime"]?.jsonPrimitive?.content
665 ?: ""
666 } catch (_: Exception) {
667 ""
668 }
669 }
670 val displayTimestamp = remember(rawTimestamp) {
671 if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
672 }
674 if (displayTimestamp.isNotEmpty()) {
675 Text(
676 text = displayTimestamp,
677 style = MaterialTheme.typography.labelSmall,
678 color = MaterialTheme.colorScheme.primary,
679 fontFamily = FontFamily.Monospace,
680 )
681 Spacer(modifier = Modifier.height(4.dp))
682 }
684 if (!isExpanded) {
685 // Compact view: show first meaningful fields
686 val preview = remember(logEntry) {
687 logEntry.entries
688 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
689 .take(3)
690 .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
691 }
692 Text(
693 text = preview.ifEmpty { logEntry.toString() },
694 style = MaterialTheme.typography.bodySmall,
695 fontFamily = FontFamily.Monospace,
696 maxLines = 2,
697 overflow = TextOverflow.Ellipsis,
698 fontSize = 12.sp,
699 )
700 } else {
701 // Expanded view: show all fields + copy button
702 Row(
703 modifier = Modifier.fillMaxWidth(),
704 horizontalArrangement = Arrangement.End,
705 ) {
706 IconButton(
707 onClick = {
708 val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
709 try {
710 clipboardManager.setPrimaryClip(
711 android.content.ClipData.newPlainText("Log entry", text)
712 )
713 onCopied()
714 } catch (_: SecurityException) {
715 // Some devices/custom ROMs restrict clipboard access
716 }
717 },
718 ) {
719 Icon(
720 Icons.Filled.ContentCopy,
721 contentDescription = "Copy log entry to clipboard",
722 tint = MaterialTheme.colorScheme.onSurfaceVariant,
723 )
724 }
725 }
726 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
727 logEntry.entries.forEach { (key, value) ->
728 Row(
729 modifier = Modifier
730 .fillMaxWidth()
731 .padding(vertical = 2.dp),
732 ) {
733 Text(
734 text = "$key: ",
735 style = MaterialTheme.typography.bodySmall,
736 fontWeight = FontWeight.Bold,
737 fontFamily = FontFamily.Monospace,
738 color = MaterialTheme.colorScheme.primary,
739 fontSize = 12.sp,
740 )
741 Text(
742 text = formatJsonValue(value),
743 style = MaterialTheme.typography.bodySmall,
744 fontFamily = FontFamily.Monospace,
745 fontSize = 12.sp,
746 )
747 }
748 }
749 }
750 }
751 }
754/**
755 * Produces deterministic keys for a list of log entries so that expanded state survives
756 * list mutations (e.g., new logs prepended during streaming). Keys are content-based
757 * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
758 */
759private fun stableLogKeys(logs: List<JsonObject>): List<String> {
760 val seen = mutableMapOf<String, Int>()
761 return logs.map { log ->
762 val ts = log["p_timestamp"]?.toString()
763 val meta = log["p_metadata"]?.toString()
764 val tag = log["p_tags"]?.toString()
765 val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
766 val count = seen.getOrDefault(base, 0)
767 seen[base] = count + 1
768 if (count == 0) base else "$base|$count"
769 }
772private fun formatJsonValue(element: JsonElement): String {
773 return when (element) {
774 is JsonPrimitive -> element.content
775 is JsonNull -> "null"
776 else -> element.toString()
777 }
780@OptIn(ExperimentalMaterial3Api::class)
781@Composable
782fun FilterBottomSheet(
783 columns: List<String>,
784 onDismiss: () -> Unit,
785 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
786) {
787 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
788 var selectedOperator by remember { mutableStateOf("=") }
789 var filterValue by remember { mutableStateOf("") }
790 var columnDropdownExpanded by remember { mutableStateOf(false) }
791 var operatorDropdownExpanded by remember { mutableStateOf(false) }
793 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
795 ModalBottomSheet(onDismissRequest = onDismiss) {
796 Column(
797 modifier = Modifier.padding(16.dp),
798 verticalArrangement = Arrangement.spacedBy(12.dp),
799 ) {
800 Text(
801 text = "Add Filter",
802 style = MaterialTheme.typography.titleLarge,
803 fontWeight = FontWeight.Bold,
804 )
806 // Column selector
807 ExposedDropdownMenuBox(
808 expanded = columnDropdownExpanded,
809 onExpandedChange = { columnDropdownExpanded = it },
810 ) {
811 OutlinedTextField(
812 value = selectedColumn,
813 onValueChange = {},
814 readOnly = true,
815 label = { Text("Column") },
816 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
817 modifier = Modifier
818 .fillMaxWidth()
819 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
820 )
821 ExposedDropdownMenu(
822 expanded = columnDropdownExpanded,
823 onDismissRequest = { columnDropdownExpanded = false },
824 ) {
825 columns.forEach { column ->
826 DropdownMenuItem(
827 text = { Text(column) },
828 onClick = {
829 selectedColumn = column
830 columnDropdownExpanded = false
831 },
832 )
833 }
834 }
835 }
837 // Operator selector
838 ExposedDropdownMenuBox(
839 expanded = operatorDropdownExpanded,
840 onExpandedChange = { operatorDropdownExpanded = it },
841 ) {
842 OutlinedTextField(
843 value = selectedOperator,
844 onValueChange = {},
845 readOnly = true,
846 label = { Text("Operator") },
847 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
848 modifier = Modifier
849 .fillMaxWidth()
850 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
851 )
852 ExposedDropdownMenu(
853 expanded = operatorDropdownExpanded,
854 onDismissRequest = { operatorDropdownExpanded = false },
855 ) {
856 operators.forEach { op ->
857 DropdownMenuItem(
858 text = { Text(op) },
859 onClick = {
860 selectedOperator = op
861 operatorDropdownExpanded = false
862 },
863 )
864 }
865 }
866 }
868 // Value input (hidden for IS NULL / IS NOT NULL)
869 if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
870 val valueHint = when (selectedOperator) {
871 "LIKE", "ILIKE" -> "Pattern (% added automatically)"
872 ">", "<", ">=", "<=" -> "Numeric or date value"
873 else -> "Exact value"
874 }
875 val valueError = when {
876 filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
877 filterValue.toDoubleOrNull() == null &&
878 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
879 "Expected a number or date"
880 else -> null
881 }
882 OutlinedTextField(
883 value = filterValue,
884 onValueChange = { filterValue = it },
885 label = { Text("Value") },
886 placeholder = { Text(valueHint) },
887 modifier = Modifier.fillMaxWidth(),
888 singleLine = true,
889 isError = valueError != null,
890 supportingText = valueError?.let { { Text(it) } },
891 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
892 keyboardActions = KeyboardActions(onDone = {
893 if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
894 onApplyFilter(selectedColumn, selectedOperator, filterValue)
895 }
896 }),
897 )
898 }
900 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
901 filterValue.isNotEmpty() &&
902 filterValue.toDoubleOrNull() == null &&
903 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
905 Row(
906 modifier = Modifier.fillMaxWidth(),
907 horizontalArrangement = Arrangement.End,
908 verticalAlignment = Alignment.CenterVertically,
909 ) {
910 TextButton(onClick = onDismiss) {
911 Text("Cancel")
912 }
913 Spacer(modifier = Modifier.width(8.dp))
914 Button(
915 onClick = {
916 if (selectedColumn.isNotBlank()) {
917 onApplyFilter(selectedColumn, selectedOperator, filterValue)
918 }
919 },
920 enabled = selectedColumn.isNotBlank() &&
921 !hasValueError &&
922 (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
923 ) {
924 Text("Apply")
925 }
926 }
928 Spacer(modifier = Modifier.height(32.dp))
929 }
930 }
933@OptIn(ExperimentalMaterial3Api::class)
934@Composable
935fun SqlQueryBottomSheet(
936 streamName: String,
937 currentSql: String,
938 onDismiss: () -> Unit,
939 onExecute: (String) -> Unit,
940) {
941 val safeName = remember(streamName) { escapeIdentifier(streamName) }
942 var sql by remember {
943 mutableStateOf(
944 currentSql.ifEmpty {
945 "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
946 }
947 )
948 }
950 ModalBottomSheet(onDismissRequest = onDismiss) {
951 Column(
952 modifier = Modifier.padding(16.dp),
953 verticalArrangement = Arrangement.spacedBy(12.dp),
954 ) {
955 Text(
956 text = "SQL Query",
957 style = MaterialTheme.typography.titleLarge,
958 fontWeight = FontWeight.Bold,
959 )
961 Text(
962 text = "Use PostgreSQL-compatible SQL to query your logs.",
963 style = MaterialTheme.typography.bodySmall,
964 color = MaterialTheme.colorScheme.onSurfaceVariant,
965 )
967 OutlinedTextField(
968 value = sql,
969 onValueChange = { sql = it },
970 label = { Text("SQL") },
971 modifier = Modifier
972 .fillMaxWidth()
973 .heightIn(min = 120.dp),
974 textStyle = MaterialTheme.typography.bodyMedium.copy(
975 fontFamily = FontFamily.Monospace,
976 ),
977 maxLines = 8,
978 )
980 Row(
981 modifier = Modifier.fillMaxWidth(),
982 horizontalArrangement = Arrangement.End,
983 ) {
984 TextButton(onClick = onDismiss) {
985 Text("Cancel")
986 }
987 Spacer(modifier = Modifier.width(8.dp))
988 Button(
989 onClick = { onExecute(sql) },
990 enabled = sql.isNotBlank(),
991 ) {
992 Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
993 Spacer(modifier = Modifier.width(4.dp))
994 Text("Execute")
995 }
996 }
998 Spacer(modifier = Modifier.height(32.dp))
999 }