feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
Android: a gated DiagnosticsReporter taps connectivity, server-health, UPnP drops/player-state/route, power (Doze/battery-opt/screen), and app fg/bg, plus a heartbeat snapshotting Sonos-vs-local position — the locked-phone desync signal. Events buffer in a Room ring buffer (deliberately NOT the MutationQueue: high-volume best-effort telemetry that must survive the dead zone being debugged) and DiagnosticsUploader drains them on a tick / health-recovery / sign-in. Gating: the account flag (users.debug_mode_enabled) reaches the device via a new /api/me refresh in AuthController; a per-device local OFF switch lives in Settings. Reporter runs only when enabled && !optOut; disabling drops the unsent buffer. Web admin: /admin/diagnostics — pick account+device+kind+time-window, see a chronological timeline, flip an account's debug mode remotely, and Copy-JSON / Download-NDJSON the slice for analysis. Room schema 6→7 (new diagnostic_events table + auth_session.diagnosticsOptOut; pre-v1 destructive fallback). Refs Scribe M9 (#119), tasks #1174 #1175 #1176 #1177. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
This commit is contained in:
@@ -10,6 +10,8 @@ import com.fabledsword.minstrel.cache.CacheIndexer
|
|||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.diagnostics.DiagnosticsReporter
|
||||||
|
import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
@@ -155,6 +157,11 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||||
|
|
||||||
|
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||||
|
// debug flag; the uploader drains its buffer on a tick / recovery.
|
||||||
|
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
||||||
|
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
||||||
|
|
||||||
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for device diagnostics ingest (M9). One call
|
||||||
|
* uploads a batch of buffered events. The server stores them only when
|
||||||
|
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
|
||||||
|
* which the uploader treats as success so it can drop the batch).
|
||||||
|
*/
|
||||||
|
interface DiagnosticsApi {
|
||||||
|
@POST("api/diagnostics")
|
||||||
|
suspend fun report(@Body body: DiagnosticsReportRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiagnosticsReportRequest(
|
||||||
|
@SerialName("client_id") val clientId: String,
|
||||||
|
@SerialName("app_version") val appVersion: String? = null,
|
||||||
|
@SerialName("os_version") val osVersion: String? = null,
|
||||||
|
val events: List<DiagnosticEventWire>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiagnosticEventWire(
|
||||||
|
val kind: String,
|
||||||
|
// Device-clock epoch milliseconds. The server stamps its own
|
||||||
|
// received_at; both are stored so a skewed device clock is visible.
|
||||||
|
@SerialName("occurred_at") val occurredAt: Long,
|
||||||
|
// Opaque structured payload carrying the event sub-type + fields.
|
||||||
|
val payload: JsonElement,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.auth
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.UserRef
|
import com.fabledsword.minstrel.models.UserRef
|
||||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||||
@@ -35,6 +36,7 @@ class AuthController @Inject constructor(
|
|||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: AuthApi = retrofit.create()
|
private val api: AuthApi = retrofit.create()
|
||||||
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
|
||||||
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
||||||
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
||||||
@@ -55,6 +57,10 @@ class AuthController @Inject constructor(
|
|||||||
currentUserState.value = raw?.let { decodeUser(it) }
|
currentUserState.value = raw?.let { decodeUser(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refresh from /api/me on startup so a remotely-changed account
|
||||||
|
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
||||||
|
// without a re-login. Best-effort; failures keep the cached value.
|
||||||
|
scope.launch { refreshProfile() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,9 +78,32 @@ class AuthController @Inject constructor(
|
|||||||
)
|
)
|
||||||
currentUserState.value = user
|
currentUserState.value = user
|
||||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||||
|
// Pull the fuller /me shape (carries debug_mode_enabled) right
|
||||||
|
// after login so the diagnostics gate is correct without waiting
|
||||||
|
// for the next startup refresh.
|
||||||
|
scope.launch { refreshProfile() }
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch the caller's profile from /api/me and update currentUser
|
||||||
|
* + persisted userJson. Carries account-level flags (debug mode)
|
||||||
|
* that aren't in the login response. Best-effort: a network failure
|
||||||
|
* leaves the cached identity untouched. No-op when signed out.
|
||||||
|
*/
|
||||||
|
suspend fun refreshProfile() {
|
||||||
|
if (!isSignedIn) return
|
||||||
|
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
||||||
|
val user = UserRef(
|
||||||
|
id = p.id,
|
||||||
|
username = p.username,
|
||||||
|
isAdmin = p.isAdmin,
|
||||||
|
debugModeEnabled = p.debugModeEnabled,
|
||||||
|
)
|
||||||
|
currentUserState.value = user
|
||||||
|
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the local session immediately and best-effort hits
|
* Clears the local session immediately and best-effort hits
|
||||||
* `/api/auth/logout` so the server can drop its session row.
|
* `/api/auth/logout` so the server can drop its session row.
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ class AuthStore @Inject constructor(
|
|||||||
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||||
|
|
||||||
|
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||||
|
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -70,6 +73,7 @@ class AuthStore @Inject constructor(
|
|||||||
themeModeState.value = row?.themeMode
|
themeModeState.value = row?.themeMode
|
||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +116,11 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistCacheSettings(encoded) }
|
scope.launch { persistCacheSettings(encoded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDiagnosticsOptOut(value: Boolean) {
|
||||||
|
diagnosticsOptOutState.value = value
|
||||||
|
scope.launch { persistDiagnosticsOptOut(value) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -160,6 +169,14 @@ class AuthStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun persistDiagnosticsOptOut(value: Boolean) {
|
||||||
|
if (dao.get() == null) {
|
||||||
|
dao.upsert(currentEntity().copy(diagnosticsOptOut = value))
|
||||||
|
} else {
|
||||||
|
dao.setDiagnosticsOptOut(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
sessionCookie = sessionCookieState.value,
|
||||||
@@ -171,6 +188,7 @@ class AuthStore @Inject constructor(
|
|||||||
CacheSettings.serializer(),
|
CacheSettings.serializer(),
|
||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
|
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
@@ -30,6 +31,7 @@ import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHomeIndexEntity::class,
|
CachedHomeIndexEntity::class,
|
||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
|
DiagnosticEventEntity::class,
|
||||||
],
|
],
|
||||||
version = 6,
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
||||||
|
version = 7,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
@@ -105,5 +106,10 @@ object DatabaseModule {
|
|||||||
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
||||||
db.audioCacheIndexDao()
|
db.audioCacheIndexDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||||
|
db.diagnosticEventDao()
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -42,4 +42,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized cache settings. */
|
/** Partial update: change only the serialized cache settings. */
|
||||||
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
||||||
suspend fun setCacheSettingsJson(json: String?)
|
suspend fun setCacheSettingsJson(json: String?)
|
||||||
|
|
||||||
|
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||||
|
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||||
|
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface DiagnosticEventDao {
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(row: DiagnosticEventEntity): Long
|
||||||
|
|
||||||
|
/** FIFO drain order so the uploader sends oldest-first. */
|
||||||
|
@Query("SELECT * FROM diagnostic_events ORDER BY id ASC LIMIT :limit")
|
||||||
|
suspend fun takeBatch(limit: Int): List<DiagnosticEventEntity>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM diagnostic_events")
|
||||||
|
suspend fun count(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM diagnostic_events WHERE id IN (:ids)")
|
||||||
|
suspend fun deleteByIds(ids: List<Long>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring-buffer trim: drop the oldest rows beyond [keep]. Called after
|
||||||
|
* insert so a long offline stretch can't grow the buffer unbounded.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"DELETE FROM diagnostic_events WHERE id NOT IN " +
|
||||||
|
"(SELECT id FROM diagnostic_events ORDER BY id DESC LIMIT :keep)",
|
||||||
|
)
|
||||||
|
suspend fun trimToNewest(keep: Int)
|
||||||
|
|
||||||
|
@Query("DELETE FROM diagnostic_events")
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
+7
@@ -36,4 +36,11 @@ data class AuthSessionEntity(
|
|||||||
* CacheSettings shape evolves.
|
* CacheSettings shape evolves.
|
||||||
*/
|
*/
|
||||||
val cacheSettingsJson: String? = null,
|
val cacheSettingsJson: String? = null,
|
||||||
|
/**
|
||||||
|
* Per-device opt-out of diagnostics reporting (M9). When the
|
||||||
|
* account's debug mode is enabled by an admin, the user can still
|
||||||
|
* turn reporting OFF on this device (battery/privacy) — that local
|
||||||
|
* choice lives here. Default false = honor the account flag.
|
||||||
|
*/
|
||||||
|
val diagnosticsOptOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
|
||||||
|
* writes rows here when the account's debug mode is on; the
|
||||||
|
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
|
||||||
|
* on success.
|
||||||
|
*
|
||||||
|
* This is a deliberate ring buffer that is NOT routed through the offline
|
||||||
|
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
|
||||||
|
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
|
||||||
|
* offline — so events must persist locally through the dead zone and
|
||||||
|
* upload on recovery, without clogging the user-data mutation replay.
|
||||||
|
*
|
||||||
|
* `occurredAtMillis` is the device-clock epoch-ms when the event happened
|
||||||
|
* (the server also stamps its own received_at). `payloadJson` is an
|
||||||
|
* opaque JSON object carrying the event sub-type + fields.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "diagnostic_events")
|
||||||
|
data class DiagnosticEventEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val kind: String,
|
||||||
|
val occurredAtMillis: Long,
|
||||||
|
val payloadJson: String,
|
||||||
|
)
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
package com.fabledsword.minstrel.diagnostics
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||||
|
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
|
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||||
|
import com.fabledsword.minstrel.player.output.OutputPickerController
|
||||||
|
import com.fabledsword.minstrel.player.output.OutputRoute
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device diagnostics collector (M9). Active only while the account's
|
||||||
|
* debug mode is enabled (server flag, read via AuthController.currentUser)
|
||||||
|
* AND the user hasn't opted out locally. When active it taps existing
|
||||||
|
* connectivity / player / UPnP / power seams READ-ONLY and writes a
|
||||||
|
* timeseries of events into the [DiagnosticEventDao] ring buffer; the
|
||||||
|
* [DiagnosticsUploader] drains that buffer to the server.
|
||||||
|
*
|
||||||
|
* The aim is to explain two failure classes after the fact: roaming /
|
||||||
|
* poor-data recovery (connectivity + server-health transitions) and
|
||||||
|
* locked-phone UPnP desync (power/Doze transitions correlated with the
|
||||||
|
* Sonos-vs-local position deltas captured in the heartbeat).
|
||||||
|
*/
|
||||||
|
@Suppress("TooManyFunctions") // one collector + one (un)register pair per signal; cohesive
|
||||||
|
@Singleton
|
||||||
|
class DiagnosticsReporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
private val dao: DiagnosticEventDao,
|
||||||
|
private val authController: AuthController,
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
private val connectivity: ConnectivityObserver,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
|
private val playerController: PlayerController,
|
||||||
|
private val outputPicker: OutputPickerController,
|
||||||
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
@Volatile private var enabled = false
|
||||||
|
private var collectorJob: Job? = null
|
||||||
|
private var powerReceiver: BroadcastReceiver? = null
|
||||||
|
private var lifecycleObserver: DefaultLifecycleObserver? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Gate = account flag (currentUser.debugModeEnabled) AND not the
|
||||||
|
// per-device local opt-out.
|
||||||
|
scope.launch {
|
||||||
|
combine(authController.currentUser, authStore.diagnosticsOptOut) { user, optOut ->
|
||||||
|
(user?.debugModeEnabled == true) && !optOut
|
||||||
|
}.distinctUntilChanged().collect { setEnabled(it) }
|
||||||
|
}
|
||||||
|
// Periodically re-pull /me so a remote admin flip propagates
|
||||||
|
// without a re-login or app restart.
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(GATE_REFRESH_MS)
|
||||||
|
authController.refreshProfile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setEnabled(value: Boolean) {
|
||||||
|
if (value == enabled) return
|
||||||
|
enabled = value
|
||||||
|
if (value) start() else stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
registerPowerReceiver()
|
||||||
|
registerLifecycleObserver()
|
||||||
|
collectorJob = scope.launch {
|
||||||
|
record("lifecycle", buildJsonObject { put("event", "reporting_started") })
|
||||||
|
launch { collectConnectivity() }
|
||||||
|
launch { collectServerHealth() }
|
||||||
|
launch { collectUpnpDrops() }
|
||||||
|
launch { collectPlayerState() }
|
||||||
|
launch { collectRoutes() }
|
||||||
|
launch { heartbeatLoop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
collectorJob?.cancel()
|
||||||
|
collectorJob = null
|
||||||
|
unregisterPowerReceiver()
|
||||||
|
unregisterLifecycleObserver()
|
||||||
|
// Off means off: drop any unsent buffer (honors the user OFF
|
||||||
|
// intent and avoids leaking data captured before disable).
|
||||||
|
scope.launch { runCatching { dao.clear() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectConnectivity() {
|
||||||
|
connectivity.online.distinctUntilChanged().collect { online ->
|
||||||
|
record("connectivity", buildJsonObject {
|
||||||
|
put("event", "online_changed")
|
||||||
|
put("online", online)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectServerHealth() {
|
||||||
|
networkStatus.state.distinctUntilChanged().collect { s ->
|
||||||
|
record("connectivity", buildJsonObject {
|
||||||
|
put("event", "server_health")
|
||||||
|
put("state", s.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectUpnpDrops() {
|
||||||
|
playerController.dropEvents.collect { msg ->
|
||||||
|
record("upnp_sync", buildJsonObject {
|
||||||
|
put("event", "drop")
|
||||||
|
put("message", msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectPlayerState() {
|
||||||
|
playerController.uiState
|
||||||
|
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { (source, upnpLoading, err) ->
|
||||||
|
record("upnp_sync", buildJsonObject {
|
||||||
|
put("event", "player_state")
|
||||||
|
put("source", source ?: "")
|
||||||
|
put("upnp_loading", upnpLoading)
|
||||||
|
if (err != null) put("error", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectRoutes() {
|
||||||
|
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
|
||||||
|
record("upnp_sync", buildJsonObject {
|
||||||
|
put("event", "route")
|
||||||
|
put("id", r.id)
|
||||||
|
put("name", r.name)
|
||||||
|
put("kind", r.kind.name)
|
||||||
|
put("protocol", r.protocol.name)
|
||||||
|
put("connected", r.isConnected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun heartbeatLoop() {
|
||||||
|
while (true) {
|
||||||
|
delay(HEARTBEAT_MS)
|
||||||
|
val ui = playerController.uiState.value
|
||||||
|
val route = outputPicker.routesState.value.current
|
||||||
|
val routeActive = route.protocol != OutputRoute.Protocol.SYSTEM
|
||||||
|
// Idle = nothing worth sampling. Skip to keep the buffer lean.
|
||||||
|
if (!ui.isPlaying && !routeActive) continue
|
||||||
|
record("heartbeat", buildJsonObject {
|
||||||
|
put("server_health", networkStatus.state.value.name)
|
||||||
|
put("is_playing", ui.isPlaying)
|
||||||
|
put("source", ui.currentSource ?: "")
|
||||||
|
put("local_index", ui.queueIndex)
|
||||||
|
put("local_pos_ms", ui.positionMs)
|
||||||
|
put("route", route.name)
|
||||||
|
put("route_protocol", route.protocol.name)
|
||||||
|
// UPnP/Sonos remote-vs-local — the desync signal.
|
||||||
|
put("sonos_track", remoteState.trackNumber)
|
||||||
|
put("sonos_pos_ms", remoteState.positionMs)
|
||||||
|
put("sonos_playing", remoteState.isPlaying)
|
||||||
|
addPowerFields(this)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- power + lifecycle taps -------------------------------------------
|
||||||
|
|
||||||
|
private fun registerPowerReceiver() {
|
||||||
|
if (powerReceiver != null) return
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, intent: Intent?) {
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
if (!enabled) return
|
||||||
|
scope.launch { record("power", powerEvent(action)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
||||||
|
}
|
||||||
|
// System-protected broadcasts, but pass NOT_EXPORTED explicitly so
|
||||||
|
// the API 34+ registerReceiver flag requirement is satisfied.
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
powerReceiver = receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterPowerReceiver() {
|
||||||
|
powerReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||||
|
powerReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerLifecycleObserver() {
|
||||||
|
if (lifecycleObserver != null) return
|
||||||
|
val obs = object : DefaultLifecycleObserver {
|
||||||
|
override fun onStart(owner: LifecycleOwner) = logLifecycle("app_foreground")
|
||||||
|
override fun onStop(owner: LifecycleOwner) = logLifecycle("app_background")
|
||||||
|
}
|
||||||
|
lifecycleObserver = obs
|
||||||
|
scope.launch(Dispatchers.Main) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterLifecycleObserver() {
|
||||||
|
val obs = lifecycleObserver ?: return
|
||||||
|
lifecycleObserver = null
|
||||||
|
scope.launch(Dispatchers.Main) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logLifecycle(event: String) {
|
||||||
|
if (!enabled) return
|
||||||
|
scope.launch { record("lifecycle", buildJsonObject { put("event", event) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun powerEvent(action: String): JsonObject = buildJsonObject {
|
||||||
|
put("event", action.substringAfterLast('.'))
|
||||||
|
addPowerFields(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
|
||||||
|
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
builder.put("doze", pm.isDeviceIdleMode)
|
||||||
|
builder.put("screen_on", pm.isInteractive)
|
||||||
|
builder.put("battery_opt_ignored", pm.isIgnoringBatteryOptimizations(context.packageName))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun record(kind: String, payload: JsonObject) {
|
||||||
|
runCatching {
|
||||||
|
val id = dao.insert(
|
||||||
|
DiagnosticEventEntity(
|
||||||
|
kind = kind,
|
||||||
|
occurredAtMillis = System.currentTimeMillis(),
|
||||||
|
payloadJson = json.encodeToString(JsonObject.serializer(), payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (id % TRIM_INTERVAL == 0L) dao.trimToNewest(MAX_BUFFER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HEARTBEAT_MS = 45_000L
|
||||||
|
const val GATE_REFRESH_MS = 300_000L // re-pull /me every 5 min
|
||||||
|
const val MAX_BUFFER = 5_000 // ring-buffer ceiling, oldest dropped
|
||||||
|
const val TRIM_INTERVAL = 50L // trim every N inserts, not every one
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package com.fabledsword.minstrel.diagnostics
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.DiagnosticEventWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.DiagnosticsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.DiagnosticsReportRequest
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
|
||||||
|
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
||||||
|
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
|
||||||
|
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
|
||||||
|
* upload the moment the link comes back.
|
||||||
|
*
|
||||||
|
* Deliberately NOT the MutationQueue: diagnostics are high-volume,
|
||||||
|
* best-effort telemetry. A failed upload just retries on the next tick;
|
||||||
|
* the server no-ops (204) when the account's debug flag is off, which the
|
||||||
|
* uploader treats as success so the buffer drains either way.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DiagnosticsUploader @Inject constructor(
|
||||||
|
private val dao: DiagnosticEventDao,
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
private val json: Json,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: DiagnosticsApi = retrofit.create()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(UPLOAD_INTERVAL_MS)
|
||||||
|
drainSafe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
authStore.sessionCookie.filterNotNull().distinctUntilChanged().collect { drainSafe() }
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
networkStatus.state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it }
|
||||||
|
.collect { drainSafe() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
|
||||||
|
private suspend fun drainSafe() {
|
||||||
|
if (authStore.sessionCookie.value.isNullOrEmpty()) return
|
||||||
|
val clientId = authStore.clientId.value ?: return
|
||||||
|
if (!mutex.tryLock()) return
|
||||||
|
try {
|
||||||
|
drain(clientId)
|
||||||
|
} finally {
|
||||||
|
mutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun drain(clientId: String) {
|
||||||
|
while (true) {
|
||||||
|
val batch = runCatching { dao.takeBatch(BATCH_SIZE) }.getOrNull().orEmpty()
|
||||||
|
if (batch.isEmpty()) return
|
||||||
|
val sent = runCatching { upload(clientId, batch) }.isSuccess
|
||||||
|
if (!sent) return // leave rows for the next trigger
|
||||||
|
runCatching { dao.deleteByIds(batch.map { it.id }) }
|
||||||
|
if (batch.size < BATCH_SIZE) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun upload(clientId: String, batch: List<DiagnosticEventEntity>) {
|
||||||
|
api.report(
|
||||||
|
DiagnosticsReportRequest(
|
||||||
|
clientId = clientId,
|
||||||
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
|
osVersion = "Android ${Build.VERSION.RELEASE} (${Build.MODEL})",
|
||||||
|
events = batch.map { row ->
|
||||||
|
DiagnosticEventWire(
|
||||||
|
kind = row.kind,
|
||||||
|
occurredAt = row.occurredAtMillis,
|
||||||
|
payload = json.parseToJsonElement(row.payloadJson),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val UPLOAD_INTERVAL_MS = 60_000L
|
||||||
|
const val BATCH_SIZE = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,4 +16,11 @@ data class UserRef(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Account-level diagnostics opt-in (M9). NOT present in the login
|
||||||
|
* response (that's the narrow UserView) — populated by the /api/me
|
||||||
|
* refresh in AuthController so a remote admin flip reaches the
|
||||||
|
* device. Default false until the first refresh.
|
||||||
|
*/
|
||||||
|
val debugModeEnabled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ data class MyProfileWire(
|
|||||||
@SerialName("display_name") val displayName: String? = null,
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
val email: String? = null,
|
val email: String? = null,
|
||||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||||
|
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -94,6 +95,7 @@ fun SettingsScreen(
|
|||||||
onPickTheme = themeVm::setThemeMode,
|
onPickTheme = themeVm::setThemeMode,
|
||||||
onNavToRequests = { navController.navigate(Requests) },
|
onNavToRequests = { navController.navigate(Requests) },
|
||||||
onNavToAdmin = { navController.navigate(Admin) },
|
onNavToAdmin = { navController.navigate(Admin) },
|
||||||
|
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||||
onSignOutClick = { showSignOutConfirm = true },
|
onSignOutClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,7 @@ private fun SettingsList(
|
|||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
onNavToRequests: () -> Unit,
|
onNavToRequests: () -> Unit,
|
||||||
onNavToAdmin: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
|
onToggleDiagnostics: (Boolean) -> Unit,
|
||||||
onSignOutClick: () -> Unit,
|
onSignOutClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -148,6 +151,12 @@ private fun SettingsList(
|
|||||||
ProfileCard()
|
ProfileCard()
|
||||||
PasswordCard()
|
PasswordCard()
|
||||||
ListenBrainzCard()
|
ListenBrainzCard()
|
||||||
|
if (state.diagnosticsEnabledByAdmin) {
|
||||||
|
DiagnosticsCard(
|
||||||
|
optOut = state.diagnosticsOptOut,
|
||||||
|
onToggle = onToggleDiagnostics,
|
||||||
|
)
|
||||||
|
}
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
@@ -262,6 +271,46 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shown only while an admin has enabled debug mode on this account. Lets
|
||||||
|
// the user turn reporting OFF on this device (battery/privacy) without
|
||||||
|
// touching the account flag. Checked = reporting on = NOT opted out.
|
||||||
|
@Composable
|
||||||
|
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Diagnostics",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Send diagnostic reports",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your admin enabled debug mode to investigate an " +
|
||||||
|
"issue. This device streams connectivity and playback " +
|
||||||
|
"diagnostics. Turn off any time.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Switch(
|
||||||
|
checked = !optOut,
|
||||||
|
onCheckedChange = { checked -> onToggle(!checked) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||||
|
|||||||
+13
-2
@@ -19,12 +19,15 @@ data class SettingsState(
|
|||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
val isSigningOut: Boolean = false,
|
val isSigningOut: Boolean = false,
|
||||||
val signedOut: Boolean = false,
|
val signedOut: Boolean = false,
|
||||||
|
// M9: account debug flag (admin-set) + this device's local opt-out.
|
||||||
|
val diagnosticsEnabledByAdmin: Boolean = false,
|
||||||
|
val diagnosticsOptOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val authController: AuthController,
|
private val authController: AuthController,
|
||||||
authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val transient = MutableStateFlow(TransientState())
|
private val transient = MutableStateFlow(TransientState())
|
||||||
@@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
authStore.baseUrl,
|
authStore.baseUrl,
|
||||||
authController.currentUser,
|
authController.currentUser,
|
||||||
|
authStore.diagnosticsOptOut,
|
||||||
transient,
|
transient,
|
||||||
) { url, user, t ->
|
) { url, user, optOut, t ->
|
||||||
SettingsState(
|
SettingsState(
|
||||||
serverUrl = url,
|
serverUrl = url,
|
||||||
username = user?.username.orEmpty(),
|
username = user?.username.orEmpty(),
|
||||||
isAdmin = user?.isAdmin == true,
|
isAdmin = user?.isAdmin == true,
|
||||||
isSigningOut = t.isSigningOut,
|
isSigningOut = t.isSigningOut,
|
||||||
signedOut = t.signedOut,
|
signedOut = t.signedOut,
|
||||||
|
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
|
||||||
|
diagnosticsOptOut = optOut,
|
||||||
)
|
)
|
||||||
}.collect { internal.value = it }
|
}.collect { internal.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-device opt-out of diagnostics while the account flag is on. */
|
||||||
|
fun setDiagnosticsOptOut(optOut: Boolean) {
|
||||||
|
authStore.setDiagnosticsOptOut(optOut)
|
||||||
|
}
|
||||||
|
|
||||||
fun signOut() {
|
fun signOut() {
|
||||||
if (transient.value.isSigningOut) return
|
if (transient.value.isSigningOut) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ export type AdminUser = {
|
|||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
auto_approve_requests: boolean;
|
auto_approve_requests: boolean;
|
||||||
|
debug_mode_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -451,6 +452,12 @@ export async function updateUserAutoApprove(id: string, autoApprove: boolean): P
|
|||||||
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
|
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set;
|
||||||
|
// the client obeys it (with a local per-device OFF switch).
|
||||||
|
export async function updateUserDebugMode(id: string, enabled: boolean): Promise<AdminUser> {
|
||||||
|
return api.put<AdminUser>(`/api/admin/users/${id}/debug-mode`, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
export function createAdminUsersQuery() {
|
export function createAdminUsersQuery() {
|
||||||
return createQuery({
|
return createQuery({
|
||||||
queryKey: qk.adminUsers(),
|
queryKey: qk.adminUsers(),
|
||||||
@@ -497,3 +504,82 @@ export function createSMTPConfigQuery() {
|
|||||||
staleTime: 60_000
|
staleTime: 60_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device diagnostics (M9) ---------------------------------------------------
|
||||||
|
|
||||||
|
// Coarse event category. The finer event sub-type lives inside `payload`.
|
||||||
|
export type DiagnosticKind =
|
||||||
|
| 'connectivity'
|
||||||
|
| 'upnp_sync'
|
||||||
|
| 'power'
|
||||||
|
| 'lifecycle'
|
||||||
|
| 'heartbeat'
|
||||||
|
| 'http';
|
||||||
|
|
||||||
|
export type AdminDiagnostic = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
client_id: string;
|
||||||
|
app_version?: string;
|
||||||
|
os_version?: string;
|
||||||
|
kind: DiagnosticKind | string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
occurred_at: string;
|
||||||
|
received_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDiagnosticDevice = {
|
||||||
|
client_id: string;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
app_version: string;
|
||||||
|
os_version: string;
|
||||||
|
last_seen: string;
|
||||||
|
event_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiagnosticsFilter = {
|
||||||
|
userId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
kind?: string;
|
||||||
|
from?: string; // RFC3339
|
||||||
|
to?: string; // RFC3339
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise<AdminDiagnostic[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (f.userId) params.set('user_id', f.userId);
|
||||||
|
if (f.clientId) params.set('client_id', f.clientId);
|
||||||
|
if (f.kind) params.set('kind', f.kind);
|
||||||
|
if (f.from) params.set('from', f.from);
|
||||||
|
if (f.to) params.set('to', f.to);
|
||||||
|
if (f.limit !== undefined) params.set('limit', String(f.limit));
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<AdminDiagnostic[]>(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDiagnosticDevices(userId?: string): Promise<AdminDiagnosticDevice[]> {
|
||||||
|
const qs = userId ? `?user_id=${userId}` : '';
|
||||||
|
return api.get<AdminDiagnosticDevice[]>(`/api/admin/diagnostics/devices${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.adminDiagnostics(f as Record<string, string | number | undefined>),
|
||||||
|
queryFn: () => listAdminDiagnostics(f),
|
||||||
|
// The operator enables debug then watches events stream in; a short
|
||||||
|
// poll keeps the timeline live without manual refresh.
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
staleTime: 5_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiagnosticDevicesQuery(userId?: string) {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.adminDiagnosticDevices(userId),
|
||||||
|
queryFn: () => listDiagnosticDevices(userId),
|
||||||
|
staleTime: 15_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export const qk = {
|
|||||||
coverProviders: () => ['coverProviders'] as const,
|
coverProviders: () => ['coverProviders'] as const,
|
||||||
adminUsers: () => ['adminUsers'] as const,
|
adminUsers: () => ['adminUsers'] as const,
|
||||||
adminInvites: () => ['adminInvites'] as const,
|
adminInvites: () => ['adminInvites'] as const,
|
||||||
|
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
|
||||||
|
['adminDiagnostics', f] as const,
|
||||||
|
adminDiagnosticDevices: (userId?: string) =>
|
||||||
|
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||||
smtpConfig: () => ['smtpConfig'] as const,
|
smtpConfig: () => ['smtpConfig'] as const,
|
||||||
suggestions: (limit?: number) =>
|
suggestions: (limit?: number) =>
|
||||||
['suggestions', { limit: limit ?? 12 }] as const,
|
['suggestions', { limit: limit ?? 12 }] as const,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ href: '/admin/requests', label: 'Requests' },
|
{ href: '/admin/requests', label: 'Requests' },
|
||||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||||
|
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||||
{ href: '/admin/users', label: 'Users' }
|
{ href: '/admin/users', label: 'Users' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pageTitle } from '$lib/branding';
|
||||||
|
import { Copy, Download, Activity } from 'lucide-svelte';
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
createAdminDiagnosticsQuery,
|
||||||
|
createDiagnosticDevicesQuery,
|
||||||
|
createAdminUsersQuery,
|
||||||
|
updateUserDebugMode,
|
||||||
|
type AdminDiagnostic,
|
||||||
|
type DiagnosticsFilter
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
import { qk } from '$lib/api/queries';
|
||||||
|
import { errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
// Device diagnostics timeline (M9). The operator enables debug-mode on
|
||||||
|
// an account (remotely, here), then watches the account's device(s)
|
||||||
|
// stream connectivity / UPnP-sync / power events. The point of the page
|
||||||
|
// is the EXPORT: filter to the window in question and copy/download the
|
||||||
|
// slice as JSON to hand off for analysis.
|
||||||
|
|
||||||
|
const client = useQueryClient();
|
||||||
|
|
||||||
|
const KINDS = ['connectivity', 'upnp_sync', 'power', 'lifecycle', 'heartbeat', 'http'] as const;
|
||||||
|
|
||||||
|
function kindLabel(k: string): string {
|
||||||
|
switch (k) {
|
||||||
|
case 'connectivity': return 'Connectivity';
|
||||||
|
case 'upnp_sync': return 'UPnP sync';
|
||||||
|
case 'power': return 'Power';
|
||||||
|
case 'lifecycle': return 'Lifecycle';
|
||||||
|
case 'heartbeat': return 'Heartbeat';
|
||||||
|
case 'http': return 'HTTP';
|
||||||
|
default: return k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter state.
|
||||||
|
let accountId = $state('');
|
||||||
|
let clientId = $state('');
|
||||||
|
let kind = $state('');
|
||||||
|
let fromLocal = $state('');
|
||||||
|
let toLocal = $state('');
|
||||||
|
let limit = $state(500);
|
||||||
|
|
||||||
|
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
|
||||||
|
function toRfc(v: string): string | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const d = new Date(v);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = $derived<DiagnosticsFilter>({
|
||||||
|
userId: accountId || undefined,
|
||||||
|
clientId: clientId || undefined,
|
||||||
|
kind: kind || undefined,
|
||||||
|
from: toRfc(fromLocal),
|
||||||
|
to: toRfc(toLocal),
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersStore = $derived(createAdminUsersQuery());
|
||||||
|
const usersQuery = $derived($usersStore);
|
||||||
|
const users = $derived(usersQuery.data ?? []);
|
||||||
|
const selectedUser = $derived(users.find((u) => u.id === accountId));
|
||||||
|
|
||||||
|
const devicesStore = $derived(createDiagnosticDevicesQuery(accountId || undefined));
|
||||||
|
const devicesQuery = $derived($devicesStore);
|
||||||
|
const devices = $derived(devicesQuery.data ?? []);
|
||||||
|
|
||||||
|
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
|
||||||
|
const diagQuery = $derived($diagStore);
|
||||||
|
// API returns newest-first; reverse to chronological for a readable timeline.
|
||||||
|
const rows = $derived([...((diagQuery.data ?? []) as AdminDiagnostic[])].reverse());
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadPreview(p: Record<string, unknown>): string {
|
||||||
|
const s = JSON.stringify(p);
|
||||||
|
return s.length > 120 ? s.slice(0, 120) + '…' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the selected account's debug-mode remotely.
|
||||||
|
let toggling = $state(false);
|
||||||
|
async function toggleDebug() {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
toggling = true;
|
||||||
|
try {
|
||||||
|
await updateUserDebugMode(selectedUser.id, !selectedUser.debug_mode_enabled);
|
||||||
|
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
|
pushToast(
|
||||||
|
`Debug mode ${selectedUser.debug_mode_enabled ? 'disabled' : 'enabled'} for ${selectedUser.username}`
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Toggle failed: ${errMessage(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
toggling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the current (chronological) slice as an analysis-ready object.
|
||||||
|
function exportObject() {
|
||||||
|
return {
|
||||||
|
account: selectedUser?.username ?? accountId ?? 'all',
|
||||||
|
device: clientId || 'all',
|
||||||
|
kind: kind || 'all',
|
||||||
|
from: toRfc(fromLocal) ?? null,
|
||||||
|
to: toRfc(toLocal) ?? null,
|
||||||
|
count: rows.length,
|
||||||
|
events: rows.map((r) => ({
|
||||||
|
occurred_at: r.occurred_at,
|
||||||
|
received_at: r.received_at,
|
||||||
|
kind: r.kind,
|
||||||
|
client_id: r.client_id,
|
||||||
|
app_version: r.app_version,
|
||||||
|
os_version: r.os_version,
|
||||||
|
payload: r.payload
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopyJson() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(exportObject(), null, 2));
|
||||||
|
pushToast(`Copied ${rows.length} events to clipboard`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDownloadNdjson() {
|
||||||
|
const lines = exportObject().events.map((e) => JSON.stringify(e)).join('\n');
|
||||||
|
const blob = new Blob([lines], { type: 'application/x-ndjson' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const who = selectedUser?.username ?? 'all';
|
||||||
|
a.href = url;
|
||||||
|
a.download = `diagnostics-${who}-${Date.now()}.ndjson`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{pageTitle('Admin · Diagnostics')}</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<header>
|
||||||
|
<h1 class="font-display text-2xl font-medium text-text-primary">Device diagnostics</h1>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
Enable debug mode on an account to have its device(s) stream a timeseries
|
||||||
|
of connectivity, UPnP-sync, and power/Doze events here. Filter to the
|
||||||
|
window you care about, then copy or download the slice for analysis.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Account debug-mode control -->
|
||||||
|
<section class="rounded-md border border-border p-3">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
Account
|
||||||
|
<select
|
||||||
|
bind:value={accountId}
|
||||||
|
class="mt-1 block w-56 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="">All accounts</option>
|
||||||
|
{#each users as u (u.id)}
|
||||||
|
<option value={u.id}>{u.username}{u.debug_mode_enabled ? ' · debug on' : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if selectedUser}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleDebug}
|
||||||
|
disabled={toggling}
|
||||||
|
class="rounded px-3 py-2 text-sm text-action-fg hover:opacity-90 disabled:opacity-50
|
||||||
|
{selectedUser.debug_mode_enabled ? 'bg-action-destructive' : 'bg-action-primary'}"
|
||||||
|
>
|
||||||
|
{selectedUser.debug_mode_enabled ? 'Disable debug mode' : 'Enable debug mode'}
|
||||||
|
</button>
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
|
||||||
|
<Activity size={14} />
|
||||||
|
{selectedUser.debug_mode_enabled ? 'Reporting active' : 'Reporting off'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<section class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
Device
|
||||||
|
<select
|
||||||
|
bind:value={clientId}
|
||||||
|
class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="">All devices</option>
|
||||||
|
{#each devices as d (d.client_id)}
|
||||||
|
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
Kind
|
||||||
|
<select
|
||||||
|
bind:value={kind}
|
||||||
|
class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="">All kinds</option>
|
||||||
|
{#each KINDS as k (k)}
|
||||||
|
<option value={k}>{kindLabel(k)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
From
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={fromLocal}
|
||||||
|
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
To
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={toLocal}
|
||||||
|
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block text-xs text-text-secondary">
|
||||||
|
Limit
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5000"
|
||||||
|
bind:value={limit}
|
||||||
|
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onCopyJson}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Copy size={15} /> Copy JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onDownloadNdjson}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Download size={15} /> Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
{#if diagQuery.isError}
|
||||||
|
<p class="text-error">Couldn't load: {errMessage(diagQuery.error)}</p>
|
||||||
|
{:else if diagQuery.isPending}
|
||||||
|
<p class="text-text-secondary">Loading…</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="text-text-secondary">
|
||||||
|
No events for this filter. Enable debug mode on an account and have the
|
||||||
|
device reproduce the issue — events appear here within a minute.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-text-muted">{rows.length} events (oldest first)</span>
|
||||||
|
</div>
|
||||||
|
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
|
||||||
|
{#each rows as r (r.id)}
|
||||||
|
<li class="flex items-start gap-3 px-3 py-2">
|
||||||
|
<span class="w-44 shrink-0 text-text-muted" title={`received ${fmtTime(r.received_at)}`}>
|
||||||
|
{fmtTime(r.occurred_at)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="w-24 shrink-0 rounded bg-surface-hover px-1.5 py-0.5 text-center text-[10px] uppercase tracking-wide text-text-secondary"
|
||||||
|
>
|
||||||
|
{kindLabel(r.kind)}
|
||||||
|
</span>
|
||||||
|
<details class="min-w-0 flex-1">
|
||||||
|
<summary class="cursor-pointer truncate text-text-primary">
|
||||||
|
{payloadPreview(r.payload)}
|
||||||
|
</summary>
|
||||||
|
<pre class="mt-1 overflow-x-auto whitespace-pre-wrap text-text-secondary">{JSON.stringify(r.payload, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user