feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
test-web / test (push) Failing after 11s
android / Build + lint + test (push) Failing after 1m19s

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:
2026-06-29 18:56:36 -04:00
parent 4ed831d9c3
commit 4d42e298dd
20 changed files with 1039 additions and 3 deletions
@@ -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"
} }
@@ -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)
} }
@@ -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()
}
@@ -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,
) )
@@ -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,
)
@@ -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
}
}
@@ -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) {
@@ -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 {
+86
View File
@@ -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
});
}
+4
View File
@@ -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,
+1
View File
@@ -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>