Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d145fee35d | |||
| 7628330f72 | |||
| 09102dc615 | |||
| 611715154b | |||
| 392454b249 | |||
| cdfc79e6ab | |||
| 79f2d79a2e | |||
| 96b15b75e6 | |||
| 23a82fb38d | |||
| 782f152d37 | |||
| bffa5b28bd | |||
| 8a58f07237 | |||
| 4d42e298dd | |||
| 4ed831d9c3 |
@@ -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() {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.Invite
|
import com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.message != null) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> EmptyState(
|
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminQuarantineUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> EmptyState(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminRequestsUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminUsersUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
+18
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.sync
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
@@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
|||||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
|||||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human copy for the most recent sync failure; null after any clean
|
||||||
|
* pass. Lets the Library screen distinguish "empty because the first
|
||||||
|
* sync failed" (show error + retry) from "genuinely empty library"
|
||||||
|
* (show welcome copy) — a failed sync over a populated cache stays
|
||||||
|
* silent, since the cached content is still the better surface.
|
||||||
|
*/
|
||||||
|
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -82,11 +97,13 @@ class SyncController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
||||||
suspend fun syncSafe() {
|
suspend fun syncSafe() {
|
||||||
if (!mutex.tryLock()) return
|
if (!mutex.tryLock()) return
|
||||||
try {
|
try {
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
|
.onSuccess { lastSyncErrorInternal.value = null }
|
||||||
|
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||||
} finally {
|
} finally {
|
||||||
mutex.unlock()
|
mutex.unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
|
||||||
|
* after the collector subscribed — the StateFlow's replayed current value
|
||||||
|
* is dropped, so only genuine down→up transitions fire (a screen that
|
||||||
|
* subscribes while already Healthy doesn't double-load).
|
||||||
|
*
|
||||||
|
* This is the screen-level half of the recovery idiom the app-lifetime
|
||||||
|
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
|
||||||
|
* already use: a ViewModel collects this in its viewModelScope and re-runs
|
||||||
|
* its load, so a surface that failed while the server was unreachable
|
||||||
|
* heals itself the moment connectivity returns instead of sitting in the
|
||||||
|
* failed state until a manual pull-to-refresh.
|
||||||
|
*/
|
||||||
|
fun NetworkStatusController.recoveries(): Flow<Unit> = state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.filter { it }
|
||||||
|
.map { }
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
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 { collectTrackChanges() }
|
||||||
|
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() {
|
||||||
|
// state is a StateFlow — already conflated/distinct, so no
|
||||||
|
// distinctUntilChanged (it's a deprecation warning = build error).
|
||||||
|
networkStatus.state.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() {
|
||||||
|
// 'playback', not 'upnp_sync' — player state applies to every output
|
||||||
|
// route (phone speaker, Bluetooth, UPnP), not just casting.
|
||||||
|
playerController.uiState
|
||||||
|
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { (source, upnpLoading, err) ->
|
||||||
|
record("playback", buildJsonObject {
|
||||||
|
put("event", "player_state")
|
||||||
|
put("source", source ?: "")
|
||||||
|
put("upnp_loading", upnpLoading)
|
||||||
|
if (err != null) put("error", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit a snapshot at each track/queue-index change — captures a
|
||||||
|
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
|
||||||
|
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
|
||||||
|
// very rapid skip burst may coalesce intermediate indices (we still get
|
||||||
|
// the boundaries + the local-vs-Sonos snapshot).
|
||||||
|
private suspend fun collectTrackChanges() {
|
||||||
|
playerController.uiState
|
||||||
|
.map { it.queueIndex to it.currentTrack?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { (index, _) ->
|
||||||
|
val ui = playerController.uiState.value
|
||||||
|
val casting = outputPicker.routesState.value.current.protocol !=
|
||||||
|
OutputRoute.Protocol.SYSTEM
|
||||||
|
record("playback", buildJsonObject {
|
||||||
|
put("event", "track_change")
|
||||||
|
put("local_index", index)
|
||||||
|
// Track IDENTITY, not just index — indices wobble across
|
||||||
|
// re-casts, so the id + Sonos URI make a desync unambiguous.
|
||||||
|
put("local_track_id", ui.currentTrack?.id ?: "")
|
||||||
|
put("local_pos_ms", ui.positionMs)
|
||||||
|
putSonos(this, casting)
|
||||||
|
put("upnp_loading", ui.isUpnpLoading)
|
||||||
|
put("server_health", networkStatus.state.value.name)
|
||||||
|
put("route", outputPicker.routesState.value.current.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectRoutes() {
|
||||||
|
// 'playback' — route changes happen for all outputs. This only ever
|
||||||
|
// logs the ACTIVE route (routesState.current), so no "connected" flag.
|
||||||
|
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
|
||||||
|
record("playback", buildJsonObject {
|
||||||
|
put("event", "route")
|
||||||
|
put("id", r.id)
|
||||||
|
put("name", r.name)
|
||||||
|
put("kind", r.kind.name)
|
||||||
|
put("protocol", r.protocol.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_track_id", ui.currentTrack?.id ?: "")
|
||||||
|
put("local_pos_ms", ui.positionMs)
|
||||||
|
put("route", route.name)
|
||||||
|
put("route_protocol", route.protocol.name)
|
||||||
|
putSonos(this, routeActive)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
|
||||||
|
// remote route is active; zeroed otherwise so a stale RemotePlayerState
|
||||||
|
// from a just-ended cast can't masquerade as live Sonos data (the
|
||||||
|
// cast→phone handoff artifact). currentTrackUri is the desync ground
|
||||||
|
// truth — it carries the track the speaker is actually streaming.
|
||||||
|
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
|
||||||
|
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
|
||||||
|
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
|
||||||
|
builder.put("sonos_playing", casting && remoteState.isPlaying)
|
||||||
|
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
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() {
|
||||||
|
val clientId = authStore.clientId.value
|
||||||
|
// Signed out / no client id yet → nothing to do. Single guard keeps
|
||||||
|
// the return count within the detekt gate.
|
||||||
|
if (authStore.sessionCookie.value.isNullOrEmpty() || clientId == null) return
|
||||||
|
if (!mutex.tryLock()) return
|
||||||
|
try {
|
||||||
|
drain(clientId)
|
||||||
|
} finally {
|
||||||
|
mutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun drain(clientId: String) {
|
||||||
|
var more = true
|
||||||
|
while (more) {
|
||||||
|
val batch = runCatching { dao.takeBatch(BATCH_SIZE) }.getOrNull().orEmpty()
|
||||||
|
val sent = batch.isNotEmpty() && runCatching { upload(clientId, batch) }.isSuccess
|
||||||
|
if (sent) runCatching { dao.deleteByIds(batch.map { it.id }) }
|
||||||
|
// Keep going only while a full batch sent cleanly; otherwise stop
|
||||||
|
// (empty buffer, or a failure to retry on the next trigger).
|
||||||
|
more = sent && batch.size >= BATCH_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -109,6 +110,7 @@ private fun DiscoverBody(
|
|||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.loadSuggestions() },
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
is ResultsState.Loaded -> ResultsList(
|
||||||
@@ -121,7 +123,11 @@ private fun DiscoverBody(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
is ResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = r.message,
|
||||||
|
onRetry = viewModel::runSearch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,10 +183,15 @@ private fun SuggestionsPane(
|
|||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
SuggestionState.Loading -> LoadingCentered()
|
||||||
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
is SuggestionState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load suggestions",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SuggestionState.Loaded -> SuggestionsList(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
@@ -46,6 +48,7 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -53,6 +56,15 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
loadSuggestions()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||||
|
// failed while the server was unreachable once health returns.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
val s = internal.value
|
||||||
|
if (s.suggestions is SuggestionState.Error) loadSuggestions()
|
||||||
|
if (s.results is ResultsState.Error) runSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.history.data.HistoryEntry
|
import com.fabledsword.minstrel.history.data.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
@@ -77,6 +78,7 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -167,8 +169,22 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from offline-pool taps. */
|
/** Transient snackbar messages from offline-pool taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy for the most recent /home/index refresh failure; null once a
|
||||||
|
* refresh succeeds. Folded into [uiState] so an empty cache + failed
|
||||||
|
* refresh renders as Error (with retry) instead of masquerading as
|
||||||
|
* the "Welcome to Minstrel" empty state.
|
||||||
|
*/
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||||
// and You-might-like rows reflect the new snapshot without a manual
|
// and You-might-like rows reflect the new snapshot without a manual
|
||||||
@@ -289,7 +305,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
* actual completion before hiding the indicator.
|
* actual completion before hiding the indicator.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
refreshError.value = null
|
||||||
|
val home = launch {
|
||||||
|
// /home/index is the load-bearing pull: its failure drives the
|
||||||
|
// empty-cache Error state. A failure over a populated cache
|
||||||
|
// stays silent — cached sections beat a full-screen error.
|
||||||
|
runCatching { homeRepository.refreshIndex() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
|
}
|
||||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
@@ -336,9 +359,17 @@ class HomeViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
combine(
|
||||||
|
observeHomeSections(),
|
||||||
|
playlistsRepository.observeAll(),
|
||||||
|
refreshError,
|
||||||
|
) { sections, playlists, err ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
when {
|
||||||
|
!merged.isAllEmpty -> UiState.Success(merged)
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +420,10 @@ fun HomeScreen(
|
|||||||
"settings, then come back here for system playlists " +
|
"settings, then come back here for system playlists " +
|
||||||
"and recommendations.",
|
"and recommendations.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, so coming back to the screen shows content, not an error.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AlbumDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success ->
|
is ArtistDetailUiState.Success ->
|
||||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
|
|||||||
+16
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
||||||
|
// whole load; from Success, re-pull just the secondary sections —
|
||||||
|
// their fetch failures are swallowed to emptyList, so a dead zone
|
||||||
|
// during the first load leaves them permanently absent otherwise.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
when (internal.value) {
|
||||||
|
is ArtistDetailUiState.Error -> refresh()
|
||||||
|
is ArtistDetailUiState.Success -> loadSecondarySections()
|
||||||
|
is ArtistDetailUiState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleLikeArtist() {
|
fun toggleLikeArtist() {
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
syncController.lastSyncError,
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
) { artists, albums, syncError ->
|
||||||
UiState.Empty
|
when {
|
||||||
} else {
|
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||||
UiState.Success(LibraryData(artists, albums))
|
UiState.Success(LibraryData(artists, albums))
|
||||||
|
// Empty cache + failed sync is a load failure, not an empty
|
||||||
|
// library — surface it so the user gets a Retry instead of
|
||||||
|
// the welcome copy. Heals automatically: SyncController
|
||||||
|
// re-syncs on health recovery, clearing lastSyncError.
|
||||||
|
syncError != null -> UiState.Error(syncError)
|
||||||
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
|||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
refreshError,
|
||||||
|
) { artists, albums, tracks, err ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
val sections = LikedSections(artists, albums, tracks)
|
||||||
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
when {
|
||||||
|
!sections.isAllEmpty -> UiState.Success(sections)
|
||||||
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
|
// likes yet" — surface Error so the user gets a Retry.
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}.asCacheFirstStateFlow(viewModelScope)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-3
@@ -133,7 +133,7 @@ private fun RouteRow(
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitle = route.description ?: defaultSubtitle(route)
|
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
// isSelected (route == the active/selected route) drives the Bluetooth
|
||||||
|
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
|
||||||
|
// DISCONNECTED for local SYSTEM routes even when they're in use.
|
||||||
|
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
|
||||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||||
OutputRoute.Kind.Wired -> "Wired"
|
OutputRoute.Kind.Wired -> "Wired"
|
||||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
|
||||||
OutputRoute.Kind.Cast -> "Cast"
|
OutputRoute.Kind.Cast -> "Cast"
|
||||||
OutputRoute.Kind.Other -> "Available"
|
OutputRoute.Kind.Other -> "Available"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ data class OutputRoute(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
val protocol: Protocol,
|
val protocol: Protocol,
|
||||||
val isConnected: Boolean,
|
|
||||||
) {
|
) {
|
||||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||||
|
|
||||||
@@ -43,9 +42,11 @@ data class OutputRoute(
|
|||||||
/**
|
/**
|
||||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||||
* device types fall through to [Kind.Other]. The
|
* device types fall through to [Kind.Other]. "Active" is NOT an
|
||||||
* `connectionState` proxy is good enough for the chip's
|
* intrinsic of the route — the caller derives it by comparing to
|
||||||
* "Connected"/"Available" subtitle.
|
* the selected route (RouteSnapshot.current), because
|
||||||
|
* RouteInfo.connectionState only reflects remote-route handshakes
|
||||||
|
* and stays DISCONNECTED for local SYSTEM routes even when in use.
|
||||||
*/
|
*/
|
||||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||||
val kind = when (route.deviceType) {
|
val kind = when (route.deviceType) {
|
||||||
@@ -58,15 +59,12 @@ data class OutputRoute(
|
|||||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||||
else -> Kind.Other
|
else -> Kind.Other
|
||||||
}
|
}
|
||||||
val connected =
|
|
||||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
|
||||||
return OutputRoute(
|
return OutputRoute(
|
||||||
id = route.id,
|
id = route.id,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
description = route.description,
|
description = route.description,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
protocol = Protocol.SYSTEM,
|
protocol = Protocol.SYSTEM,
|
||||||
isConnected = connected,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +74,10 @@ data class OutputRoute(
|
|||||||
* network speakers into the same `OutputPickerController`
|
* network speakers into the same `OutputPickerController`
|
||||||
* routes stream the system routes come through.
|
* routes stream the system routes come through.
|
||||||
*
|
*
|
||||||
* `isConnected = false` because UPnP devices have no
|
* UPnP devices have no MediaRouter connection-state concept —
|
||||||
* MediaRouter connection-state concept — they're always
|
* they're always "available" on the LAN, and the picker's
|
||||||
* "available" on the LAN, and the picker's selected-route
|
* selected-route rendering handles the "currently playing"
|
||||||
* rendering handles the "currently playing" indicator.
|
* indicator.
|
||||||
*
|
*
|
||||||
* Subtitle is `manufacturer modelName` joined by a single
|
* Subtitle is `manufacturer modelName` joined by a single
|
||||||
* space, falling back to "Network speaker" when both fields
|
* space, falling back to "Network speaker" when both fields
|
||||||
@@ -96,7 +94,6 @@ data class OutputRoute(
|
|||||||
description = description,
|
description = description,
|
||||||
kind = Kind.Other,
|
kind = Kind.Other,
|
||||||
protocol = Protocol.UPNP,
|
protocol = Protocol.UPNP,
|
||||||
isConnected = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-30
@@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -60,6 +58,8 @@ import com.composables.icons.lucide.Play
|
|||||||
import com.composables.icons.lucide.RefreshCw
|
import com.composables.icons.lucide.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
@@ -76,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -117,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -167,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.collect { handlePlaylistEvent(it) }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, instead of waiting for the Retry tap.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is PlaylistDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,7 +379,11 @@ private fun PlaylistDetailContent(
|
|||||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
is PlaylistDetailUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load playlist",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.collectAsState()
|
val playerState by playerViewModel.uiState.collectAsState()
|
||||||
@@ -653,33 +667,6 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
|
||||||
var retried by remember { mutableStateOf(false) }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
if (!retried) {
|
|
||||||
retried = true
|
|
||||||
onRetry()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text("Retry") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||||
|
|||||||
+26
-9
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||||
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
combine(repository.observeAll(), refreshError) { list, err ->
|
||||||
.map { list ->
|
when {
|
||||||
if (list.isEmpty()) {
|
list.isNotEmpty() -> UiState.Success(list)
|
||||||
UiState.Empty
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
} else {
|
// playlists yet" — surface Error so the user gets a Retry.
|
||||||
UiState.Success(list)
|
err != null -> UiState.Error(err)
|
||||||
}
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
|||||||
"and it'll show up here. The track stays out of system " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
|||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
.collect { silentReload() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
viewModelScope.launch { pollWhileInFlight() }
|
viewModelScope.launch { pollWhileInFlight() }
|
||||||
|
// Screen-level auto-recovery (issue #1245): a load that failed while
|
||||||
|
// the server was unreachable heals the moment health returns, without
|
||||||
|
// waiting for the next poll tick.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is UiState.Error) silentReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollWhileInFlight() {
|
private suspend fun pollWhileInFlight() {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (hasInFlightRequest()) {
|
// Also retry from a failed load (issue #1245): from UiState.Error
|
||||||
|
// the in-flight predicate can never become true, so without this
|
||||||
|
// the poll never self-heals the screen — e.g. after a transient
|
||||||
|
// 500 that health monitoring (transport-level) never saw.
|
||||||
|
if (hasInFlightRequest() || internal.value is UiState.Error) {
|
||||||
silentReload()
|
silentReload()
|
||||||
}
|
}
|
||||||
delay(POLL_INTERVAL_MS)
|
delay(POLL_INTERVAL_MS)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
SearchResultsState.Loading -> LoadingCentered()
|
||||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
is SearchResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SearchResultsState.Loaded -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -43,6 +45,7 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
runSearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a search that failed
|
||||||
|
// while the server was unreachable re-runs when health returns —
|
||||||
|
// the queryFlow pipeline can't, since the text hasn't changed.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.results is SearchResultsState.Error) retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
setQuery("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run the current query after a failure. Bypasses the queryFlow
|
||||||
|
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
|
||||||
|
* without this, a transient error pins "Search failed" until the
|
||||||
|
* user edits the query.
|
||||||
|
*/
|
||||||
|
fun retry() {
|
||||||
|
val q = internal.value.query.trim()
|
||||||
|
if (q.isEmpty()) return
|
||||||
|
viewModelScope.launch { runSearch(q) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tapping a search result builds a queue from the full visible
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* track-results list starting at the tapped entry, matching Flutter.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
|||||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
||||||
|
*
|
||||||
|
* Renders inside a single-item LazyColumn (same rationale as
|
||||||
|
* [EmptyState]) so the widget participates in nested-scroll dispatch
|
||||||
|
* under PullToRefreshBox — both the Retry button and a pull gesture
|
||||||
|
* can recover from the error state.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
Column(
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
modifier = modifier
|
item {
|
||||||
.fillMaxSize()
|
Column(
|
||||||
.padding(32.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.fillParentMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(32.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Icon(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
imageVector = Lucide.TriangleAlert,
|
) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(48.dp),
|
imageVector = Lucide.TriangleAlert,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(48.dp),
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.error,
|
||||||
text = message,
|
)
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
if (title != null) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Button(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = onRetry,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
)
|
||||||
colors = ButtonDefaults.buttonColors(
|
}
|
||||||
containerColor = actionColors.primary,
|
Text(
|
||||||
contentColor = actionColors.onAction,
|
text = message,
|
||||||
),
|
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(retryLabel)
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = actionColors.primary,
|
||||||
|
contentColor = actionColors.onAction,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(retryLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
|||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
|
|||||||
@ExtendWith(MainDispatcherExtension::class)
|
@ExtendWith(MainDispatcherExtension::class)
|
||||||
class LibraryViewModelTest {
|
class LibraryViewModelTest {
|
||||||
|
|
||||||
|
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
|
||||||
|
// and a relaxed-mock flow never emits, so the combine would never fire.
|
||||||
|
private fun syncControllerMock(): SyncController =
|
||||||
|
mockk<SyncController>(relaxed = true) {
|
||||||
|
every { lastSyncError } returns MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state is Loading before any DAO emission`() = runTest {
|
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||||
val repo = mockk<LibraryRepository>()
|
val repo = mockk<LibraryRepository>()
|
||||||
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type adminUserView struct {
|
|||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
AutoApproveRequests bool `json:"auto_approve_requests"`
|
AutoApproveRequests bool `json:"auto_approve_requests"`
|
||||||
|
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
IsAdmin: row.IsAdmin,
|
IsAdmin: row.IsAdmin,
|
||||||
AutoApproveRequests: row.AutoApproveRequests,
|
AutoApproveRequests: row.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: row.DebugModeEnabled,
|
||||||
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
@@ -120,6 +122,7 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: updated.DisplayName,
|
DisplayName: updated.DisplayName,
|
||||||
IsAdmin: updated.IsAdmin,
|
IsAdmin: updated.IsAdmin,
|
||||||
AutoApproveRequests: updated.AutoApproveRequests,
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: user.DisplayName,
|
DisplayName: user.DisplayName,
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
AutoApproveRequests: user.AutoApproveRequests,
|
AutoApproveRequests: user.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: user.DebugModeEnabled,
|
||||||
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -329,6 +333,7 @@ func (h *handlers) handleAdminAutoApproveToggle(w http.ResponseWriter, r *http.R
|
|||||||
DisplayName: updated.DisplayName,
|
DisplayName: updated.DisplayName,
|
||||||
IsAdmin: updated.IsAdmin,
|
IsAdmin: updated.IsAdmin,
|
||||||
AutoApproveRequests: updated.AutoApproveRequests,
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// load failures). Admin-only inbox; any user can report.
|
// load failures). Admin-only inbox; any user can report.
|
||||||
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
||||||
|
|
||||||
|
// Device diagnostics ingest (M9). Any signed-in user can
|
||||||
|
// POST a batch, but events are only stored when the
|
||||||
|
// account's debug_mode_enabled flag is on (handler no-ops
|
||||||
|
// otherwise). Admin views live under /admin/diagnostics.
|
||||||
|
authed.Post("/diagnostics", h.handleReportDiagnostics)
|
||||||
|
|
||||||
// Self-hosted in-app update channel (#397). Auth-gated to
|
// Self-hosted in-app update channel (#397). Auth-gated to
|
||||||
// prevent anonymous bandwidth abuse on the APK stream;
|
// prevent anonymous bandwidth abuse on the APK stream;
|
||||||
// /apk additionally per-user rate-limited.
|
// /apk additionally per-user rate-limited.
|
||||||
@@ -181,6 +187,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
||||||
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
||||||
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
||||||
|
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
|
||||||
|
|
||||||
|
// Device diagnostics timeline + device overview (M9).
|
||||||
|
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
|
||||||
|
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
|
||||||
|
|
||||||
admin.Get("/cover-sources", h.handleListCoverSources)
|
admin.Get("/cover-sources", h.handleListCoverSources)
|
||||||
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validDiagnosticKinds is the COARSE category whitelist. Mirrors the
|
||||||
|
// CHECK constraint in migration 0036 — keep both in sync (per the
|
||||||
|
// enum-CHECK-whitelist rule). The finer event discriminator + all
|
||||||
|
// event-specific fields live inside each event's payload, so new event
|
||||||
|
// variants do NOT require a new category here.
|
||||||
|
var validDiagnosticKinds = map[string]struct{}{
|
||||||
|
"connectivity": {},
|
||||||
|
"upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
|
||||||
|
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
|
||||||
|
"power": {},
|
||||||
|
"lifecycle": {},
|
||||||
|
"heartbeat": {},
|
||||||
|
"http": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDiagnosticBatch caps a single ingest call. The client buffers and
|
||||||
|
// flushes in batches of ~100; 500 leaves generous headroom while
|
||||||
|
// bounding a misbehaving client's per-request work.
|
||||||
|
const maxDiagnosticBatch = 500
|
||||||
|
|
||||||
|
// Diagnostics list paging. Larger than the generic parsePaging caps
|
||||||
|
// because the admin timeline is meant to be exported as a slice and
|
||||||
|
// handed off for analysis — a heartbeat-dense window has many rows.
|
||||||
|
const (
|
||||||
|
defaultDiagnosticPageSize = 500
|
||||||
|
maxDiagnosticPageSize = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// diagnosticEventIn is one event in a POST /api/diagnostics batch.
|
||||||
|
// OccurredAt is the client device clock as epoch milliseconds (may be
|
||||||
|
// skewed — the server also stamps received_at). Payload is opaque JSON
|
||||||
|
// carrying the event's sub-type + fields.
|
||||||
|
type diagnosticEventIn struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportDiagnosticsRequest is the body of POST /api/diagnostics — one
|
||||||
|
// batch from one device.
|
||||||
|
type reportDiagnosticsRequest struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppVersion *string `json:"app_version,omitempty"`
|
||||||
|
OsVersion *string `json:"os_version,omitempty"`
|
||||||
|
Events []diagnosticEventIn `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in
|
||||||
|
// user can call it, but events are only stored when the account's
|
||||||
|
// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the
|
||||||
|
// client can safely drop the batch (it gates locally on /api/me, this is
|
||||||
|
// the race-safe backstop for "admin just disabled debug").
|
||||||
|
func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !user.DebugModeEnabled {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req reportDiagnosticsRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ClientID) == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) == 0 {
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) > maxDiagnosticBatch {
|
||||||
|
writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range req.Events {
|
||||||
|
if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
for i := range req.Events {
|
||||||
|
ev := req.Events[i]
|
||||||
|
payload := ev.Payload
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
ClientID: req.ClientID,
|
||||||
|
AppVersion: req.AppVersion,
|
||||||
|
OsVersion: req.OsVersion,
|
||||||
|
Kind: ev.Kind,
|
||||||
|
Payload: payload,
|
||||||
|
OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDiagnosticView is one row in the admin timeline response. Payload
|
||||||
|
// is passed through verbatim as raw JSON so the SPA can render / export
|
||||||
|
// the structured event without the server re-shaping it.
|
||||||
|
type adminDiagnosticView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppVersion *string `json:"app_version,omitempty"`
|
||||||
|
OsVersion *string `json:"os_version,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
OccurredAt string `json:"occurred_at"`
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAdminDiagnostics implements GET /api/admin/diagnostics with
|
||||||
|
// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch
|
||||||
|
// millis) plus limit/offset. Newest-first; the SPA reverses for a
|
||||||
|
// chronological timeline + export.
|
||||||
|
func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
params, ok := h.buildDiagnosticsFilter(w, q)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminDiagnosticView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, adminDiagnosticView{
|
||||||
|
ID: uuidToString(row.ID),
|
||||||
|
UserID: uuidToString(row.UserID),
|
||||||
|
Username: row.Username,
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
AppVersion: row.AppVersion,
|
||||||
|
OsVersion: row.OsVersion,
|
||||||
|
Kind: row.Kind,
|
||||||
|
Payload: json.RawMessage(row.Payload),
|
||||||
|
OccurredAt: formatTimestamp(row.OccurredAt),
|
||||||
|
ReceivedAt: formatTimestamp(row.ReceivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiagnosticsFilter parses the query string into a query param
|
||||||
|
// struct, writing a 400 and returning ok=false on a malformed time.
|
||||||
|
func (h *handlers) buildDiagnosticsFilter(
|
||||||
|
w http.ResponseWriter, q map[string][]string,
|
||||||
|
) (dbq.ListAdminDiagnosticsParams, bool) {
|
||||||
|
get := func(k string) string {
|
||||||
|
if v, present := q[k]; present && len(v) > 0 {
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
limit, offset, err := parseDiagPaging(get("limit"), get("offset"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset"))
|
||||||
|
return dbq.ListAdminDiagnosticsParams{}, false
|
||||||
|
}
|
||||||
|
from, okFrom := parseDiagTime(get("from"))
|
||||||
|
to, okTo := parseDiagTime(get("to"))
|
||||||
|
if !okFrom || !okTo {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp"))
|
||||||
|
return dbq.ListAdminDiagnosticsParams{}, false
|
||||||
|
}
|
||||||
|
params := dbq.ListAdminDiagnosticsParams{
|
||||||
|
ClientID: optionalQuery(get("client_id")),
|
||||||
|
Kind: optionalQuery(get("kind")),
|
||||||
|
FromTs: from,
|
||||||
|
ToTs: to,
|
||||||
|
Off: int32(offset),
|
||||||
|
Lim: int32(limit),
|
||||||
|
}
|
||||||
|
if uid, valid := parseUUID(get("user_id")); valid {
|
||||||
|
params.UserID = uid
|
||||||
|
}
|
||||||
|
return params, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDiagnosticDeviceView is one device in the "who is reporting"
|
||||||
|
// overview / device filter dropdown.
|
||||||
|
type adminDiagnosticDeviceView struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
OsVersion string `json:"os_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
EventCount int64 `json:"event_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAdminDiagnosticDevices implements GET
|
||||||
|
// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting
|
||||||
|
// devices so the admin UI can populate its device filter.
|
||||||
|
func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var userID pgtype.UUID
|
||||||
|
if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid {
|
||||||
|
userID = uid
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminDiagnosticDeviceView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, adminDiagnosticDeviceView{
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
UserID: uuidToString(row.UserID),
|
||||||
|
Username: row.Username,
|
||||||
|
AppVersion: row.AppVersion,
|
||||||
|
OsVersion: row.OsVersion,
|
||||||
|
LastSeen: formatTimestamp(row.LastSeen),
|
||||||
|
EventCount: row.EventCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode.
|
||||||
|
type adminDebugModeReq struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminDebugModeToggle implements PUT
|
||||||
|
// /api/admin/users/{id}/debug-mode — flip an account's diagnostics
|
||||||
|
// opt-in remotely while a bug is live. Echoes the updated user.
|
||||||
|
func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetID, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req adminDebugModeReq
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{
|
||||||
|
ID: targetID,
|
||||||
|
DebugModeEnabled: req.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("admin: debug mode toggled",
|
||||||
|
"target_user", uuidToString(targetID), "enabled", req.Enabled)
|
||||||
|
writeJSON(w, http.StatusOK, adminUserView{
|
||||||
|
ID: uuidToString(updated.ID),
|
||||||
|
Username: updated.Username,
|
||||||
|
DisplayName: updated.DisplayName,
|
||||||
|
IsAdmin: updated.IsAdmin,
|
||||||
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiagPaging reads limit/offset with diagnostics-specific caps.
|
||||||
|
// Blank → defaults; out-of-range clamps; non-numeric → error.
|
||||||
|
func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) {
|
||||||
|
limit = defaultDiagnosticPageSize
|
||||||
|
if s := strings.TrimSpace(rawLimit); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, perr
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > maxDiagnosticPageSize {
|
||||||
|
n = maxDiagnosticPageSize
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(rawOffset); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, perr
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limit, offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalQuery returns nil for an absent/blank query value so the sqlc
|
||||||
|
// NULL-or-equals filter treats it as "no filter."
|
||||||
|
func optionalQuery(raw string) *string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL
|
||||||
|
// filter, ok=true). Returns ok=false only on a non-empty unparseable value.
|
||||||
|
func parseDiagTime(raw string) (pgtype.Timestamptz, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return pgtype.Timestamptz{}, true
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true
|
||||||
|
}
|
||||||
|
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true
|
||||||
|
}
|
||||||
|
return pgtype.Timestamptz{}, false
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ type meProfileResp struct {
|
|||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
// DebugModeEnabled is the account's diagnostics opt-in. The client
|
||||||
|
// reads it here to decide whether to run the on-device diagnostics
|
||||||
|
// reporter (M9). Admin-set; the client also has a local OFF switch.
|
||||||
|
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -120,10 +124,11 @@ func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func profileViewFromUser(u dbq.User) meProfileResp {
|
func profileViewFromUser(u dbq.User) meProfileResp {
|
||||||
return meProfileResp{
|
return meProfileResp{
|
||||||
ID: uuidToString(u.ID),
|
ID: uuidToString(u.ID),
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
|
DebugModeEnabled: u.DebugModeEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: diagnostics.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows
|
||||||
|
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Retention sweep for the gc worker. Deletes diagnostic_events older
|
||||||
|
// than 30 days by server clock (received_at) so a skewed client clock
|
||||||
|
// can't keep rows alive. Matches the other gc lifecycle sweeps'
|
||||||
|
// hardcoded-threshold style; diagnostics are debug telemetry, not user
|
||||||
|
// data, and 30 days is ample to investigate a live incident after the
|
||||||
|
// fact. Returns the affected row count for the sweep log line.
|
||||||
|
func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, gcPruneDiagnostics)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one
|
||||||
|
INSERT INTO diagnostic_events (
|
||||||
|
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, received_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertDiagnosticEventParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ClientID string
|
||||||
|
AppVersion *string
|
||||||
|
OsVersion *string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
OccurredAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type InsertDiagnosticEventRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
ReceivedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records one client-reported diagnostic event. The writer validates the
|
||||||
|
// `kind` category against its whitelist before this runs; the CHECK
|
||||||
|
// constraint is the belt-and-braces guard. payload carries the finer
|
||||||
|
// event discriminator + all event-specific fields. Returns received_at
|
||||||
|
// so the caller can confirm ingest.
|
||||||
|
func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertDiagnosticEvent,
|
||||||
|
arg.UserID,
|
||||||
|
arg.ClientID,
|
||||||
|
arg.AppVersion,
|
||||||
|
arg.OsVersion,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Payload,
|
||||||
|
arg.OccurredAt,
|
||||||
|
)
|
||||||
|
var i InsertDiagnosticEventRow
|
||||||
|
err := row.Scan(&i.ID, &i.ReceivedAt)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many
|
||||||
|
SELECT
|
||||||
|
de.id AS id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.app_version AS app_version,
|
||||||
|
de.os_version AS os_version,
|
||||||
|
de.kind AS kind,
|
||||||
|
de.payload AS payload,
|
||||||
|
de.occurred_at AS occurred_at,
|
||||||
|
de.received_at AS received_at
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||||
|
AND ($2::text IS NULL OR de.client_id = $2::text)
|
||||||
|
AND ($3::text IS NULL OR de.kind = $3::text)
|
||||||
|
AND ($4::timestamptz IS NULL
|
||||||
|
OR de.occurred_at >= $4::timestamptz)
|
||||||
|
AND ($5::timestamptz IS NULL
|
||||||
|
OR de.occurred_at <= $5::timestamptz)
|
||||||
|
ORDER BY de.occurred_at DESC
|
||||||
|
LIMIT $7::int OFFSET $6::int
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAdminDiagnosticsParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ClientID *string
|
||||||
|
Kind *string
|
||||||
|
FromTs pgtype.Timestamptz
|
||||||
|
ToTs pgtype.Timestamptz
|
||||||
|
Off int32
|
||||||
|
Lim int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAdminDiagnosticsRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Username string
|
||||||
|
ClientID string
|
||||||
|
AppVersion *string
|
||||||
|
OsVersion *string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
OccurredAt pgtype.Timestamptz
|
||||||
|
ReceivedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin timeline query. All filters are optional (NULL = no filter):
|
||||||
|
// account (user_id), device (client_id), category (kind), and the
|
||||||
|
// occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
||||||
|
// a chronological timeline / export. Joined with users for the username
|
||||||
|
// so the view renders without a second round-trip.
|
||||||
|
func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAdminDiagnostics,
|
||||||
|
arg.UserID,
|
||||||
|
arg.ClientID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.FromTs,
|
||||||
|
arg.ToTs,
|
||||||
|
arg.Off,
|
||||||
|
arg.Lim,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAdminDiagnosticsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAdminDiagnosticsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Username,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.AppVersion,
|
||||||
|
&i.OsVersion,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Payload,
|
||||||
|
&i.OccurredAt,
|
||||||
|
&i.ReceivedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many
|
||||||
|
SELECT
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
max(de.app_version)::text AS app_version,
|
||||||
|
max(de.os_version)::text AS os_version,
|
||||||
|
max(de.occurred_at)::timestamptz AS last_seen,
|
||||||
|
count(*) AS event_count
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||||
|
GROUP BY de.client_id, de.user_id, u.username
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDiagnosticDevicesRow struct {
|
||||||
|
ClientID string
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Username string
|
||||||
|
AppVersion string
|
||||||
|
OsVersion string
|
||||||
|
LastSeen pgtype.Timestamptz
|
||||||
|
EventCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct devices that have reported, for the admin device filter +
|
||||||
|
// "who is currently reporting" overview. Optionally scoped to one account.
|
||||||
|
func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listDiagnosticDevices, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListDiagnosticDevicesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListDiagnosticDevicesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ClientID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Username,
|
||||||
|
&i.AppVersion,
|
||||||
|
&i.OsVersion,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.EventCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -265,6 +265,18 @@ type CoverArtSourcesMetum struct {
|
|||||||
LastRegisteredProvidersHash string
|
LastRegisteredProvidersHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiagnosticEvent struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ClientID string
|
||||||
|
AppVersion *string
|
||||||
|
OsVersion *string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
OccurredAt pgtype.Timestamptz
|
||||||
|
ReceivedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type GeneralLike struct {
|
type GeneralLike struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
@@ -552,6 +564,7 @@ type User struct {
|
|||||||
Email *string
|
Email *string
|
||||||
Timezone string
|
Timezone string
|
||||||
TimezoneUpdatedAt pgtype.Timestamptz
|
TimezoneUpdatedAt pgtype.Timestamptz
|
||||||
|
DebugModeEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInvite struct {
|
type UserInvite struct {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
|||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
@@ -89,6 +89,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserAdminParams struct {
|
type CreateUserAdminParams struct {
|
||||||
@@ -134,6 +135,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -145,7 +147,7 @@ VALUES (
|
|||||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||||
$4
|
$4
|
||||||
)
|
)
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserFirstAdminRaceParams struct {
|
type CreateUserFirstAdminRaceParams struct {
|
||||||
@@ -191,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -237,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
|
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
|
||||||
@@ -258,12 +261,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1)
|
||||||
`
|
`
|
||||||
|
|
||||||
// Used by forgot-password lookup. Lowercase comparison both sides
|
// Used by forgot-password lookup. Lowercase comparison both sides
|
||||||
@@ -287,12 +291,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||||
@@ -313,12 +318,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
@@ -339,6 +345,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -382,7 +389,8 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listUsers = `-- name: ListUsers :many
|
const listUsers = `-- name: ListUsers :many
|
||||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
||||||
|
debug_mode_enabled, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -393,6 +401,7 @@ type ListUsersRow struct {
|
|||||||
DisplayName *string
|
DisplayName *string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
AutoApproveRequests bool
|
AutoApproveRequests bool
|
||||||
|
DebugModeEnabled bool
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +421,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -426,7 +436,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
|
|
||||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||||
UPDATE users SET api_token = $2 WHERE id = $1
|
UPDATE users SET api_token = $2 WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type RegenerateApiTokenParams struct {
|
type RegenerateApiTokenParams struct {
|
||||||
@@ -454,6 +464,7 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -478,6 +489,44 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setDebugMode = `-- name: SetDebugMode :one
|
||||||
|
UPDATE users
|
||||||
|
SET debug_mode_enabled = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDebugModeParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
DebugModeEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
||||||
|
// from /admin (flip remotely while a bug is live) or self-driven OFF from
|
||||||
|
// the client. Returns the updated row so the handler can echo it.
|
||||||
|
func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, setDebugMode, arg.ID, arg.DebugModeEnabled)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.ApiToken,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.SubsonicPassword,
|
||||||
|
&i.ListenbrainzToken,
|
||||||
|
&i.ListenbrainzEnabled,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET listenbrainz_enabled = $2
|
SET listenbrainz_enabled = $2
|
||||||
@@ -531,7 +580,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_admin = $2
|
SET is_admin = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserAdminParams struct {
|
type UpdateUserAdminParams struct {
|
||||||
@@ -559,6 +608,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -567,7 +617,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET auto_approve_requests = $2
|
SET auto_approve_requests = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserAutoApproveParams struct {
|
type UpdateUserAutoApproveParams struct {
|
||||||
@@ -594,6 +644,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -603,7 +654,7 @@ UPDATE users
|
|||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
email = $3
|
email = $3
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserProfileParams struct {
|
type UpdateUserProfileParams struct {
|
||||||
@@ -632,6 +683,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled;
|
||||||
|
DROP TABLE IF EXISTS diagnostic_events;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Device diagnostics / debug-reporting subsystem (M9).
|
||||||
|
--
|
||||||
|
-- When an account has users.debug_mode_enabled = true, that account's
|
||||||
|
-- client(s) stream a timeseries of device state here: connectivity +
|
||||||
|
-- server-health transitions (roaming / poor-data recovery), UPnP/Sonos
|
||||||
|
-- sync events (locked-phone desync), and power/lifecycle state (Doze /
|
||||||
|
-- battery-optimization, the prime suspect for the desync). The admin
|
||||||
|
-- /admin/diagnostics view filters by account + device + time window and
|
||||||
|
-- exports the slice as JSON for offline analysis.
|
||||||
|
--
|
||||||
|
-- `kind` is a COARSE category gated by a CHECK whitelist; the finer
|
||||||
|
-- event discriminator + all event-specific fields live in the `payload`
|
||||||
|
-- jsonb. This keeps the enum small and stable so new event variants
|
||||||
|
-- don't require a migration (per the enum-CHECK-whitelist rule, only a
|
||||||
|
-- new *category* would). If you add a category here, mirror it in the
|
||||||
|
-- handler whitelist in internal/api/diagnostics.go.
|
||||||
|
--
|
||||||
|
-- Two timestamps on purpose: occurred_at is the client device clock
|
||||||
|
-- (when the event happened on the phone — may be skewed/wrong, e.g. in
|
||||||
|
-- a dead zone), received_at is the server clock at ingest. Retention
|
||||||
|
-- pruning is keyed on received_at so a bad client clock can't keep rows
|
||||||
|
-- alive forever.
|
||||||
|
|
||||||
|
CREATE TABLE diagnostic_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
client_id text NOT NULL,
|
||||||
|
app_version text,
|
||||||
|
os_version text,
|
||||||
|
kind text NOT NULL,
|
||||||
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT diagnostic_events_kind_check
|
||||||
|
CHECK (kind IN (
|
||||||
|
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||||
|
'heartbeat', 'http'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admin reads filter by account then device, ordered newest-first.
|
||||||
|
CREATE INDEX idx_diagnostic_events_user
|
||||||
|
ON diagnostic_events (user_id, occurred_at DESC);
|
||||||
|
CREATE INDEX idx_diagnostic_events_client
|
||||||
|
ON diagnostic_events (client_id, occurred_at DESC);
|
||||||
|
-- Retention pruner deletes by server-clock age.
|
||||||
|
CREATE INDEX idx_diagnostic_events_received
|
||||||
|
ON diagnostic_events (received_at);
|
||||||
|
|
||||||
|
-- Per-account opt-in for diagnostics reporting. Admin-set primarily
|
||||||
|
-- (flip remotely while a bug is live); the client also exposes a local
|
||||||
|
-- OFF switch. Default false: no account reports until explicitly enabled.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS debug_mode_enabled boolean NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Drop rows using the new value first so they don't violate the restored
|
||||||
|
-- (narrower) constraint, then revert the whitelist.
|
||||||
|
DELETE FROM diagnostic_events WHERE kind = 'playback';
|
||||||
|
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
|
||||||
|
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
|
||||||
|
CHECK (kind IN (
|
||||||
|
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||||
|
'heartbeat', 'http'
|
||||||
|
));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
|
||||||
|
--
|
||||||
|
-- `route` and `player_state` events fire for EVERY output route (phone
|
||||||
|
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
|
||||||
|
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
|
||||||
|
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
|
||||||
|
-- (drops, and future resync/desync); general playback + route telemetry
|
||||||
|
-- moves to the new 'playback' kind.
|
||||||
|
--
|
||||||
|
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
|
||||||
|
-- re-adding the constraint in the same change. Existing rows tagged
|
||||||
|
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
|
||||||
|
|
||||||
|
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
|
||||||
|
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
|
||||||
|
CHECK (kind IN (
|
||||||
|
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||||
|
'heartbeat', 'http', 'playback'
|
||||||
|
));
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- name: InsertDiagnosticEvent :one
|
||||||
|
-- Records one client-reported diagnostic event. The writer validates the
|
||||||
|
-- `kind` category against its whitelist before this runs; the CHECK
|
||||||
|
-- constraint is the belt-and-braces guard. payload carries the finer
|
||||||
|
-- event discriminator + all event-specific fields. Returns received_at
|
||||||
|
-- so the caller can confirm ingest.
|
||||||
|
INSERT INTO diagnostic_events (
|
||||||
|
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, received_at;
|
||||||
|
|
||||||
|
-- name: ListAdminDiagnostics :many
|
||||||
|
-- Admin timeline query. All filters are optional (NULL = no filter):
|
||||||
|
-- account (user_id), device (client_id), category (kind), and the
|
||||||
|
-- occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
||||||
|
-- a chronological timeline / export. Joined with users for the username
|
||||||
|
-- so the view renders without a second round-trip.
|
||||||
|
SELECT
|
||||||
|
de.id AS id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.app_version AS app_version,
|
||||||
|
de.os_version AS os_version,
|
||||||
|
de.kind AS kind,
|
||||||
|
de.payload AS payload,
|
||||||
|
de.occurred_at AS occurred_at,
|
||||||
|
de.received_at AS received_at
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
||||||
|
AND (sqlc.narg(client_id)::text IS NULL OR de.client_id = sqlc.narg(client_id)::text)
|
||||||
|
AND (sqlc.narg(kind)::text IS NULL OR de.kind = sqlc.narg(kind)::text)
|
||||||
|
AND (sqlc.narg(from_ts)::timestamptz IS NULL
|
||||||
|
OR de.occurred_at >= sqlc.narg(from_ts)::timestamptz)
|
||||||
|
AND (sqlc.narg(to_ts)::timestamptz IS NULL
|
||||||
|
OR de.occurred_at <= sqlc.narg(to_ts)::timestamptz)
|
||||||
|
ORDER BY de.occurred_at DESC
|
||||||
|
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
|
||||||
|
|
||||||
|
-- name: ListDiagnosticDevices :many
|
||||||
|
-- Distinct devices that have reported, for the admin device filter +
|
||||||
|
-- "who is currently reporting" overview. Optionally scoped to one account.
|
||||||
|
SELECT
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
max(de.app_version)::text AS app_version,
|
||||||
|
max(de.os_version)::text AS os_version,
|
||||||
|
max(de.occurred_at)::timestamptz AS last_seen,
|
||||||
|
count(*) AS event_count
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
||||||
|
GROUP BY de.client_id, de.user_id, u.username
|
||||||
|
ORDER BY last_seen DESC;
|
||||||
|
|
||||||
|
-- name: GcPruneDiagnostics :execrows
|
||||||
|
-- Retention sweep for the gc worker. Deletes diagnostic_events older
|
||||||
|
-- than 30 days by server clock (received_at) so a skewed client clock
|
||||||
|
-- can't keep rows alive. Matches the other gc lifecycle sweeps'
|
||||||
|
-- hardcoded-threshold style; diagnostics are debug telemetry, not user
|
||||||
|
-- data, and 30 days is ample to investigate a live incident after the
|
||||||
|
-- fact. Returns the affected row count for the sweep log line.
|
||||||
|
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days';
|
||||||
@@ -55,7 +55,8 @@ WHERE id = $1;
|
|||||||
|
|
||||||
-- name: ListUsers :many
|
-- name: ListUsers :many
|
||||||
-- Admin user-management list. Sort newest-first.
|
-- Admin user-management list. Sort newest-first.
|
||||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
||||||
|
debug_mode_enabled, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
@@ -115,6 +116,15 @@ UPDATE users
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetDebugMode :one
|
||||||
|
-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
||||||
|
-- from /admin (flip remotely while a bug is live) or self-driven OFF from
|
||||||
|
-- the client. Returns the updated row so the handler can echo it.
|
||||||
|
UPDATE users
|
||||||
|
SET debug_mode_enabled = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ChangeUserPassword :exec
|
-- name: ChangeUserPassword :exec
|
||||||
-- Self-service password change. Caller (HTTP handler) verifies the
|
-- Self-service password change. Caller (HTTP handler) verifies the
|
||||||
-- current password before calling this. Distinct from
|
-- current password before calling this. Distinct from
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// - GcExpireScrobbleQueueFailedRows (#567)
|
// - GcExpireScrobbleQueueFailedRows (#567)
|
||||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||||
// - GcDeleteExpiredPasswordResets (#575)
|
// - GcDeleteExpiredPasswordResets (#575)
|
||||||
|
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||||
package gc
|
package gc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -82,6 +83,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
|||||||
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
||||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||||
|
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSweep is a small adapter so each sweep call site is a one-liner
|
// runSweep is a small adapter so each sweep call site is a one-liner
|
||||||
|
|||||||
@@ -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' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders all six tabs in order', () => {
|
test('renders all seven tabs in order', () => {
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
render(AdminTabs);
|
render(AdminTabs);
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
@@ -62,6 +62,7 @@ describe('AdminTabs', () => {
|
|||||||
'Requests',
|
'Requests',
|
||||||
'Quarantine',
|
'Quarantine',
|
||||||
'Playback errors',
|
'Playback errors',
|
||||||
|
'Diagnostics',
|
||||||
'Users'
|
'Users'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<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',
|
||||||
|
'playback',
|
||||||
|
'upnp_sync',
|
||||||
|
'power',
|
||||||
|
'lifecycle',
|
||||||
|
'heartbeat',
|
||||||
|
'http'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function kindLabel(k: string): string {
|
||||||
|
switch (k) {
|
||||||
|
case 'connectivity': return 'Connectivity';
|
||||||
|
case 'playback': return 'Playback';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view = the most recent N events, no time window. The
|
||||||
|
// start/end window is an Advanced affordance.
|
||||||
|
const DEFAULT_LIMIT = 500;
|
||||||
|
|
||||||
|
// Filter state.
|
||||||
|
let accountId = $state('');
|
||||||
|
let clientId = $state('');
|
||||||
|
let kind = $state('');
|
||||||
|
let fromLocal = $state('');
|
||||||
|
let toLocal = $state('');
|
||||||
|
let limit = $state(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
// True when an explicit time window is set (drives the caption wording).
|
||||||
|
const hasWindow = $derived(Boolean(fromLocal || toLocal));
|
||||||
|
|
||||||
|
function resetWindow() {
|
||||||
|
fromLocal = '';
|
||||||
|
toLocal = '';
|
||||||
|
limit = DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ?? []);
|
||||||
|
|
||||||
|
// Display sort. The API returns newest-first; default the view to that
|
||||||
|
// (most recent at the top), with an Oldest-first option for reading a
|
||||||
|
// timeline top-to-bottom. Export follows whatever's displayed.
|
||||||
|
let sortOrder = $state<'newest' | 'oldest'>('newest');
|
||||||
|
|
||||||
|
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
|
||||||
|
const diagQuery = $derived($diagStore);
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
const data = [...((diagQuery.data ?? []) as AdminDiagnostic[])]; // newest-first
|
||||||
|
return sortOrder === 'oldest' ? data.reverse() : data;
|
||||||
|
});
|
||||||
|
|
||||||
|
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. Default view = most recent {DEFAULT_LIMIT} events, no time
|
||||||
|
window; the start/end window lives under Advanced. -->
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div 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">
|
||||||
|
Sort
|
||||||
|
<select
|
||||||
|
bind:value={sortOrder}
|
||||||
|
class="mt-1 block w-36 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="newest">Newest first</option>
|
||||||
|
<option value="oldest">Oldest first</option>
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced: explicit start/end window + row cap. Collapsed by
|
||||||
|
default so the common case (recent N) needs no interaction. -->
|
||||||
|
<details class="rounded-md border border-border" open={hasWindow}>
|
||||||
|
<summary class="cursor-pointer px-3 py-2 text-xs text-text-secondary">
|
||||||
|
Advanced filters{hasWindow ? ' · window active' : ''}
|
||||||
|
</summary>
|
||||||
|
<div class="flex flex-wrap items-end gap-3 border-t border-border px-3 py-3">
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetWindow}
|
||||||
|
class="rounded border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
Reset to recent {DEFAULT_LIMIT}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</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">
|
||||||
|
{#if hasWindow}
|
||||||
|
{rows.length} events in window ({sortOrder === 'newest' ? 'newest' : 'oldest'} first)
|
||||||
|
{:else}
|
||||||
|
Most recent {rows.length} events ({sortOrder === 'newest' ? 'newest' : 'oldest'} first) · set a time window under Advanced
|
||||||
|
{/if}
|
||||||
|
</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>
|
||||||
@@ -38,6 +38,7 @@ const alice: AdminUser = {
|
|||||||
display_name: null,
|
display_name: null,
|
||||||
is_admin: true,
|
is_admin: true,
|
||||||
auto_approve_requests: false,
|
auto_approve_requests: false,
|
||||||
|
debug_mode_enabled: false,
|
||||||
created_at: '2026-05-01T00:00:00Z'
|
created_at: '2026-05-01T00:00:00Z'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ const bob: AdminUser = {
|
|||||||
display_name: 'Bob B',
|
display_name: 'Bob B',
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
auto_approve_requests: false,
|
auto_approve_requests: false,
|
||||||
|
debug_mode_enabled: false,
|
||||||
created_at: '2026-05-02T00:00:00Z'
|
created_at: '2026-05-02T00:00:00Z'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user