feat(desktop): add embedded local relay with SQLite event persistence
Add an in-process local relay to Amethyst Desktop that persists all received events to a per-account SQLite database using quartz's existing EventStore infrastructure. On startup, the local store hydrates DesktopLocalCache for instant feed rendering before remote relays connect. - LocalRelayStore: manages per-account EventStore lifecycle, batched write-through via BasicBundledInsert (250ms window), startup hydration (contact list -> metadata -> recent content events) - LocalRelayMaintenance: periodic cleanup (NIP-40 expiration, 30-day prune, weekly VACUUM), disk space monitoring - Settings UI: integrated into RelaySettingsScreen with statistics, storage management (prune/vacuum/clear), JSONL export/import, and error display - OfflineBanner: animated banner in both SinglePaneLayout and DeckColumnContainer showing offline status with local cache indicator - Thread-safe store access via @Volatile + synchronized lock - Skips re-enqueue during hydration (checks LOCAL_RELAY_URL) - DB stored at ~/.amethyst/accounts/<pubkey8>/events.db - Corrupt DB auto-detected and recreated on open Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,8 @@ sealed class DesktopScreen {
|
|||||||
data object Drafts : DesktopScreen()
|
data object Drafts : DesktopScreen()
|
||||||
|
|
||||||
data object Settings : DesktopScreen()
|
data object Settings : DesktopScreen()
|
||||||
|
|
||||||
|
data object LocalRelaySettings : DesktopScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reference to active Tor manager for shutdown hook. Set by App composable. */
|
/** Reference to active Tor manager for shutdown hook. Set by App composable. */
|
||||||
@@ -720,6 +722,23 @@ fun App(
|
|||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||||
|
|
||||||
|
// Local relay store — persists events to SQLite per account
|
||||||
|
val localRelayStore =
|
||||||
|
remember {
|
||||||
|
com.vitorpamplona.amethyst.desktop.relay
|
||||||
|
.LocalRelayStore(scope)
|
||||||
|
}
|
||||||
|
val localRelayMaintenance =
|
||||||
|
remember {
|
||||||
|
com.vitorpamplona.amethyst.desktop.relay
|
||||||
|
.LocalRelayMaintenance(localRelayStore, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire local relay store to cache for write-through
|
||||||
|
LaunchedEffect(localRelayStore) {
|
||||||
|
localCache.localRelayStore = localRelayStore
|
||||||
|
}
|
||||||
|
|
||||||
// Build TorRelayEvaluation for per-relay routing
|
// Build TorRelayEvaluation for per-relay routing
|
||||||
val torRelayEvaluation =
|
val torRelayEvaluation =
|
||||||
remember(torSettings) {
|
remember(torSettings) {
|
||||||
@@ -795,6 +814,8 @@ fun App(
|
|||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
subscriptionsCoordinator.clear()
|
subscriptionsCoordinator.clear()
|
||||||
localCache.clear()
|
localCache.clear()
|
||||||
|
localRelayMaintenance.stop()
|
||||||
|
localRelayStore.close()
|
||||||
previousAccountPubKey = null
|
previousAccountPubKey = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,8 +825,16 @@ fun App(
|
|||||||
// Account switched — clear old data so new feed loads fresh
|
// Account switched — clear old data so new feed loads fresh
|
||||||
subscriptionsCoordinator.clear()
|
subscriptionsCoordinator.clear()
|
||||||
localCache.clear()
|
localCache.clear()
|
||||||
|
localRelayMaintenance.stop()
|
||||||
|
localRelayStore.close()
|
||||||
subscriptionsCoordinator.start()
|
subscriptionsCoordinator.start()
|
||||||
}
|
}
|
||||||
|
// Open local relay store for the current account and hydrate cache
|
||||||
|
localRelayStore.openForAccount(currentPubKey)
|
||||||
|
localRelayMaintenance.start()
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
localRelayStore.hydrate(localCache)
|
||||||
|
}
|
||||||
previousAccountPubKey = currentPubKey
|
previousAccountPubKey = currentPubKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,6 +912,8 @@ fun App(
|
|||||||
runBlocking { accountManager.disconnectNip46Client() }
|
runBlocking { accountManager.disconnectNip46Client() }
|
||||||
subscriptionsCoordinator.clear()
|
subscriptionsCoordinator.clear()
|
||||||
relayManager.disconnect()
|
relayManager.disconnect()
|
||||||
|
localRelayMaintenance.stop()
|
||||||
|
localRelayStore.close()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -901,6 +932,7 @@ fun App(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
||||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore,
|
||||||
) {
|
) {
|
||||||
when (accountState) {
|
when (accountState) {
|
||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
@@ -1686,6 +1718,19 @@ fun RelaySettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Local Relay section
|
||||||
|
val localRelay = com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore.current
|
||||||
|
if (localRelay != null) {
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.settings.LocalRelaySettingsScreen(
|
||||||
|
localRelayStore = localRelay,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
val logoutScope = rememberCoroutineScope()
|
val logoutScope = rememberCoroutineScope()
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|||||||
Vendored
+9
-1
@@ -79,6 +79,9 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
|
|
||||||
val eventStream = DesktopCacheEventStream()
|
val eventStream = DesktopCacheEventStream()
|
||||||
|
|
||||||
|
/** Local relay store for persisting events to SQLite. Set from Main.kt on account login. */
|
||||||
|
var localRelayStore: com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore? = null
|
||||||
|
|
||||||
/** Cached follow set for the logged-in user. Thread-safe + Compose-observable. */
|
/** Cached follow set for the logged-in user. Thread-safe + Compose-observable. */
|
||||||
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
|
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||||
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
|
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
|
||||||
@@ -204,7 +207,12 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
wasVerified: Boolean = false,
|
wasVerified: Boolean = false,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (!wasVerified && !justVerify(event)) return false
|
if (!wasVerified && !justVerify(event)) return false
|
||||||
return route(event, relay)
|
val consumed = route(event, relay)
|
||||||
|
// Write-through to local store, but skip if event came from local store (hydration)
|
||||||
|
if (consumed && relay != com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore.LOCAL_RELAY_URL) {
|
||||||
|
localRelayStore?.enqueue(event)
|
||||||
|
}
|
||||||
|
return consumed
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun route(
|
private fun route(
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.relay
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
|
class LocalRelayMaintenance(
|
||||||
|
private val store: LocalRelayStore,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private var maintenanceJob: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
maintenanceJob?.cancel()
|
||||||
|
maintenanceJob =
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
// Startup maintenance
|
||||||
|
try {
|
||||||
|
store.deleteExpiredEvents()
|
||||||
|
maybeVacuum()
|
||||||
|
store.checkDiskSpace()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayMaintenance") { "Startup maintenance: ${e.message}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic maintenance (every 6 hours)
|
||||||
|
while (isActive) {
|
||||||
|
delay(6 * 60 * 60 * 1000L)
|
||||||
|
try {
|
||||||
|
store.deleteExpiredEvents()
|
||||||
|
store.pruneOldEvents(maxAgeDays = 30)
|
||||||
|
store.checkDiskSpace()
|
||||||
|
store.refreshStats()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayMaintenance") { "Periodic maintenance: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun maybeVacuum() {
|
||||||
|
val prefs = Preferences.userRoot().node("amethyst/localrelay")
|
||||||
|
val lastVacuum = prefs.getLong("lastVacuum", 0)
|
||||||
|
val sevenDays = 7 * 24 * 60 * 60 * 1000L
|
||||||
|
if (System.currentTimeMillis() - lastVacuum > sevenDays) {
|
||||||
|
store.vacuum()
|
||||||
|
prefs.putLong("lastVacuum", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
maintenanceJob?.cancel()
|
||||||
|
maintenanceJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.relay
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
|
||||||
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalRelayStore(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) : AutoCloseable {
|
||||||
|
companion object {
|
||||||
|
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
|
||||||
|
|
||||||
|
private fun dbDir(pubKeyHex: String): File = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}")
|
||||||
|
|
||||||
|
fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var store: EventStore? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var currentPubKey: String? = null
|
||||||
|
|
||||||
|
private val _enabled = MutableStateFlow(true)
|
||||||
|
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _writesDisabled = MutableStateFlow(false)
|
||||||
|
val writesDisabled: StateFlow<Boolean> = _writesDisabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastError = MutableStateFlow<String?>(null)
|
||||||
|
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||||
|
|
||||||
|
private val _eventCount = MutableStateFlow(0L)
|
||||||
|
val eventCount: StateFlow<Long> = _eventCount.asStateFlow()
|
||||||
|
|
||||||
|
private val _dbSizeBytes = MutableStateFlow(0L)
|
||||||
|
val dbSizeBytes: StateFlow<Long> = _dbSizeBytes.asStateFlow()
|
||||||
|
|
||||||
|
private val writeBundler =
|
||||||
|
BasicBundledInsert<Event>(
|
||||||
|
delay = 250,
|
||||||
|
dispatcher = Dispatchers.IO,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun openForAccount(pubKeyHex: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (currentPubKey == pubKeyHex && store != null) return
|
||||||
|
close()
|
||||||
|
currentPubKey = pubKeyHex
|
||||||
|
val dir = dbDir(pubKeyHex)
|
||||||
|
dir.mkdirs()
|
||||||
|
val path = File(dir, "events.db").absolutePath
|
||||||
|
try {
|
||||||
|
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||||
|
_lastError.value = null
|
||||||
|
refreshStats()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" }
|
||||||
|
try {
|
||||||
|
deleteDbFiles(path)
|
||||||
|
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||||
|
_lastError.value = "Database was recreated: ${e.message}"
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
_lastError.value = "Cannot open local store: ${e2.message}"
|
||||||
|
Log.e("LocalRelayStore", "Failed to recreate DB", e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueue(event: Event) {
|
||||||
|
if (!_enabled.value || _writesDisabled.value) return
|
||||||
|
val s = store ?: return
|
||||||
|
writeBundler.invalidateList(event) { batch ->
|
||||||
|
try {
|
||||||
|
s.transaction {
|
||||||
|
batch.forEach { insert(it) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayStore") { "Batch insert failed: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun hydrate(cache: DesktopLocalCache) {
|
||||||
|
val s = store ?: return
|
||||||
|
val relay = LOCAL_RELAY_URL
|
||||||
|
Log.d("LocalRelayStore") { "Starting hydration..." }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Contact list (kind 3) — need follow list for filtering
|
||||||
|
val contactFilter = Filter(kinds = listOf(3), limit = 10)
|
||||||
|
s.query<Event>(contactFilter).forEach { event ->
|
||||||
|
cache.consume(event, relay, wasVerified = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Metadata (kind 0) for followed users
|
||||||
|
val followed = cache.followedUsers.value
|
||||||
|
if (followed.isNotEmpty()) {
|
||||||
|
followed.chunked(500).forEach { chunk ->
|
||||||
|
val metaFilter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(0),
|
||||||
|
authors = chunk.toList(),
|
||||||
|
limit = chunk.size,
|
||||||
|
)
|
||||||
|
s.query<Event>(metaFilter).forEach { event ->
|
||||||
|
cache.consume(event, relay, wasVerified = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Recent content events (last 7 days, max 5000)
|
||||||
|
val since = (System.currentTimeMillis() / 1000) - (7 * 24 * 3600)
|
||||||
|
val contentFilter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(1, 6, 7, 16, 1111, 9735),
|
||||||
|
since = since,
|
||||||
|
limit = 5000,
|
||||||
|
)
|
||||||
|
s.query<Event>(contentFilter).forEach { event ->
|
||||||
|
cache.consume(event, relay, wasVerified = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshStats()
|
||||||
|
Log.d("LocalRelayStore") { "Hydration complete. Events: ${_eventCount.value}" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_lastError.value = "Hydration failed: ${e.message}"
|
||||||
|
Log.w("LocalRelayStore") { "Hydration error: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteExpiredEvents() {
|
||||||
|
try {
|
||||||
|
store?.deleteExpiredEvents()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayStore") { "deleteExpiredEvents failed: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pruneOldEvents(maxAgeDays: Int = 30) {
|
||||||
|
try {
|
||||||
|
val cutoff = (System.currentTimeMillis() / 1000) - (maxAgeDays.toLong() * 24 * 3600)
|
||||||
|
store?.delete(Filter(until = cutoff))
|
||||||
|
refreshStats()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_lastError.value = "Prune failed: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun vacuum() {
|
||||||
|
try {
|
||||||
|
store?.store?.vacuum()
|
||||||
|
refreshStats()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_lastError.value = "Vacuum failed: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAll() {
|
||||||
|
val pk = currentPubKey ?: return
|
||||||
|
close()
|
||||||
|
val path = dbFile(pk).absolutePath
|
||||||
|
deleteDbFiles(path)
|
||||||
|
openForAccount(pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(value: Boolean) {
|
||||||
|
_enabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableWrites() {
|
||||||
|
_writesDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableWrites() {
|
||||||
|
_writesDisabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_lastError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshStats() {
|
||||||
|
val pk = currentPubKey ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
_eventCount.value = store?.count(Filter())?.toLong() ?: 0
|
||||||
|
_dbSizeBytes.value = dbFile(pk).length()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayStore") { "Stats refresh failed: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkDiskSpace(): Boolean {
|
||||||
|
val pk = currentPubKey ?: return true
|
||||||
|
val usable = dbFile(pk).parentFile?.usableSpace ?: return true
|
||||||
|
val low = usable < 100 * 1024 * 1024 // < 100MB
|
||||||
|
if (low && !_writesDisabled.value) {
|
||||||
|
disableWrites()
|
||||||
|
_lastError.value = "Disk space low (${usable / 1024 / 1024}MB). Writes disabled."
|
||||||
|
}
|
||||||
|
return !low
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportEvents(outputFile: File) {
|
||||||
|
val s = store ?: return
|
||||||
|
outputFile.bufferedWriter().use { writer ->
|
||||||
|
s.query<Event>(Filter()) { event ->
|
||||||
|
writer.write(event.toJson())
|
||||||
|
writer.newLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importEvents(inputFile: File) {
|
||||||
|
val s = store ?: return
|
||||||
|
val lines = inputFile.readLines()
|
||||||
|
s.transaction {
|
||||||
|
lines.forEach { line ->
|
||||||
|
if (line.isNotBlank()) {
|
||||||
|
try {
|
||||||
|
val event = Event.fromJson(line)
|
||||||
|
insert(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayStore") { "Import skip: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
synchronized(lock) {
|
||||||
|
try {
|
||||||
|
store?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalRelayStore") { "Close error: ${e.message}" }
|
||||||
|
}
|
||||||
|
store = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDbFiles(path: String) {
|
||||||
|
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
|
||||||
|
File(path + suffix).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OfflineBanner(
|
||||||
|
connectedRelayCount: Int,
|
||||||
|
hasLocalData: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = connectedRelayCount == 0,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
val containerColor =
|
||||||
|
if (hasLocalData) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
}
|
||||||
|
val contentColor =
|
||||||
|
if (hasLocalData) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol =
|
||||||
|
if (hasLocalData) {
|
||||||
|
MaterialSymbols.CloudSync
|
||||||
|
} else {
|
||||||
|
MaterialSymbols.Warning
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = contentColor,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (hasLocalData) {
|
||||||
|
"Offline \u2014 showing cached events"
|
||||||
|
} else {
|
||||||
|
"Offline \u2014 no cached events available"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -131,6 +131,21 @@ fun DeckColumnContainer(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Offline banner — shows when no remote relays connected
|
||||||
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
|
val localRelay = LocalLocalRelayStore.current
|
||||||
|
val hasLocalData =
|
||||||
|
if (localRelay != null) {
|
||||||
|
val count by localRelay.eventCount.collectAsState()
|
||||||
|
count > 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.components.OfflineBanner(
|
||||||
|
connectedRelayCount = connectedRelays.size,
|
||||||
|
hasLocalData = hasLocalData,
|
||||||
|
)
|
||||||
|
|
||||||
// Content runs edge-to-edge; each screen adds its own header padding
|
// Content runs edge-to-edge; each screen adds its own header padding
|
||||||
// to match the Messages pattern (padding(horizontal = 12, vertical = 8)
|
// to match the Messages pattern (padding(horizontal = 12, vertical = 8)
|
||||||
// on the title row, no outer wrapper).
|
// on the title row, no outer wrapper).
|
||||||
|
|||||||
+5
@@ -80,3 +80,8 @@ val LocalRelayManager =
|
|||||||
compositionLocalOf<DesktopRelayConnectionManager?> {
|
compositionLocalOf<DesktopRelayConnectionManager?> {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val LocalLocalRelayStore =
|
||||||
|
compositionLocalOf<com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore?> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|||||||
+15
@@ -265,6 +265,21 @@ fun SinglePaneLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
// Offline banner — shows when no remote relays connected
|
||||||
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
|
val localRelay = LocalLocalRelayStore.current
|
||||||
|
val hasLocalData =
|
||||||
|
if (localRelay != null) {
|
||||||
|
val count by localRelay.eventCount.collectAsState()
|
||||||
|
count > 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.components.OfflineBanner(
|
||||||
|
connectedRelayCount = connectedRelays.size,
|
||||||
|
hasLocalData = hasLocalData,
|
||||||
|
)
|
||||||
|
|
||||||
// Content extends to the window edges; individual screens add their
|
// Content extends to the window edges; individual screens add their
|
||||||
// own internal padding where appropriate (Messages uses full-bleed
|
// own internal padding where appropriate (Messages uses full-bleed
|
||||||
// panes to match native two-column chat apps).
|
// panes to match native two-column chat apps).
|
||||||
|
|||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocalRelaySettingsScreen(
|
||||||
|
localRelayStore: LocalRelayStore,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val enabled by localRelayStore.enabled.collectAsState()
|
||||||
|
val writesDisabled by localRelayStore.writesDisabled.collectAsState()
|
||||||
|
val lastError by localRelayStore.lastError.collectAsState()
|
||||||
|
val eventCount by localRelayStore.eventCount.collectAsState()
|
||||||
|
val dbSizeBytes by localRelayStore.dbSizeBytes.collectAsState()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Local Relay",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Status section
|
||||||
|
StatusSection(
|
||||||
|
enabled = enabled,
|
||||||
|
writesDisabled = writesDisabled,
|
||||||
|
onToggle = { localRelayStore.setEnabled(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Disk full warning
|
||||||
|
DiskFullWarning(
|
||||||
|
writesDisabled = writesDisabled,
|
||||||
|
onPrune = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
localRelayStore.pruneOldEvents()
|
||||||
|
localRelayStore.enableWrites()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClear = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
localRelayStore.clearAll()
|
||||||
|
localRelayStore.enableWrites()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Statistics section
|
||||||
|
CollapsibleSection(title = "Statistics", initiallyExpanded = true) {
|
||||||
|
StatisticsContent(eventCount = eventCount, dbSizeBytes = dbSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Storage management
|
||||||
|
CollapsibleSection(title = "Storage", initiallyExpanded = false) {
|
||||||
|
StorageContent(
|
||||||
|
onPrune = { scope.launch(Dispatchers.IO) { localRelayStore.pruneOldEvents() } },
|
||||||
|
onVacuum = { scope.launch(Dispatchers.IO) { localRelayStore.vacuum() } },
|
||||||
|
onClearAll = { scope.launch(Dispatchers.IO) { localRelayStore.clearAll() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Export / Import
|
||||||
|
CollapsibleSection(title = "Export / Import", initiallyExpanded = false) {
|
||||||
|
ExportImportContent(
|
||||||
|
onExport = { file -> scope.launch(Dispatchers.IO) { localRelayStore.exportEvents(file) } },
|
||||||
|
onImport = { file -> scope.launch(Dispatchers.IO) { localRelayStore.importEvents(file) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Errors section
|
||||||
|
CollapsibleSection(title = "Errors", initiallyExpanded = false) {
|
||||||
|
ErrorsContent(lastError = lastError, onClear = { localRelayStore.clearError() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusSection(
|
||||||
|
enabled: Boolean,
|
||||||
|
writesDisabled: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol =
|
||||||
|
if (enabled && !writesDisabled) {
|
||||||
|
MaterialSymbols.CheckCircle
|
||||||
|
} else {
|
||||||
|
MaterialSymbols.Storage
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint =
|
||||||
|
if (enabled && !writesDisabled) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = "Local Event Cache", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
when {
|
||||||
|
!enabled -> "Disabled"
|
||||||
|
writesDisabled -> "Active (writes paused)"
|
||||||
|
else -> "Active"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = enabled, onCheckedChange = onToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DiskFullWarning(
|
||||||
|
writesDisabled: Boolean,
|
||||||
|
onPrune: () -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = writesDisabled,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Writes paused \u2014 disk space low or error occurred",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = onPrune) { Text("Prune Old Events") }
|
||||||
|
OutlinedButton(onClick = onClear) { Text("Clear Cache") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatisticsContent(
|
||||||
|
eventCount: Long,
|
||||||
|
dbSizeBytes: Long,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(start = 8.dp, top = 8.dp)) {
|
||||||
|
StatRow("Total events", "$eventCount")
|
||||||
|
StatRow("Database size", formatBytes(dbSizeBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(text = value, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StorageContent(
|
||||||
|
onPrune: () -> Unit,
|
||||||
|
onVacuum: () -> Unit,
|
||||||
|
onClearAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = onPrune, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Prune events older than 30 days")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onVacuum, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Reclaim disk space (VACUUM)")
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
Button(
|
||||||
|
onClick = onClearAll,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
|
) {
|
||||||
|
Icon(symbol = MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Clear all cached events")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExportImportContent(
|
||||||
|
onExport: (File) -> Unit,
|
||||||
|
onImport: (File) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Export Events", FileDialog.SAVE)
|
||||||
|
dialog.file = "events.jsonl"
|
||||||
|
dialog.isVisible = true
|
||||||
|
val dir = dialog.directory
|
||||||
|
val file = dialog.file
|
||||||
|
if (dir != null && file != null) onExport(File(dir, file))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(symbol = MaterialSymbols.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Export events (JSONL)")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Import Events", FileDialog.LOAD)
|
||||||
|
dialog.isVisible = true
|
||||||
|
val dir = dialog.directory
|
||||||
|
val file = dialog.file
|
||||||
|
if (dir != null && file != null) onImport(File(dir, file))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(symbol = MaterialSymbols.Upload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Import events (JSONL)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorsContent(
|
||||||
|
lastError: String?,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(start = 8.dp, top = 8.dp)) {
|
||||||
|
if (lastError != null) {
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
|
||||||
|
Text(
|
||||||
|
text = lastError,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedButton(onClick = onClear) { Text("Clear errors") }
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "No errors",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CollapsibleSection(
|
||||||
|
title: String,
|
||||||
|
initiallyExpanded: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(initiallyExpanded) }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBytes(bytes: Long): String =
|
||||||
|
when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||||
|
bytes < 1024 * 1024 * 1024 -> "${"%.1f".format(bytes / (1024.0 * 1024))} MB"
|
||||||
|
else -> "${"%.2f".format(bytes / (1024.0 * 1024 * 1024))} GB"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user