git.iko.soy/parseable-android
on 3b7ea33 help sign in
1359 lines 59 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 showSavedFiltersSheet by remember { mutableStateOf(false) }
60 var showSaveFilterDialog by remember { mutableStateOf(false) }
61 var expandedLogKey by remember { mutableStateOf<String?>(null) }
62 var showDateRangePicker by remember { mutableStateOf(false) }
63 var showShareWarning by remember { mutableStateOf(false) }
65 LaunchedEffect(streamName) {
66 viewModel.initialize(streamName)
67 }
69 Scaffold(
70 snackbarHost = { SnackbarHost(snackbarHostState) },
71 topBar = {
72 TopAppBar(
73 title = {
74 Column {
75 Text(
76 text = streamName,
77 fontWeight = FontWeight.Bold,
78 maxLines = 1,
79 overflow = TextOverflow.Ellipsis,
80 )
81 Text(
82 text = if (state.isStreaming) {
83 "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
84 } else {
85 "${state.logs.size} results"
86 },
87 style = MaterialTheme.typography.bodySmall,
88 color = if (state.isStreaming) {
89 MaterialTheme.colorScheme.primary
90 } else {
91 MaterialTheme.colorScheme.onSurfaceVariant
92 },
93 )
94 }
95 },
96 navigationIcon = {
97 IconButton(onClick = onBack) {
98 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
99 }
100 },
101 actions = {
102 StreamingToggleButton(
103 isStreaming = state.isStreaming,
104 onClick = viewModel::toggleStreaming,
105 )
106 IconButton(onClick = { onStreamInfo(streamName) }) {
107 Icon(Icons.Filled.Info, contentDescription = "Stream Info")
108 }
109 IconButton(onClick = { showFilterSheet = true }) {
110 Icon(Icons.Filled.FilterList, contentDescription = "Filters")
111 }
112 IconButton(onClick = { showSqlSheet = true }) {
113 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
114 }
115 IconButton(onClick = {
116 viewModel.loadSavedFilters()
117 showSavedFiltersSheet = true
118 }) {
119 Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
120 }
121 IconButton(
122 onClick = { showShareWarning = true },
123 enabled = state.logs.isNotEmpty(),
124 ) {
125 Icon(Icons.Filled.Share, contentDescription = "Share logs")
126 }
127 },
128 )
129 },
130 ) { padding ->
131 Column(
132 modifier = Modifier
133 .fillMaxSize()
134 .padding(padding),
135 ) {
136 // Time range chips
137 TimeRangeBar(
138 selectedRange = state.selectedTimeRange,
139 onRangeSelected = { range ->
140 if (range == TimeRange.CUSTOM) {
141 showDateRangePicker = true
142 } else {
143 viewModel.onTimeRangeChange(range)
144 }
145 },
146 )
148 // Quick search bar
149 OutlinedTextField(
150 value = state.searchQuery,
151 onValueChange = viewModel::onSearchQueryChange,
152 placeholder = { Text("Search logs...") },
153 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
154 trailingIcon = {
155 if (state.isSearching) {
156 CircularProgressIndicator(
157 modifier = Modifier.size(20.dp),
158 strokeWidth = 2.dp,
159 )
160 } else if (state.searchQuery.isNotEmpty()) {
161 IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
162 Icon(Icons.Filled.Clear, contentDescription = "Clear")
163 }
164 }
165 },
166 singleLine = true,
167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
168 keyboardActions = KeyboardActions(onSearch = {
169 keyboardController?.hide()
170 viewModel.refresh()
171 }),
172 modifier = Modifier
173 .fillMaxWidth()
174 .padding(horizontal = 16.dp, vertical = 4.dp),
175 textStyle = MaterialTheme.typography.bodyMedium,
176 )
178 // Active filters indicator
179 if (state.activeFilters.isNotEmpty()) {
180 Row(
181 modifier = Modifier
182 .horizontalScroll(rememberScrollState())
183 .padding(horizontal = 16.dp, vertical = 4.dp)
184 .animateContentSize(),
185 horizontalArrangement = Arrangement.spacedBy(8.dp),
186 ) {
187 state.activeFilters.forEach { filter ->
188 FilterChip(
189 selected = true,
190 onClick = { viewModel.removeFilter(filter) },
191 label = { Text(filter, maxLines = 1) },
192 trailingIcon = {
193 Icon(
194 Icons.Filled.Close,
195 contentDescription = "Remove filter",
196 modifier = Modifier.size(16.dp),
197 )
198 },
199 )
200 }
201 TextButton(onClick = viewModel::clearFilters) {
202 Text("Clear all")
203 }
204 }
205 }
207 // Streaming indicator banner
208 AnimatedVisibility(visible = state.isStreaming) {
209 Surface(
210 color = MaterialTheme.colorScheme.primaryContainer,
211 modifier = Modifier.fillMaxWidth(),
212 ) {
213 Row(
214 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
215 verticalAlignment = Alignment.CenterVertically,
216 ) {
217 val infiniteTransition = rememberInfiniteTransition(label = "pulse")
218 val alpha by infiniteTransition.animateFloat(
219 initialValue = 1f,
220 targetValue = 0.3f,
221 animationSpec = infiniteRepeatable(
222 animation = tween(800, easing = EaseInOut),
223 repeatMode = RepeatMode.Reverse,
224 ),
225 label = "pulse_alpha",
226 )
227 Icon(
228 Icons.Filled.FiberManualRecord,
229 contentDescription = "Live streaming active",
230 tint = MaterialTheme.colorScheme.error,
231 modifier = Modifier
232 .size(12.dp)
233 .alpha(alpha),
234 )
235 Spacer(modifier = Modifier.width(8.dp))
236 Text(
237 text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
238 style = MaterialTheme.typography.labelMedium,
239 color = MaterialTheme.colorScheme.onPrimaryContainer,
240 modifier = Modifier.weight(1f),
241 )
242 TextButton(onClick = viewModel::stopStreaming) {
243 Text("Stop")
244 }
245 }
246 }
247 }
249 // Streaming error banner
250 AnimatedVisibility(visible = state.streamingError != null) {
251 Surface(
252 color = MaterialTheme.colorScheme.errorContainer,
253 modifier = Modifier.fillMaxWidth(),
254 ) {
255 Row(
256 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
257 verticalAlignment = Alignment.CenterVertically,
258 ) {
259 Icon(
260 Icons.Filled.ErrorOutline,
261 contentDescription = "Streaming error",
262 tint = MaterialTheme.colorScheme.onErrorContainer,
263 modifier = Modifier.size(16.dp),
264 )
265 Spacer(modifier = Modifier.width(8.dp))
266 Text(
267 text = state.streamingError ?: "",
268 style = MaterialTheme.typography.labelMedium,
269 color = MaterialTheme.colorScheme.onErrorContainer,
270 modifier = Modifier.weight(1f),
271 maxLines = 1,
272 overflow = TextOverflow.Ellipsis,
273 )
274 TextButton(onClick = viewModel::dismissStreamingError) {
275 Text("Dismiss")
276 }
277 }
278 }
279 }
281 // Log entries
282 PullToRefreshBox(
283 isRefreshing = state.isLoading,
284 onRefresh = viewModel::refresh,
285 modifier = Modifier.fillMaxSize(),
286 ) {
287 if (state.error != null && state.logs.isEmpty()) {
288 Box(
289 modifier = Modifier.fillMaxSize(),
290 contentAlignment = Alignment.Center,
291 ) {
292 Column(horizontalAlignment = Alignment.CenterHorizontally) {
293 Icon(
294 Icons.Filled.ErrorOutline,
295 contentDescription = "Error loading logs",
296 modifier = Modifier.size(48.dp),
297 tint = MaterialTheme.colorScheme.error,
298 )
299 Spacer(modifier = Modifier.height(16.dp))
300 Text(
301 text = state.error.orEmpty(),
302 style = MaterialTheme.typography.bodyMedium,
303 color = MaterialTheme.colorScheme.error,
304 modifier = Modifier.padding(horizontal = 32.dp),
305 )
306 Spacer(modifier = Modifier.height(16.dp))
307 OutlinedButton(onClick = viewModel::refresh) {
308 Text("Retry")
309 }
310 }
311 }
312 } else if (state.logs.isEmpty() && state.isLoading) {
313 Box(
314 modifier = Modifier.fillMaxSize(),
315 contentAlignment = Alignment.Center,
316 ) {
317 CircularProgressIndicator()
318 }
319 } else if (state.logs.isEmpty() && !state.isLoading) {
320 val emptyMessage = when {
321 state.isStreaming ->
322 "Waiting for new logs\u2026"
323 state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
324 "No logs match \"${state.searchQuery}\" with the active filters"
325 state.searchQuery.isNotBlank() ->
326 "No logs match \"${state.searchQuery}\""
327 state.activeFilters.isNotEmpty() ->
328 "No logs match the active filters"
329 else ->
330 "No logs found for the selected time range"
331 }
332 Box(
333 modifier = Modifier.fillMaxSize(),
334 contentAlignment = Alignment.Center,
335 ) {
336 Column(horizontalAlignment = Alignment.CenterHorizontally) {
337 Icon(
338 Icons.Filled.SearchOff,
339 contentDescription = "No logs found",
340 modifier = Modifier.size(48.dp),
341 tint = MaterialTheme.colorScheme.onSurfaceVariant,
342 )
343 Spacer(modifier = Modifier.height(16.dp))
344 Text(
345 text = emptyMessage,
346 style = MaterialTheme.typography.bodyLarge,
347 color = MaterialTheme.colorScheme.onSurfaceVariant,
348 modifier = Modifier.padding(horizontal = 32.dp),
349 )
350 }
351 }
352 } else {
353 val listState = rememberLazyListState()
354 val clipboard = remember {
355 context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
356 }
358 // Auto-load more when scrolling near the bottom
359 if (state.hasMore && !state.isLoading) {
360 val shouldLoadMore by remember {
361 derivedStateOf {
362 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
363 lastVisible >= listState.layoutInfo.totalItemsCount - 5
364 }
365 }
366 LaunchedEffect(shouldLoadMore) {
367 if (shouldLoadMore) viewModel.loadMore()
368 }
369 }
371 LazyColumn(
372 state = listState,
373 contentPadding = PaddingValues(8.dp),
374 verticalArrangement = Arrangement.spacedBy(4.dp),
375 ) {
376 itemsIndexed(
377 state.logs,
378 key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
379 ) { index, logEntry ->
380 val key = state.logKeys.getOrElse(index) { "log_$index" }
381 LogEntryCard(
382 logEntry = logEntry,
383 isExpanded = expandedLogKey == key,
384 onClick = {
385 expandedLogKey = if (expandedLogKey == key) null else key
386 },
387 clipboardManager = clipboard,
388 onCopied = {
389 scope.launch {
390 snackbarHostState.showSnackbar(
391 message = "Log entry copied to clipboard",
392 duration = SnackbarDuration.Short,
393 )
394 }
395 },
396 onFilterInclude = { column, value ->
397 viewModel.addFilter(column, "=", value)
398 },
399 onFilterExclude = { column, value ->
400 viewModel.addFilter(column, "!=", value)
401 },
402 )
403 }
405 if (state.hasMore) {
406 item {
407 Box(
408 modifier = Modifier
409 .fillMaxWidth()
410 .padding(16.dp),
411 contentAlignment = Alignment.Center,
412 ) {
413 CircularProgressIndicator(
414 modifier = Modifier.size(24.dp),
415 strokeWidth = 2.dp,
416 )
417 }
418 }
419 } else if (state.logs.isNotEmpty()) {
420 item {
421 Box(
422 modifier = Modifier
423 .fillMaxWidth()
424 .padding(16.dp),
425 contentAlignment = Alignment.Center,
426 ) {
427 val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
428 Text(
429 text = if (atCap) {
430 "Showing max 5,000 results. Refine your query to see more."
431 } else {
432 "End of results"
433 },
434 style = MaterialTheme.typography.labelMedium,
435 color = if (atCap) {
436 MaterialTheme.colorScheme.primary
437 } else {
438 MaterialTheme.colorScheme.onSurfaceVariant
439 },
440 )
441 }
442 }
443 }
444 }
445 }
446 }
447 }
448 }
450 // Filter bottom sheet
451 if (showFilterSheet) {
452 FilterBottomSheet(
453 columns = state.columns,
454 logs = state.logs,
455 onDismiss = { showFilterSheet = false },
456 onApplyFilter = { column, operator, value ->
457 viewModel.addFilter(column, operator, value)
458 showFilterSheet = false
459 },
460 )
461 }
463 // SQL query bottom sheet
464 if (showSqlSheet) {
465 SqlQueryBottomSheet(
466 streamName = streamName,
467 currentSql = state.customSql,
468 onDismiss = { showSqlSheet = false },
469 onExecute = { sql ->
470 viewModel.executeCustomSql(sql)
471 showSqlSheet = false
472 },
473 )
474 }
476 // Share confirmation dialog (logs may contain sensitive data)
477 if (showShareWarning) {
478 AlertDialog(
479 onDismissRequest = { showShareWarning = false },
480 title = { Text("Share Logs") },
481 text = {
482 Text("Logs may contain sensitive information (PII, secrets, tokens). " +
483 "They will be exported as unencrypted JSON. Continue?")
484 },
485 confirmButton = {
486 TextButton(onClick = {
487 showShareWarning = false
488 val logs = state.logs
489 if (logs.isNotEmpty()) {
490 scope.launch {
491 try {
492 val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
493 val dir = java.io.File(context.cacheDir, "shared_logs")
494 if (!dir.mkdirs() && !dir.isDirectory) {
495 throw java.io.IOException("Failed to create shared_logs directory")
496 }
497 // Clean up previous exports
498 dir.listFiles()?.forEach { old -> old.delete() }
499 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
500 val file = java.io.File(dir, "logs_$safeFileName.json")
501 file.bufferedWriter().use { writer ->
502 // Use compact JSON for large exports to reduce memory/file size
503 val encoder = if (logs.size > 1000) Json else prettyJson
504 writer.write("[")
505 logs.forEachIndexed { index, log ->
506 if (index > 0) writer.write(",")
507 writer.newLine()
508 writer.write(encoder.encodeToString(JsonObject.serializer(), log))
509 }
510 writer.newLine()
511 writer.write("]")
512 }
513 file
514 }
515 val uri = FileProvider.getUriForFile(
516 context,
517 "${context.packageName}.fileprovider",
518 file,
519 )
520 val sendIntent = Intent().apply {
521 action = Intent.ACTION_SEND
522 putExtra(Intent.EXTRA_STREAM, uri)
523 putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
524 type = "application/json"
525 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
526 }
527 context.startActivity(
528 Intent.createChooser(sendIntent, "Share logs")
529 )
530 } catch (e: Exception) {
531 snackbarHostState.showSnackbar(
532 message = "Failed to share logs: ${e.message}",
533 duration = SnackbarDuration.Short,
534 )
535 }
536 }
537 }
538 }) {
539 Text("Share")
540 }
541 },
542 dismissButton = {
543 TextButton(onClick = { showShareWarning = false }) {
544 Text("Cancel")
545 }
546 },
547 )
548 }
550 // Custom date range picker – state is created inside the block so it
551 // resets every time the dialog reopens (no stale selection carried over).
552 if (showDateRangePicker) {
553 val dateRangePickerState = rememberDateRangePickerState()
554 DatePickerDialog(
555 onDismissRequest = { showDateRangePicker = false },
556 confirmButton = {
557 TextButton(
558 onClick = {
559 val start = dateRangePickerState.selectedStartDateMillis
560 val end = dateRangePickerState.selectedEndDateMillis
561 if (start != null && end != null) {
562 // DateRangePicker returns midnight UTC for the selected date.
563 // Adjust to cover the full end day in the user's local timezone:
564 // Convert to local date, get end-of-day, convert back to UTC millis.
565 val localZone = java.time.ZoneId.systemDefault()
566 val endLocalDate = java.time.Instant.ofEpochMilli(end)
567 .atZone(localZone).toLocalDate()
568 val endOfDayUtc = endLocalDate.plusDays(1)
569 .atStartOfDay(localZone)
570 .toInstant().toEpochMilli() - 1
571 val startLocalDate = java.time.Instant.ofEpochMilli(start)
572 .atZone(localZone).toLocalDate()
573 val startUtc = startLocalDate
574 .atStartOfDay(localZone)
575 .toInstant().toEpochMilli()
576 viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
577 }
578 showDateRangePicker = false
579 },
580 enabled = dateRangePickerState.selectedStartDateMillis != null &&
581 dateRangePickerState.selectedEndDateMillis != null,
582 ) {
583 Text("Apply")
584 }
585 },
586 dismissButton = {
587 TextButton(onClick = { showDateRangePicker = false }) {
588 Text("Cancel")
589 }
590 },
591 ) {
592 DateRangePicker(
593 state = dateRangePickerState,
594 modifier = Modifier.weight(1f),
595 )
596 }
597 }
599 // Saved filters bottom sheet
600 if (showSavedFiltersSheet) {
601 SavedFiltersBottomSheet(
602 savedFiltersState = state.savedFilters,
603 hasActiveFilters = state.activeFilters.isNotEmpty() ||
604 state.searchQuery.isNotBlank() ||
605 state.customSql.isNotBlank(),
606 onDismiss = { showSavedFiltersSheet = false },
607 onSaveCurrentFilter = { showSaveFilterDialog = true },
608 onApplyFilter = { filter ->
609 viewModel.applySavedFilter(filter)
610 showSavedFiltersSheet = false
611 },
612 onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
613 onRetry = { viewModel.loadSavedFilters() },
614 )
615 }
617 // Save filter name dialog
618 if (showSaveFilterDialog) {
619 SaveFilterDialog(
620 isSaving = state.savedFilters.isSaving,
621 onDismiss = { showSaveFilterDialog = false },
622 onSave = { name ->
623 viewModel.saveCurrentFilter(name)
624 showSaveFilterDialog = false
625 showSavedFiltersSheet = false
626 },
627 )
628 }
631@Composable
632private fun TimeRangeBar(
633 selectedRange: TimeRange,
634 onRangeSelected: (TimeRange) -> Unit,
635) {
636 Row(
637 modifier = Modifier
638 .horizontalScroll(rememberScrollState())
639 .padding(horizontal = 16.dp, vertical = 4.dp),
640 horizontalArrangement = Arrangement.spacedBy(8.dp),
641 ) {
642 TimeRange.entries.forEach { range ->
643 FilterChip(
644 selected = selectedRange == range,
645 onClick = { onRangeSelected(range) },
646 label = { Text(range.label) },
647 )
648 }
649 }
652@Composable
653private fun StreamingToggleButton(
654 isStreaming: Boolean,
655 onClick: () -> Unit,
656) {
657 IconButton(onClick = onClick) {
658 if (isStreaming) {
659 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
660 val alpha by infiniteTransition.animateFloat(
661 initialValue = 1f,
662 targetValue = 0.3f,
663 animationSpec = infiniteRepeatable(
664 animation = tween(600, easing = EaseInOut),
665 repeatMode = RepeatMode.Reverse,
666 ),
667 label = "stream_btn_alpha",
668 )
669 Icon(
670 Icons.Filled.Stop,
671 contentDescription = "Stop streaming",
672 tint = MaterialTheme.colorScheme.error,
673 modifier = Modifier.alpha(alpha),
674 )
675 } else {
676 Icon(
677 Icons.Filled.PlayArrow,
678 contentDescription = "Start live tail",
679 tint = MaterialTheme.colorScheme.primary,
680 )
681 }
682 }
685@Composable
686fun LogEntryCard(
687 logEntry: JsonObject,
688 isExpanded: Boolean,
689 onClick: () -> Unit,
690 onCopied: () -> Unit = {},
691 onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
692 onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
693 clipboardManager: android.content.ClipboardManager,
694) {
695 val severity = remember(logEntry) { detectSeverity(logEntry) }
696 val severityTint = severityBackgroundColor(severity)
697 val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
698 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
699 Card(
700 onClick = onClick,
701 modifier = Modifier.fillMaxWidth(),
702 colors = cardColors,
703 ) {
704 Column(modifier = Modifier.padding(12.dp)) {
705 // Timestamp row
706 val rawTimestamp = remember(logEntry) {
707 try {
708 logEntry["p_timestamp"]?.jsonPrimitive?.content
709 ?: logEntry["datetime"]?.jsonPrimitive?.content
710 ?: ""
711 } catch (_: Exception) {
712 ""
713 }
714 }
715 val displayTimestamp = remember(rawTimestamp) {
716 if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
717 }
719 if (displayTimestamp.isNotEmpty()) {
720 Text(
721 text = displayTimestamp,
722 style = MaterialTheme.typography.labelSmall,
723 color = MaterialTheme.colorScheme.primary,
724 fontFamily = FontFamily.Monospace,
725 )
726 Spacer(modifier = Modifier.height(4.dp))
727 }
729 if (!isExpanded) {
730 // Compact view: show first meaningful fields
731 val preview = remember(logEntry) {
732 logEntry.entries
733 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
734 .take(3)
735 .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
736 }
737 Text(
738 text = preview.ifEmpty { logEntry.toString() },
739 style = MaterialTheme.typography.bodySmall,
740 fontFamily = FontFamily.Monospace,
741 maxLines = 2,
742 overflow = TextOverflow.Ellipsis,
743 fontSize = 12.sp,
744 )
745 } else {
746 // Expanded view: show all fields + copy button
747 Row(
748 modifier = Modifier.fillMaxWidth(),
749 horizontalArrangement = Arrangement.End,
750 ) {
751 IconButton(
752 onClick = {
753 val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
754 try {
755 clipboardManager.setPrimaryClip(
756 android.content.ClipData.newPlainText("Log entry", text)
757 )
758 onCopied()
759 } catch (_: SecurityException) {
760 // Some devices/custom ROMs restrict clipboard access
761 }
762 },
763 ) {
764 Icon(
765 Icons.Filled.ContentCopy,
766 contentDescription = "Copy log entry to clipboard",
767 tint = MaterialTheme.colorScheme.onSurfaceVariant,
768 )
769 }
770 }
771 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
772 logEntry.entries.forEach { (key, value) ->
773 val displayValue = formatJsonValue(value)
774 Row(
775 modifier = Modifier
776 .fillMaxWidth()
777 .padding(vertical = 2.dp),
778 verticalAlignment = Alignment.CenterVertically,
779 ) {
780 Column(modifier = Modifier.weight(1f)) {
781 Row {
782 Text(
783 text = "$key: ",
784 style = MaterialTheme.typography.bodySmall,
785 fontWeight = FontWeight.Bold,
786 fontFamily = FontFamily.Monospace,
787 color = MaterialTheme.colorScheme.primary,
788 fontSize = 12.sp,
789 )
790 Text(
791 text = displayValue,
792 style = MaterialTheme.typography.bodySmall,
793 fontFamily = FontFamily.Monospace,
794 fontSize = 12.sp,
795 )
796 }
797 }
798 // Filter include (=) button
799 IconButton(
800 onClick = { onFilterInclude(key, displayValue) },
801 modifier = Modifier.size(28.dp),
802 ) {
803 Icon(
804 Icons.Filled.AddCircle,
805 contentDescription = "Filter for $key = $displayValue",
806 modifier = Modifier.size(16.dp),
807 tint = MaterialTheme.colorScheme.primary,
808 )
809 }
810 // Filter exclude (!=) button
811 IconButton(
812 onClick = { onFilterExclude(key, displayValue) },
813 modifier = Modifier.size(28.dp),
814 ) {
815 Icon(
816 Icons.Filled.RemoveCircle,
817 contentDescription = "Filter out $key = $displayValue",
818 modifier = Modifier.size(16.dp),
819 tint = MaterialTheme.colorScheme.error,
820 )
821 }
822 }
823 }
824 }
825 }
826 }
829private fun formatJsonValue(element: JsonElement): String {
830 return when (element) {
831 is JsonPrimitive -> element.content
832 is JsonNull -> "null"
833 else -> element.toString()
834 }
837@OptIn(ExperimentalMaterial3Api::class)
838@Composable
839fun FilterBottomSheet(
840 columns: List<String>,
841 logs: List<JsonObject> = emptyList(),
842 onDismiss: () -> Unit,
843 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
844) {
845 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
846 var selectedOperator by remember { mutableStateOf("=") }
847 var filterValue by remember { mutableStateOf("") }
848 var columnDropdownExpanded by remember { mutableStateOf(false) }
849 var operatorDropdownExpanded by remember { mutableStateOf(false) }
851 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
853 // Extract distinct values for the selected column from current logs
854 val columnValues = remember(selectedColumn, logs) {
855 if (selectedColumn.isBlank() || logs.isEmpty()) {
856 emptyList()
857 } else {
858 logs.asSequence()
859 .mapNotNull { entry ->
860 try {
861 entry[selectedColumn]?.let { element ->
862 formatJsonValue(element).takeIf { it != "null" && it.isNotBlank() }
863 }
864 } catch (_: Exception) {
865 null
866 }
867 }
868 .distinct()
869 .take(50)
870 .sorted()
871 .toList()
872 }
873 }
875 ModalBottomSheet(onDismissRequest = onDismiss) {
876 Column(
877 modifier = Modifier.padding(16.dp),
878 verticalArrangement = Arrangement.spacedBy(12.dp),
879 ) {
880 Text(
881 text = "Add Filter",
882 style = MaterialTheme.typography.titleLarge,
883 fontWeight = FontWeight.Bold,
884 )
886 // Column selector
887 ExposedDropdownMenuBox(
888 expanded = columnDropdownExpanded,
889 onExpandedChange = { columnDropdownExpanded = it },
890 ) {
891 OutlinedTextField(
892 value = selectedColumn,
893 onValueChange = {},
894 readOnly = true,
895 label = { Text("Column") },
896 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
897 modifier = Modifier
898 .fillMaxWidth()
899 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
900 )
901 ExposedDropdownMenu(
902 expanded = columnDropdownExpanded,
903 onDismissRequest = { columnDropdownExpanded = false },
904 ) {
905 columns.forEach { column ->
906 DropdownMenuItem(
907 text = { Text(column) },
908 onClick = {
909 selectedColumn = column
910 filterValue = ""
911 columnDropdownExpanded = false
912 },
913 )
914 }
915 }
916 }
918 // Operator selector
919 ExposedDropdownMenuBox(
920 expanded = operatorDropdownExpanded,
921 onExpandedChange = { operatorDropdownExpanded = it },
922 ) {
923 OutlinedTextField(
924 value = selectedOperator,
925 onValueChange = {},
926 readOnly = true,
927 label = { Text("Operator") },
928 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
929 modifier = Modifier
930 .fillMaxWidth()
931 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
932 )
933 ExposedDropdownMenu(
934 expanded = operatorDropdownExpanded,
935 onDismissRequest = { operatorDropdownExpanded = false },
936 ) {
937 operators.forEach { op ->
938 DropdownMenuItem(
939 text = { Text(op) },
940 onClick = {
941 selectedOperator = op
942 operatorDropdownExpanded = false
943 },
944 )
945 }
946 }
947 }
949 // Value input (hidden for IS NULL / IS NOT NULL)
950 if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
951 val valueHint = when (selectedOperator) {
952 "LIKE", "ILIKE" -> "Pattern (% added automatically)"
953 ">", "<", ">=", "<=" -> "Numeric or date value"
954 else -> "Exact value"
955 }
956 val valueError = when {
957 filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
958 filterValue.toDoubleOrNull() == null &&
959 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
960 "Expected a number or date"
961 else -> null
962 }
963 OutlinedTextField(
964 value = filterValue,
965 onValueChange = { filterValue = it },
966 label = { Text("Value") },
967 placeholder = { Text(valueHint) },
968 modifier = Modifier.fillMaxWidth(),
969 singleLine = true,
970 isError = valueError != null,
971 supportingText = valueError?.let { { Text(it) } },
972 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
973 keyboardActions = KeyboardActions(onDone = {
974 if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
975 onApplyFilter(selectedColumn, selectedOperator, filterValue)
976 }
977 }),
978 )
980 // Show existing values as selectable suggestions
981 if (columnValues.isNotEmpty() && selectedOperator in listOf("=", "!=")) {
982 Text(
983 text = "Existing values:",
984 style = MaterialTheme.typography.labelMedium,
985 color = MaterialTheme.colorScheme.onSurfaceVariant,
986 )
987 // Filter suggestions by typed text
988 val filteredValues = remember(columnValues, filterValue) {
989 if (filterValue.isBlank()) columnValues
990 else columnValues.filter {
991 it.contains(filterValue, ignoreCase = true)
992 }
993 }
994 Row(
995 modifier = Modifier
996 .fillMaxWidth()
997 .horizontalScroll(rememberScrollState()),
998 horizontalArrangement = Arrangement.spacedBy(6.dp),
999 ) {
1000 filteredValues.forEach { value ->
1001 SuggestionChip(
1002 onClick = {
1003 onApplyFilter(selectedColumn, selectedOperator, value)
1004 },
1005 label = {
1006 Text(
1007 text = if (value.length > 40) value.take(40) + "\u2026" else value,
1008 maxLines = 1,
1009 overflow = TextOverflow.Ellipsis,
1010 fontSize = 12.sp,
1012 },
1019 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
1020 filterValue.isNotEmpty() &&
1021 filterValue.toDoubleOrNull() == null &&
1022 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
1024 Row(
1025 modifier = Modifier.fillMaxWidth(),
1026 horizontalArrangement = Arrangement.End,
1027 verticalAlignment = Alignment.CenterVertically,
1028 ) {
1029 TextButton(onClick = onDismiss) {
1030 Text("Cancel")
1032 Spacer(modifier = Modifier.width(8.dp))
1033 Button(
1034 onClick = {
1035 if (selectedColumn.isNotBlank()) {
1036 onApplyFilter(selectedColumn, selectedOperator, filterValue)
1038 },
1039 enabled = selectedColumn.isNotBlank() &&
1040 !hasValueError &&
1041 (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
1042 ) {
1043 Text("Apply")
1047 Spacer(modifier = Modifier.height(32.dp))
1052@OptIn(ExperimentalMaterial3Api::class)
1053@Composable
1054fun SqlQueryBottomSheet(
1055 streamName: String,
1056 currentSql: String,
1057 onDismiss: () -> Unit,
1058 onExecute: (String) -> Unit,
1059) {
1060 val safeName = remember(streamName) { escapeIdentifier(streamName) }
1061 var sql by remember {
1062 mutableStateOf(
1063 currentSql.ifEmpty {
1064 "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
1069 ModalBottomSheet(onDismissRequest = onDismiss) {
1070 Column(
1071 modifier = Modifier.padding(16.dp),
1072 verticalArrangement = Arrangement.spacedBy(12.dp),
1073 ) {
1074 Text(
1075 text = "SQL Query",
1076 style = MaterialTheme.typography.titleLarge,
1077 fontWeight = FontWeight.Bold,
1080 Text(
1081 text = "Use PostgreSQL-compatible SQL to query your logs.",
1082 style = MaterialTheme.typography.bodySmall,
1083 color = MaterialTheme.colorScheme.onSurfaceVariant,
1086 OutlinedTextField(
1087 value = sql,
1088 onValueChange = { sql = it },
1089 label = { Text("SQL") },
1090 modifier = Modifier
1091 .fillMaxWidth()
1092 .heightIn(min = 120.dp),
1093 textStyle = MaterialTheme.typography.bodyMedium.copy(
1094 fontFamily = FontFamily.Monospace,
1095 ),
1096 maxLines = 8,
1099 Row(
1100 modifier = Modifier.fillMaxWidth(),
1101 horizontalArrangement = Arrangement.End,
1102 ) {
1103 TextButton(onClick = onDismiss) {
1104 Text("Cancel")
1106 Spacer(modifier = Modifier.width(8.dp))
1107 Button(
1108 onClick = { onExecute(sql) },
1109 enabled = sql.isNotBlank(),
1110 ) {
1111 Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
1112 Spacer(modifier = Modifier.width(4.dp))
1113 Text("Execute")
1117 Spacer(modifier = Modifier.height(32.dp))
1122@OptIn(ExperimentalMaterial3Api::class)
1123@Composable
1124fun SavedFiltersBottomSheet(
1125 savedFiltersState: SavedFiltersState,
1126 hasActiveFilters: Boolean,
1127 onDismiss: () -> Unit,
1128 onSaveCurrentFilter: () -> Unit,
1129 onApplyFilter: (com.parseable.android.data.model.SavedFilter) -> Unit,
1130 onDeleteFilter: (String) -> Unit,
1131 onRetry: () -> Unit,
1132) {
1133 var filterToDelete by remember { mutableStateOf<String?>(null) }
1135 ModalBottomSheet(onDismissRequest = onDismiss) {
1136 Column(
1137 modifier = Modifier.padding(16.dp),
1138 verticalArrangement = Arrangement.spacedBy(12.dp),
1139 ) {
1140 Row(
1141 modifier = Modifier.fillMaxWidth(),
1142 horizontalArrangement = Arrangement.SpaceBetween,
1143 verticalAlignment = Alignment.CenterVertically,
1144 ) {
1145 Text(
1146 text = "Saved Filters",
1147 style = MaterialTheme.typography.titleLarge,
1148 fontWeight = FontWeight.Bold,
1150 if (hasActiveFilters) {
1151 FilledTonalButton(onClick = onSaveCurrentFilter) {
1152 Icon(
1153 Icons.Filled.Add,
1154 contentDescription = null,
1155 modifier = Modifier.size(18.dp),
1157 Spacer(modifier = Modifier.width(4.dp))
1158 Text("Save Current")
1163 when {
1164 savedFiltersState.isLoading -> {
1165 Box(
1166 modifier = Modifier
1167 .fillMaxWidth()
1168 .padding(32.dp),
1169 contentAlignment = Alignment.Center,
1170 ) {
1171 CircularProgressIndicator()
1174 savedFiltersState.error != null -> {
1175 Column(
1176 modifier = Modifier
1177 .fillMaxWidth()
1178 .padding(16.dp),
1179 horizontalAlignment = Alignment.CenterHorizontally,
1180 ) {
1181 Text(
1182 text = savedFiltersState.error,
1183 style = MaterialTheme.typography.bodyMedium,
1184 color = MaterialTheme.colorScheme.error,
1186 Spacer(modifier = Modifier.height(8.dp))
1187 OutlinedButton(onClick = onRetry) {
1188 Text("Retry")
1192 savedFiltersState.filters.isEmpty() -> {
1193 Box(
1194 modifier = Modifier
1195 .fillMaxWidth()
1196 .padding(32.dp),
1197 contentAlignment = Alignment.Center,
1198 ) {
1199 Column(horizontalAlignment = Alignment.CenterHorizontally) {
1200 Icon(
1201 Icons.Filled.BookmarkBorder,
1202 contentDescription = null,
1203 modifier = Modifier.size(40.dp),
1204 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1206 Spacer(modifier = Modifier.height(8.dp))
1207 Text(
1208 text = "No saved filters for this stream",
1209 style = MaterialTheme.typography.bodyMedium,
1210 color = MaterialTheme.colorScheme.onSurfaceVariant,
1212 if (hasActiveFilters) {
1213 Spacer(modifier = Modifier.height(4.dp))
1214 Text(
1215 text = "Use \"Save Current\" to save your active filters",
1216 style = MaterialTheme.typography.bodySmall,
1217 color = MaterialTheme.colorScheme.onSurfaceVariant,
1223 else -> {
1224 savedFiltersState.filters.forEach { filter ->
1225 Card(
1226 onClick = { onApplyFilter(filter) },
1227 modifier = Modifier.fillMaxWidth(),
1228 ) {
1229 Row(
1230 modifier = Modifier
1231 .fillMaxWidth()
1232 .padding(12.dp),
1233 verticalAlignment = Alignment.CenterVertically,
1234 ) {
1235 Icon(
1236 Icons.Filled.Bookmark,
1237 contentDescription = null,
1238 tint = MaterialTheme.colorScheme.primary,
1239 modifier = Modifier.size(20.dp),
1241 Spacer(modifier = Modifier.width(12.dp))
1242 Column(modifier = Modifier.weight(1f)) {
1243 Text(
1244 text = filter.filterName,
1245 style = MaterialTheme.typography.titleSmall,
1246 fontWeight = FontWeight.Medium,
1248 val description = when (filter.query.filterType) {
1249 "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1250 "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1251 else -> {
1252 val ruleCount = filter.query.filterBuilder?.rules
1253 ?.sumOf { it.rules.size } ?: 0
1254 if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1257 Text(
1258 text = description,
1259 style = MaterialTheme.typography.bodySmall,
1260 color = MaterialTheme.colorScheme.onSurfaceVariant,
1261 maxLines = 1,
1262 overflow = TextOverflow.Ellipsis,
1265 IconButton(onClick = {
1266 filterToDelete = filter.filterId
1267 }) {
1268 Icon(
1269 Icons.Filled.Delete,
1270 contentDescription = "Delete filter",
1271 tint = MaterialTheme.colorScheme.error,
1280 Spacer(modifier = Modifier.height(32.dp))
1284 // Delete confirmation dialog
1285 if (filterToDelete != null) {
1286 AlertDialog(
1287 onDismissRequest = { filterToDelete = null },
1288 title = { Text("Delete Filter") },
1289 text = { Text("Are you sure you want to delete this saved filter? This will remove it from the server.") },
1290 confirmButton = {
1291 TextButton(onClick = {
1292 filterToDelete?.let { onDeleteFilter(it) }
1293 filterToDelete = null
1294 }) {
1295 Text("Delete", color = MaterialTheme.colorScheme.error)
1297 },
1298 dismissButton = {
1299 TextButton(onClick = { filterToDelete = null }) {
1300 Text("Cancel")
1302 },
1307@Composable
1308fun SaveFilterDialog(
1309 isSaving: Boolean,
1310 onDismiss: () -> Unit,
1311 onSave: (String) -> Unit,
1312) {
1313 var filterName by remember { mutableStateOf("") }
1315 AlertDialog(
1316 onDismissRequest = onDismiss,
1317 title = { Text("Save Filter") },
1318 text = {
1319 Column {
1320 Text(
1321 text = "Save your current filters to the Parseable server. You can load them later on any device.",
1322 style = MaterialTheme.typography.bodySmall,
1323 color = MaterialTheme.colorScheme.onSurfaceVariant,
1325 Spacer(modifier = Modifier.height(12.dp))
1326 OutlinedTextField(
1327 value = filterName,
1328 onValueChange = { filterName = it },
1329 label = { Text("Filter name") },
1330 singleLine = true,
1331 modifier = Modifier.fillMaxWidth(),
1332 enabled = !isSaving,
1335 },
1336 confirmButton = {
1337 if (isSaving) {
1338 CircularProgressIndicator(
1339 modifier = Modifier
1340 .size(24.dp)
1341 .padding(end = 8.dp),
1342 strokeWidth = 2.dp,
1344 } else {
1345 TextButton(
1346 onClick = { onSave(filterName.trim()) },
1347 enabled = filterName.isNotBlank(),
1348 ) {
1349 Text("Save")
1352 },
1353 dismissButton = {
1354 TextButton(onClick = onDismiss, enabled = !isSaving) {
1355 Text("Cancel")
1357 },