git.iko.soy/parseable-android
on e7bbc3d help sign in
1003 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 severity = remember(logEntry) { detectSeverity(logEntry) }
653 val severityTint = severityBackgroundColor(severity)
654 val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
655 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
656 Card(
657 onClick = onClick,
658 modifier = Modifier.fillMaxWidth(),
659 colors = cardColors,
660 ) {
661 Column(modifier = Modifier.padding(12.dp)) {
662 // Timestamp row
663 val rawTimestamp = remember(logEntry) {
664 try {
665 logEntry["p_timestamp"]?.jsonPrimitive?.content
666 ?: logEntry["datetime"]?.jsonPrimitive?.content
667 ?: ""
668 } catch (_: Exception) {
669 ""
670 }
671 }
672 val displayTimestamp = remember(rawTimestamp) {
673 if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
674 }
676 if (displayTimestamp.isNotEmpty()) {
677 Text(
678 text = displayTimestamp,
679 style = MaterialTheme.typography.labelSmall,
680 color = MaterialTheme.colorScheme.primary,
681 fontFamily = FontFamily.Monospace,
682 )
683 Spacer(modifier = Modifier.height(4.dp))
684 }
686 if (!isExpanded) {
687 // Compact view: show first meaningful fields
688 val preview = remember(logEntry) {
689 logEntry.entries
690 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
691 .take(3)
692 .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
693 }
694 Text(
695 text = preview.ifEmpty { logEntry.toString() },
696 style = MaterialTheme.typography.bodySmall,
697 fontFamily = FontFamily.Monospace,
698 maxLines = 2,
699 overflow = TextOverflow.Ellipsis,
700 fontSize = 12.sp,
701 )
702 } else {
703 // Expanded view: show all fields + copy button
704 Row(
705 modifier = Modifier.fillMaxWidth(),
706 horizontalArrangement = Arrangement.End,
707 ) {
708 IconButton(
709 onClick = {
710 val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
711 try {
712 clipboardManager.setPrimaryClip(
713 android.content.ClipData.newPlainText("Log entry", text)
714 )
715 onCopied()
716 } catch (_: SecurityException) {
717 // Some devices/custom ROMs restrict clipboard access
718 }
719 },
720 ) {
721 Icon(
722 Icons.Filled.ContentCopy,
723 contentDescription = "Copy log entry to clipboard",
724 tint = MaterialTheme.colorScheme.onSurfaceVariant,
725 )
726 }
727 }
728 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
729 logEntry.entries.forEach { (key, value) ->
730 Row(
731 modifier = Modifier
732 .fillMaxWidth()
733 .padding(vertical = 2.dp),
734 ) {
735 Text(
736 text = "$key: ",
737 style = MaterialTheme.typography.bodySmall,
738 fontWeight = FontWeight.Bold,
739 fontFamily = FontFamily.Monospace,
740 color = MaterialTheme.colorScheme.primary,
741 fontSize = 12.sp,
742 )
743 Text(
744 text = formatJsonValue(value),
745 style = MaterialTheme.typography.bodySmall,
746 fontFamily = FontFamily.Monospace,
747 fontSize = 12.sp,
748 )
749 }
750 }
751 }
752 }
753 }
756/**
757 * Produces deterministic keys for a list of log entries so that expanded state survives
758 * list mutations (e.g., new logs prepended during streaming). Keys are content-based
759 * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
760 */
761private fun stableLogKeys(logs: List<JsonObject>): List<String> {
762 val seen = mutableMapOf<String, Int>()
763 return logs.map { log ->
764 val ts = log["p_timestamp"]?.toString()
765 val meta = log["p_metadata"]?.toString()
766 val tag = log["p_tags"]?.toString()
767 val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
768 val count = seen.getOrDefault(base, 0)
769 seen[base] = count + 1
770 if (count == 0) base else "$base|$count"
771 }
774private fun formatJsonValue(element: JsonElement): String {
775 return when (element) {
776 is JsonPrimitive -> element.content
777 is JsonNull -> "null"
778 else -> element.toString()
779 }
782@OptIn(ExperimentalMaterial3Api::class)
783@Composable
784fun FilterBottomSheet(
785 columns: List<String>,
786 onDismiss: () -> Unit,
787 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
788) {
789 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
790 var selectedOperator by remember { mutableStateOf("=") }
791 var filterValue by remember { mutableStateOf("") }
792 var columnDropdownExpanded by remember { mutableStateOf(false) }
793 var operatorDropdownExpanded by remember { mutableStateOf(false) }
795 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
797 ModalBottomSheet(onDismissRequest = onDismiss) {
798 Column(
799 modifier = Modifier.padding(16.dp),
800 verticalArrangement = Arrangement.spacedBy(12.dp),
801 ) {
802 Text(
803 text = "Add Filter",
804 style = MaterialTheme.typography.titleLarge,
805 fontWeight = FontWeight.Bold,
806 )
808 // Column selector
809 ExposedDropdownMenuBox(
810 expanded = columnDropdownExpanded,
811 onExpandedChange = { columnDropdownExpanded = it },
812 ) {
813 OutlinedTextField(
814 value = selectedColumn,
815 onValueChange = {},
816 readOnly = true,
817 label = { Text("Column") },
818 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
819 modifier = Modifier
820 .fillMaxWidth()
821 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
822 )
823 ExposedDropdownMenu(
824 expanded = columnDropdownExpanded,
825 onDismissRequest = { columnDropdownExpanded = false },
826 ) {
827 columns.forEach { column ->
828 DropdownMenuItem(
829 text = { Text(column) },
830 onClick = {
831 selectedColumn = column
832 columnDropdownExpanded = false
833 },
834 )
835 }
836 }
837 }
839 // Operator selector
840 ExposedDropdownMenuBox(
841 expanded = operatorDropdownExpanded,
842 onExpandedChange = { operatorDropdownExpanded = it },
843 ) {
844 OutlinedTextField(
845 value = selectedOperator,
846 onValueChange = {},
847 readOnly = true,
848 label = { Text("Operator") },
849 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
850 modifier = Modifier
851 .fillMaxWidth()
852 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
853 )
854 ExposedDropdownMenu(
855 expanded = operatorDropdownExpanded,
856 onDismissRequest = { operatorDropdownExpanded = false },
857 ) {
858 operators.forEach { op ->
859 DropdownMenuItem(
860 text = { Text(op) },
861 onClick = {
862 selectedOperator = op
863 operatorDropdownExpanded = false
864 },
865 )
866 }
867 }
868 }
870 // Value input (hidden for IS NULL / IS NOT NULL)
871 if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
872 val valueHint = when (selectedOperator) {
873 "LIKE", "ILIKE" -> "Pattern (% added automatically)"
874 ">", "<", ">=", "<=" -> "Numeric or date value"
875 else -> "Exact value"
876 }
877 val valueError = when {
878 filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
879 filterValue.toDoubleOrNull() == null &&
880 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
881 "Expected a number or date"
882 else -> null
883 }
884 OutlinedTextField(
885 value = filterValue,
886 onValueChange = { filterValue = it },
887 label = { Text("Value") },
888 placeholder = { Text(valueHint) },
889 modifier = Modifier.fillMaxWidth(),
890 singleLine = true,
891 isError = valueError != null,
892 supportingText = valueError?.let { { Text(it) } },
893 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
894 keyboardActions = KeyboardActions(onDone = {
895 if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
896 onApplyFilter(selectedColumn, selectedOperator, filterValue)
897 }
898 }),
899 )
900 }
902 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
903 filterValue.isNotEmpty() &&
904 filterValue.toDoubleOrNull() == null &&
905 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
907 Row(
908 modifier = Modifier.fillMaxWidth(),
909 horizontalArrangement = Arrangement.End,
910 verticalAlignment = Alignment.CenterVertically,
911 ) {
912 TextButton(onClick = onDismiss) {
913 Text("Cancel")
914 }
915 Spacer(modifier = Modifier.width(8.dp))
916 Button(
917 onClick = {
918 if (selectedColumn.isNotBlank()) {
919 onApplyFilter(selectedColumn, selectedOperator, filterValue)
920 }
921 },
922 enabled = selectedColumn.isNotBlank() &&
923 !hasValueError &&
924 (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
925 ) {
926 Text("Apply")
927 }
928 }
930 Spacer(modifier = Modifier.height(32.dp))
931 }
932 }
935@OptIn(ExperimentalMaterial3Api::class)
936@Composable
937fun SqlQueryBottomSheet(
938 streamName: String,
939 currentSql: String,
940 onDismiss: () -> Unit,
941 onExecute: (String) -> Unit,
942) {
943 val safeName = remember(streamName) { escapeIdentifier(streamName) }
944 var sql by remember {
945 mutableStateOf(
946 currentSql.ifEmpty {
947 "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
948 }
949 )
950 }
952 ModalBottomSheet(onDismissRequest = onDismiss) {
953 Column(
954 modifier = Modifier.padding(16.dp),
955 verticalArrangement = Arrangement.spacedBy(12.dp),
956 ) {
957 Text(
958 text = "SQL Query",
959 style = MaterialTheme.typography.titleLarge,
960 fontWeight = FontWeight.Bold,
961 )
963 Text(
964 text = "Use PostgreSQL-compatible SQL to query your logs.",
965 style = MaterialTheme.typography.bodySmall,
966 color = MaterialTheme.colorScheme.onSurfaceVariant,
967 )
969 OutlinedTextField(
970 value = sql,
971 onValueChange = { sql = it },
972 label = { Text("SQL") },
973 modifier = Modifier
974 .fillMaxWidth()
975 .heightIn(min = 120.dp),
976 textStyle = MaterialTheme.typography.bodyMedium.copy(
977 fontFamily = FontFamily.Monospace,
978 ),
979 maxLines = 8,
980 )
982 Row(
983 modifier = Modifier.fillMaxWidth(),
984 horizontalArrangement = Arrangement.End,
985 ) {
986 TextButton(onClick = onDismiss) {
987 Text("Cancel")
988 }
989 Spacer(modifier = Modifier.width(8.dp))
990 Button(
991 onClick = { onExecute(sql) },
992 enabled = sql.isNotBlank(),
993 ) {
994 Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
995 Spacer(modifier = Modifier.width(4.dp))
996 Text("Execute")
997 }
998 }
1000 Spacer(modifier = Modifier.height(32.dp))