feat: Implement comprehensive log filtering feature with severity, time range, and tag options

This commit is contained in:
2025-10-24 22:43:18 +02:00
parent eede176e5e
commit 95b839c791
8 changed files with 655 additions and 117 deletions
@@ -24,7 +24,11 @@ class SyncRepository(context: Context) {
suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val lastSync = getLastSyncTimestamp()
Logger.d("Sync", "Starting sync with lastSyncTimestamp: $lastSync")
val isInitialSync = lastSync == 0L
if (isInitialSync) {
Logger.i("Sync", "🔄 Starting initial sync with server")
}
// Gather local changes since last sync
val localLists = database.listDao().getListsUpdatedSince(lastSync)
@@ -32,11 +36,19 @@ class SyncRepository(context: Context) {
val localItems = database.itemDao().getItemsUpdatedSince(lastSync)
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync)
Logger.i("Sync", "Sending to server: ${localLists.size} lists, ${localFields.size} fields, ${localItems.size} items")
// Log details of lists being sent
// Only log when sending data
if (localLists.isNotEmpty() || localFields.isNotEmpty() || localItems.isNotEmpty()) {
Logger.i("Sync", "⬆️ Sending to server:")
if (localLists.isNotEmpty()) {
Logger.i("Sync", " 📋 ${localLists.size} list(s)")
localLists.forEach { list ->
Logger.d("Sync", " List: ${list.id} - ${list.name} (updated: ${list.updatedAt})")
val action = if (list.isDeleted) "Deleted" else "Updated"
Logger.i("Sync", " $action: ${list.name}")
}
}
if (localFields.isNotEmpty()) Logger.i("Sync", " 🏷️ ${localFields.size} field(s)")
if (localItems.isNotEmpty()) Logger.i("Sync", " 📝 ${localItems.size} item(s)")
if (localValues.isNotEmpty()) Logger.i("Sync", " 💾 ${localValues.size} value(s)")
}
// Send to server and get updates
@@ -53,44 +65,43 @@ class SyncRepository(context: Context) {
if (response.isSuccessful) {
val syncResponse = response.body()!!
Logger.i("Sync", "Received from server: ${syncResponse.lists.size} lists, ${syncResponse.fields.size} fields, ${syncResponse.items.size} items")
// Log details of lists being received
// Only log when receiving data
if (syncResponse.lists.isNotEmpty() || syncResponse.fields.isNotEmpty() ||
syncResponse.items.isNotEmpty() || syncResponse.itemValues.isNotEmpty()) {
Logger.i("Sync", "⬇️ Received from server:")
if (syncResponse.lists.isNotEmpty()) {
Logger.i("Sync", " 📋 ${syncResponse.lists.size} list(s)")
syncResponse.lists.forEach { list ->
Logger.d("Sync", " Received list: ${list.id} - ${list.name} (updated: ${list.updatedAt}, deleted: ${list.isDeleted})")
if (!list.isDeleted) {
Logger.i("Sync", " ${list.name}")
}
}
}
if (syncResponse.fields.isNotEmpty()) Logger.i("Sync", " 🏷️ ${syncResponse.fields.size} field(s)")
if (syncResponse.items.isNotEmpty()) Logger.i("Sync", " 📝 ${syncResponse.items.size} item(s)")
if (syncResponse.itemValues.isNotEmpty()) Logger.i("Sync", " 💾 ${syncResponse.itemValues.size} value(s)")
}
// Apply server changes to local database
Logger.d("Sync", "Inserting ${syncResponse.lists.size} lists into local database")
database.listDao().insertLists(syncResponse.lists)
Logger.d("Sync", "Inserting ${syncResponse.fields.size} fields into local database")
database.fieldDao().insertFields(syncResponse.fields)
Logger.d("Sync", "Inserting ${syncResponse.items.size} items into local database")
database.itemDao().insertItems(syncResponse.items)
Logger.d("Sync", "Inserting ${syncResponse.itemValues.size} item values into local database")
database.itemValueDao().insertValues(syncResponse.itemValues)
// Verify what was actually saved
val savedLists = database.listDao().getListsUpdatedSince(0)
Logger.d("Sync", "After insert, total lists in DB: ${savedLists.size}")
savedLists.forEach { list ->
Logger.d("Sync", " DB list: ${list.id} - ${list.name} (deleted: ${list.isDeleted})")
}
// Update last sync timestamp
setLastSyncTimestamp(syncResponse.serverTimestamp)
Logger.i("Sync", "Sync completed successfully. Timestamp updated to: ${syncResponse.serverTimestamp}")
if (isInitialSync) {
Logger.i("Sync", "✅ Initial sync completed")
}
return@withContext Result.success(Unit)
} else {
Logger.e("Sync", "Sync failed with code: ${response.code()}, message: ${response.message()}")
Logger.e("Sync", "Sync failed: HTTP ${response.code()}")
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
}
} catch (e: Exception) {
Logger.e("Sync", "Sync error", e)
Logger.e("Sync", "Sync error: ${e.message}")
return@withContext Result.failure(e)
}
}
@@ -71,24 +71,47 @@ fun ListsScreen(
}
}
) { padding ->
if (lists.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
.padding(padding)
) {
if (isLoading && lists.isEmpty()) {
// Show loading indicator during initial sync
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = "Syncing with server...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else if (lists.isEmpty()) {
// Show empty state
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.no_lists),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Tap + to create your first list",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// Show lists
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -103,6 +126,7 @@ fun ListsScreen(
}
}
}
}
if (showCreateDialog) {
CreateListDialog(
@@ -27,9 +27,11 @@ class ListsViewModel(
init {
loadLists()
// Do initial sync immediately, then start periodic sync
// Perform initial sync immediately on startup, then start periodic sync
viewModelScope.launch {
performSync() // Initial sync on startup
_isLoading.value = true
performSync()
_isLoading.value = false
startPeriodicSync()
}
}
@@ -50,12 +52,15 @@ class ListsViewModel(
}
private suspend fun performSync() {
syncRepository.performSync()
val result = syncRepository.performSync()
result.onFailure { error ->
Logger.e("Lists", "❌ Sync failed: ${error.message}")
}
}
fun createList(name: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Creating list: $name")
Logger.i("Lists", " Creating list: \"$name\"")
val timestamp = System.currentTimeMillis()
val newList = CollabList(
id = UUID.randomUUID().toString(),
@@ -71,9 +76,9 @@ class ListsViewModel(
fun renameList(listId: String, newName: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Renaming list: $listId to $newName")
val list = database.listDao().getListById(listId)
if (list != null && newName.isNotBlank()) {
Logger.i("Lists", "✏️ Renaming: \"${list.name}\"\"$newName\"")
database.listDao().updateList(
list.copy(
name = newName.trim(),
@@ -88,16 +93,19 @@ class ListsViewModel(
fun deleteList(listId: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Deleting list: $listId")
val list = database.listDao().getListById(listId)
if (list != null) {
Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"")
database.listDao().softDeleteList(listId, System.currentTimeMillis())
// Sync immediately after deleting
performSync()
}
}
}
fun manualSync() {
viewModelScope.launch {
Logger.i("ListsViewModel", "Manual sync triggered")
Logger.i("Lists", "🔄 Manual sync requested")
_isLoading.value = true
performSync()
_isLoading.value = false
@@ -3,22 +3,47 @@ package com.collabtable.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.collabtable.app.utils.LogEntry
import com.collabtable.app.utils.LogLevel
import com.collabtable.app.utils.Logger
enum class TimeRange(val label: String, val milliseconds: Long) {
LAST_10_SECONDS("Last 10 seconds", 10_000),
LAST_30_SECONDS("Last 30 seconds", 30_000),
LAST_MINUTE("Last minute", 60_000),
LAST_5_MINUTES("Last 5 minutes", 300_000),
LAST_15_MINUTES("Last 15 minutes", 900_000),
LAST_HOUR("Last hour", 3_600_000),
LAST_24_HOURS("Last 24 hours", 86_400_000),
ALL_TIME("All time", Long.MAX_VALUE)
}
data class LogFilters(
val severities: Set<LogLevel> = LogLevel.values().toSet(),
val timeRange: TimeRange = TimeRange.ALL_TIME,
val tags: Set<String> = emptySet(),
val searchText: String = ""
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsScreen(
@@ -26,24 +51,59 @@ fun LogsScreen(
) {
val logs by Logger.logs.collectAsState()
val listState = rememberLazyListState()
var showFilterSheet by remember { mutableStateOf(false) }
var filters by remember { mutableStateOf(LogFilters()) }
// Extract all unique tags from logs
val allTags = remember(logs) {
logs.map { it.tag }.distinct().sorted()
}
// Apply filters
val filteredLogs = remember(logs, filters) {
val now = System.currentTimeMillis()
val cutoffTime = now - filters.timeRange.milliseconds
logs.filter { log ->
// Severity filter
log.level in filters.severities &&
// Time range filter
log.timestamp >= cutoffTime &&
// Tag filter (empty means show all)
(filters.tags.isEmpty() || log.tag in filters.tags) &&
// Search text filter
(filters.searchText.isEmpty() ||
log.message.contains(filters.searchText, ignoreCase = true) ||
log.tag.contains(filters.searchText, ignoreCase = true))
}
}
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
LaunchedEffect(filteredLogs.size) {
if (filteredLogs.isNotEmpty()) {
listState.animateScrollToItem(filteredLogs.size - 1)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Logs") },
title = { Text("Logs (${filteredLogs.size}/${logs.size})") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showFilterSheet = true }) {
Badge(
containerColor = if (hasActiveFilters(filters))
MaterialTheme.colorScheme.error
else Color.Transparent
) {
Icon(Icons.Default.FilterList, contentDescription = "Filter logs")
}
}
IconButton(onClick = { Logger.clear() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear logs")
}
@@ -55,15 +115,87 @@ fun LogsScreen(
)
}
) { padding ->
if (logs.isEmpty()) {
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = androidx.compose.ui.Alignment.Center
.padding(padding)
) {
// Active filters chips
if (hasActiveFilters(filters)) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Severity filters
if (filters.severities.size < LogLevel.values().size) {
items(filters.severities.toList()) { level ->
FilterChip(
selected = true,
onClick = {
filters = filters.copy(
severities = filters.severities - level
)
},
label = { Text(level.name, fontSize = 12.sp) }
)
}
}
// Time range filter
if (filters.timeRange != TimeRange.ALL_TIME) {
item {
FilterChip(
selected = true,
onClick = {
filters = filters.copy(timeRange = TimeRange.ALL_TIME)
},
label = { Text(filters.timeRange.label, fontSize = 12.sp) }
)
}
}
// Tag filters
items(filters.tags.toList()) { tag ->
FilterChip(
selected = true,
onClick = {
filters = filters.copy(tags = filters.tags - tag)
},
label = { Text(tag, fontSize = 12.sp) }
)
}
// Clear all button
item {
FilterChip(
selected = false,
onClick = {
filters = LogFilters()
},
label = { Text("Clear All", fontSize = 12.sp) },
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
// Logs list
if (filteredLogs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No logs yet",
text = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -73,16 +205,211 @@ fun LogsScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp)
) {
items(logs) { log ->
items(filteredLogs) { log ->
LogItem(log)
}
}
}
}
}
// Filter bottom sheet
if (showFilterSheet) {
FilterBottomSheet(
filters = filters,
allTags = allTags,
onFiltersChanged = { filters = it },
onDismiss = { showFilterSheet = false }
)
}
}
private fun hasActiveFilters(filters: LogFilters): Boolean {
return filters.severities.size < LogLevel.values().size ||
filters.timeRange != TimeRange.ALL_TIME ||
filters.tags.isNotEmpty() ||
filters.searchText.isNotEmpty()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
filters: LogFilters,
allTags: List<String>,
onFiltersChanged: (LogFilters) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(bottom = 32.dp)
) {
Text(
text = "Filter Logs",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
// Severity filters
Text(
text = "Severity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
LogLevel.values().forEach { level ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = level in filters.severities,
onCheckedChange = { checked ->
onFiltersChanged(
filters.copy(
severities = if (checked) {
filters.severities + level
} else {
filters.severities - level
}
)
)
}
)
Text(
text = level.name,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Time range filter
Text(
text = "Time Range",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
TimeRange.values().forEach { range ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = filters.timeRange == range,
onClick = {
onFiltersChanged(filters.copy(timeRange = range))
}
)
Text(
text = range.label,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Tag filters
Text(
text = "Tags (${filters.tags.size} selected)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
if (allTags.isEmpty()) {
Text(
text = "No tags available",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
onClick = {
onFiltersChanged(filters.copy(tags = allTags.toSet()))
}
) {
Text("Select All")
}
TextButton(
onClick = {
onFiltersChanged(filters.copy(tags = emptySet()))
}
) {
Text("Clear All")
}
}
allTags.forEach { tag ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = tag in filters.tags,
onCheckedChange = { checked ->
onFiltersChanged(
filters.copy(
tags = if (checked) {
filters.tags + tag
} else {
filters.tags - tag
}
)
)
}
)
Text(
text = tag,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Reset button
Button(
onClick = {
onFiltersChanged(LogFilters())
},
modifier = Modifier.fillMaxWidth()
) {
Text("Reset All Filters")
}
}
}
}
@Composable
+3 -7
View File
@@ -32,12 +32,8 @@
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:kspDebugKotlin
> Task :app:compileDebugKotlin
w: file:///C:/Users/gabri/VSCode%20Projects/CollabTable/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt:1336:72 Elvis operator (?:) always returns the left operand of non-nullable type String
w: file:///C:/Users/gabri/VSCode%20Projects/CollabTable/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt:2126:28 The expression is unused
> Task :app:compileDebugJavaWithJavac
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
@@ -53,5 +49,5 @@ You can use '--warning-mode all' to show the individual deprecation warnings and
For more on this, please refer to https://docs.gradle.org/8.10/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
BUILD SUCCESSFUL in 38s
36 actionable tasks: 7 executed, 29 up-to-date
BUILD SUCCESSFUL in 10s
36 actionable tasks: 6 executed, 30 up-to-date
+43 -15
View File
@@ -25,13 +25,20 @@ let syncStats = {
lastReset: Date.now()
};
// Print stats every 30 seconds
// Print stats every 60 seconds (reduced spam)
setInterval(() => {
if (syncStats.totalSyncs > 0) {
const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000);
console.log(`\n[STATS] Last ${elapsed}s: ${syncStats.totalSyncs} syncs`);
console.log(` Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
console.log(` Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values\n`);
console.log(`\n[STATS] Sync Summary (last ${elapsed}s):`);
console.log(` ${syncStats.totalSyncs} sync requests processed`);
if (syncStats.listsReceived > 0 || syncStats.fieldsReceived > 0 || syncStats.itemsReceived > 0 || syncStats.valuesReceived > 0) {
console.log(` [IN] Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
}
if (syncStats.listsSent > 0 || syncStats.fieldsSent > 0 || syncStats.itemsSent > 0 || syncStats.valuesSent > 0) {
console.log(` [OUT] Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values`);
}
// Reset stats
syncStats = {
@@ -47,7 +54,7 @@ setInterval(() => {
lastReset: Date.now()
};
}
}, 30000);
}, 60000); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization)
function getUpsertStatements() {
@@ -104,16 +111,36 @@ router.post('/sync', async (req: Request, res: Response) => {
syncStats.itemsReceived += incomingItems;
syncStats.valuesReceived += incomingValues;
// Log only when there's actual data being synced
// Only log significant sync events (not empty syncs)
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
const isInitialSync = lastSyncTimestamp === 0;
if (hasIncomingData) {
console.log(`[SYNC] Received: ${incomingLists} lists, ${incomingFields} fields, ${incomingItems} items, ${incomingValues} values`);
// Log list details
if (lists && lists.length > 0) {
const timestamp = new Date().toLocaleTimeString();
console.log(`\n[SYNC] [${timestamp}] Receiving data from client`);
if (incomingLists > 0) {
console.log(` [LISTS] ${incomingLists} list(s)`);
lists.forEach(list => {
console.log(` List: ${list.id} - "${list.name}" (updated: ${list.updatedAt}, deleted: ${list.isDeleted})`);
const action = list.isDeleted ? 'Deleted' : (lastSyncTimestamp === 0 ? 'Created' : 'Updated');
console.log(` ${action}: "${list.name}"`);
});
}
if (incomingFields > 0) {
console.log(` [FIELDS] ${incomingFields} field(s)`);
}
if (incomingItems > 0) {
console.log(` [ITEMS] ${incomingItems} item(s)`);
}
if (incomingValues > 0) {
console.log(` [VALUES] ${incomingValues} value(s)`);
}
} else if (isInitialSync) {
const timestamp = new Date().toLocaleTimeString();
console.log(`\n[SYNC] [${timestamp}] Initial sync from new client`);
}
// Save incoming data from client using a transaction
@@ -130,7 +157,6 @@ router.post('/sync', async (req: Request, res: Response) => {
updatedAt: list.updatedAt,
isDeleted: list.isDeleted ? 1 : 0
});
console.log(` Saved list ${list.id}`);
}
}
@@ -210,10 +236,12 @@ router.post('/sync', async (req: Request, res: Response) => {
syncStats.itemsSent += (serverItems as any[]).length;
syncStats.valuesSent += (serverItemValues as any[]).length;
// Log only when sending data back
// Only log when sending significant data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData) {
console.log(`[SYNC] Sending: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
if (hasOutgoingData && !isInitialSync) {
console.log(` [OUT] Sending back: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
} else if (isInitialSync && hasOutgoingData) {
console.log(` [OUT] Sending initial data: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
}
res.json({
@@ -224,7 +252,7 @@ router.post('/sync', async (req: Request, res: Response) => {
serverTimestamp
});
} catch (error) {
console.error('[SYNC] Error:', error);
console.error('[ERROR] Sync error:', error);
res.status(500).json({ error: 'Sync failed' });
}
});
+11 -2
View File
@@ -189,7 +189,10 @@ router.get('/', (req: Request, res: Response) => {
<script>
async function loadData() {
try {
const response = await fetch('/web/data');
// Add cache-busting timestamp to URL
const response = await fetch('/web/data?_=' + Date.now(), {
cache: 'no-store'
});
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
@@ -298,6 +301,11 @@ router.get('/', (req: Request, res: Response) => {
// API endpoint to get all data (no auth required for web UI)
router.get('/web/data', async (req: Request, res: Response) => {
try {
// Add cache control headers to prevent stale data
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all();
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all();
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all();
@@ -318,7 +326,8 @@ router.get('/web/data', async (req: Request, res: Response) => {
fields: formattedFields.length,
items: formattedItems.length,
values: (values as any[]).length
}
},
timestamp: Date.now() // Add timestamp for debugging
});
} catch (error) {
console.error('Error fetching data:', error);
+135
View File
@@ -0,0 +1,135 @@
# Logs Filter Feature
## Overview
Added comprehensive filtering capabilities to the Android app's logs screen, allowing users to filter logs by severity, time range, and tags.
## Features
### 1. **Filter Button**
- Located in the top app bar next to the clear button
- Shows a red badge indicator when filters are active
- Opens a bottom sheet with all filter options
### 2. **Filter Options**
#### **Severity Filtering**
Filter by log level:
- ✅ DEBUG
- ✅ INFO
- ✅ WARN
- ✅ ERROR
Users can select multiple severity levels or deselect to hide certain log types.
#### **Time Range Filtering**
Filter logs by recency:
- Last 10 seconds
- Last 30 seconds
- Last minute
- Last 5 minutes
- Last 15 minutes
- Last hour
- Last 24 hours
- All time (default)
#### **Tag Filtering**
- Dynamically shows all unique tags from current logs
- Multi-select checkboxes for each tag
- "Select All" and "Clear All" buttons for convenience
- Only shows logs from selected tags (empty = show all)
### 3. **Active Filter Chips**
- Horizontal scrollable row below the app bar
- Shows all currently active filters as chips
- Tap any chip to remove that filter
- "Clear All" chip to reset all filters at once
- Only visible when filters are active
### 4. **Log Counter**
- Top bar shows: "Logs (filtered count/total count)"
- Example: "Logs (15/234)" means 15 logs match current filters out of 234 total
### 5. **Smart Empty States**
- "No logs yet" - when no logs exist
- "No logs match filters" - when logs exist but none match filters
## Implementation Details
### New Components
**TimeRange enum**
```kotlin
enum class TimeRange(val label: String, val milliseconds: Long) {
LAST_10_SECONDS("Last 10 seconds", 10_000),
LAST_30_SECONDS("Last 30 seconds", 30_000),
// ... etc
}
```
**LogFilters data class**
```kotlin
data class LogFilters(
val severities: Set<LogLevel> = LogLevel.values().toSet(),
val timeRange: TimeRange = TimeRange.ALL_TIME,
val tags: Set<String> = emptySet(),
val searchText: String = ""
)
```
**FilterBottomSheet composable**
- Modal bottom sheet with scrollable content
- Checkboxes for severities and tags
- Radio buttons for time range selection
- Reset button to clear all filters
### Filter Logic
Filters are applied in real-time using `remember()` with dependencies:
```kotlin
val filteredLogs = remember(logs, filters) {
val now = System.currentTimeMillis()
val cutoffTime = now - filters.timeRange.milliseconds
logs.filter { log ->
log.level in filters.severities &&
log.timestamp >= cutoffTime &&
(filters.tags.isEmpty() || log.tag in filters.tags) &&
(filters.searchText.isEmpty() || /* search logic */)
}
}
```
### Auto-scroll Behavior
- Automatically scrolls to the latest log when new logs arrive
- Works with filtered logs, not just all logs
## User Experience
1. **Opening filters**: Tap filter icon in top bar
2. **Applying filters**: Check/uncheck options in bottom sheet
3. **Viewing active filters**: See chips below app bar
4. **Quick removal**: Tap any filter chip to remove it
5. **Reset all**: Tap "Clear All" chip or "Reset All Filters" button
6. **Dismissing sheet**: Tap outside or swipe down
## Benefits
- **Debugging**: Quickly isolate ERROR/WARN logs
- **Performance**: Focus on specific time windows
- **Organization**: Filter by component/tag
- **Visibility**: Clear indication of active filters
- **Efficiency**: Persistent filters across log updates
- **UX**: Smooth animations and Material Design 3
## Technical Notes
- Uses Jetpack Compose with Material3
- State management with `remember` and `mutableStateOf`
- Filter state persists during screen lifetime
- Efficient filtering with Kotlin collections
- Responsive to real-time log updates
## Files Modified
- `LogsScreen.kt`: Complete rewrite with filter UI and logic
- Build verified successfully on both Android and Server