diff --git a/app/src/main/kotlin/app/getarcane/android/core/ActivityCenterStore.kt b/app/src/main/kotlin/app/getarcane/android/core/ActivityCenterStore.kt index db288b4..0a99446 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ActivityCenterStore.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ActivityCenterStore.kt @@ -15,7 +15,6 @@ import app.getarcane.sdk.models.activity.Activity import app.getarcane.sdk.models.activity.ActivityMessage import app.getarcane.sdk.models.activity.ActivityStreamEvent import app.getarcane.sdk.models.activity.ActivityStreamEventType -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.base.SortOrder import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -319,11 +318,7 @@ class ActivityCenterStore(private val scope: CoroutineScope) { values.filter { it.isNotEmpty() }.toSortedSet(String.CASE_INSENSITIVE_ORDER).toList() private suspend fun resolveEnvironments(client: ArcaneClient): List { - val items = runCatching { - client.environments.list( - query = SearchPaginationSort(start = 0, limit = 100, sortOrder = SortOrder.ASCENDING), - ).data - }.getOrNull().orEmpty() + val items = loadCompleteEnvironments { query -> client.environments.list(query) } return items.map { environment -> ActivityEnvironment( id = EnvironmentId(environment.id), diff --git a/app/src/main/kotlin/app/getarcane/android/core/CompleteListLoader.kt b/app/src/main/kotlin/app/getarcane/android/core/CompleteListLoader.kt new file mode 100644 index 0000000..3dced6f --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/CompleteListLoader.kt @@ -0,0 +1,91 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.models.base.SearchPaginationSort +import app.getarcane.sdk.models.base.SortOrder +import app.getarcane.sdk.models.environment.Environment +import app.getarcane.sdk.pagination.PaginatedResponse + +internal const val COMPLETE_LIST_LIMIT = -1 + +internal data class CompleteListResponse( + val items: List, + val totalItems: Long, + val success: Boolean = true, +) + +internal class IncompleteCollectionException(message: String) : IllegalStateException(message) + +internal fun completeListQuery( + sortBy: String? = null, + sortOrder: SortOrder? = null, +): SearchPaginationSort = + SearchPaginationSort( + start = 0, + limit = COMPLETE_LIST_LIMIT, + sortBy = sortBy, + sortOrder = sortOrder, + ) + +/** + * Normalizes one endpoint-supported show-all response without ever publishing a partial result. + * Duplicate identities are collapsed in server order. The server may count either raw rows or + * unique identities, but any other reported total means the response is incomplete or malformed. + */ +internal fun normalizeCompleteCollection( + resourceName: String, + response: CompleteListResponse, + idOf: (T) -> K, +): List { + if (!response.success) { + throw IncompleteCollectionException("$resourceName response was unsuccessful") + } + val itemsById = linkedMapOf() + response.items.forEach { item -> itemsById.putIfAbsent(idOf(item), item) } + + val rawItemCount = response.items.size.toLong() + val uniqueItemCount = itemsById.size.toLong() + if (response.totalItems != rawItemCount && + response.totalItems != uniqueItemCount + ) { + throw IncompleteCollectionException( + "$resourceName response counts: raw=$rawItemCount, unique=$uniqueItemCount; " + + "server reported ${response.totalItems}", + ) + } + return itemsById.values.toList() +} + +internal suspend fun loadCompleteCollection( + resourceName: String, + idOf: (T) -> K, + loadAll: suspend () -> CompleteListResponse, +): List = normalizeCompleteCollection(resourceName, loadAll(), idOf) + +internal suspend fun loadCompletePaginatedCollection( + resourceName: String, + idOf: (T) -> K, + loadAll: suspend () -> PaginatedResponse, +): List { + return loadCompleteCollection( + resourceName = resourceName, + idOf = idOf, + loadAll = { + val response = loadAll() + CompleteListResponse(response.data, response.pagination.totalItems, response.success) + }, + ) +} + +/** All visible environments, using the server's documented finite show-all request. */ +internal suspend fun loadCompleteEnvironments( + loadAll: suspend (SearchPaginationSort) -> PaginatedResponse, +): List = + loadCompletePaginatedCollection( + resourceName = "Environment", + idOf = Environment::id, + loadAll = { + loadAll( + completeListQuery(sortBy = "id", sortOrder = SortOrder.ASCENDING), + ) + }, + ) diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardOverviewSource.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardOverviewSource.kt index 3fd9796..596664c 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardOverviewSource.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardOverviewSource.kt @@ -5,8 +5,11 @@ import app.getarcane.sdk.models.base.arrayValue import app.getarcane.sdk.models.base.boolValue import app.getarcane.sdk.models.base.objectValue import app.getarcane.sdk.models.base.stringValue +import app.getarcane.sdk.models.dashboard.ActionItemKind +import app.getarcane.sdk.models.dashboard.ActionItems import app.getarcane.sdk.models.dashboard.DashboardEnvironmentOverview import app.getarcane.sdk.models.dashboard.DashboardEnvironmentsOverview +import app.getarcane.sdk.models.dashboard.EnvironmentSnapshotState import app.getarcane.sdk.models.environment.Environment data class DashboardEnvironmentCardOverviewCounts( @@ -31,6 +34,19 @@ internal fun DashboardEnvironmentsOverview.toDashTotals( stopped = summary.containers.stoppedContainers, ) +internal fun ActionItems.imageUpdateActionCount(): Int = + items.firstOrNull { it.kind == ActionItemKind.IMAGE_UPDATES } + ?.count + ?.coerceAtLeast(0) + ?: 0 + +internal fun DashboardEnvironmentsOverview.imageUpdateActionCount(): Int? { + if (environments.any { it.snapshotState == EnvironmentSnapshotState.ERROR }) return null + return environments + .filter { it.snapshotState == EnvironmentSnapshotState.READY } + .sumOf { it.actionItems.imageUpdateActionCount() } +} + internal fun DashboardEnvironmentOverview.cardOverviewCounts(): DashboardEnvironmentCardOverviewCounts = DashboardEnvironmentCardOverviewCounts( running = containers.runningContainers, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt index c74bb56..aa018e1 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt @@ -49,9 +49,13 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.LocalPinnedStore import app.getarcane.android.core.PinnedItemsStore +import app.getarcane.android.core.CompleteListResponse +import app.getarcane.android.core.completeListQuery import app.getarcane.android.core.displayName import app.getarcane.android.core.friendlyErrorMessage import app.getarcane.android.core.isRunning +import app.getarcane.android.core.loadCompleteCollection +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ResourceStatusBadge import app.getarcane.android.ui.theme.ArcaneBlue import app.getarcane.android.ui.theme.ArcaneGreen @@ -60,10 +64,10 @@ import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.android.ui.theme.ArcaneTeal import app.getarcane.android.ui.theme.StatusRunning import app.getarcane.android.ui.theme.StatusUnknown -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.container.ContainerSummary import app.getarcane.sdk.models.project.ProjectDetails import app.getarcane.sdk.models.volume.Volume as SdkVolume +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @Composable @@ -101,28 +105,42 @@ fun DashboardPinnedSection( loading = containers.isEmpty() && projects.isEmpty() && volumes.isEmpty() containers = if (pinnedContainers.isNotEmpty()) { - runCatching { client.containers.list(envId = envId).data.filter { it.id in pinnedContainers } } - .getOrDefault(emptyList()) + try { + loadCompleteCollection("Container", ContainerSummary::id) { + val response = client.containers.list(envId = envId, query = completeListQuery()) + CompleteListResponse(response.data, response.pagination.totalItems, response.success) + }.filter { it.id in pinnedContainers } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + emptyList() + } } else { emptyList() } projects = if (pinnedProjects.isNotEmpty()) { - runCatching { - client.projects.list( - envId = envId, - query = SearchPaginationSort(start = 0, limit = 500), - ).data.filter { it.id in pinnedProjects } - }.getOrDefault(emptyList()) + try { + loadCompletePaginatedCollection("Project", ProjectDetails::id) { + client.projects.list(envId = envId, query = completeListQuery()) + }.filter { it.id in pinnedProjects } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + emptyList() + } } else { emptyList() } volumes = if (pinnedVolumes.isNotEmpty()) { - runCatching { - client.volumes.list( - envId = envId, - query = SearchPaginationSort(start = 0, limit = 500), - ).data.filter { it.id in pinnedVolumes } - }.getOrDefault(emptyList()) + try { + loadCompletePaginatedCollection("Volume", SdkVolume::id) { + client.volumes.list(envId = envId, query = completeListQuery()) + }.filter { it.id in pinnedVolumes } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + emptyList() + } } else { emptyList() } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt index 2f2134f..04c57ed 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle +import app.getarcane.android.core.loadCompleteEnvironments import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import app.getarcane.android.core.formatBytes @@ -80,7 +81,6 @@ import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage import app.getarcane.android.nav.AppTab import app.getarcane.android.ui.screens.activities.ActivitiesTab -import app.getarcane.android.ui.screens.activities.sortTime import app.getarcane.android.ui.screens.settings.FormErrorRow import app.getarcane.android.ui.screens.settings.FormSuccessRow import app.getarcane.android.ui.screens.settings.LabeledPicker @@ -92,7 +92,6 @@ import app.getarcane.android.ui.theme.ArcanePurple import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.android.ui.theme.ArcaneTeal import app.getarcane.sdk.EnvironmentId -import app.getarcane.sdk.models.activity.Activity import app.getarcane.sdk.models.activity.ActivityStatus import app.getarcane.sdk.models.base.SortOrder import app.getarcane.sdk.models.dashboard.DashboardEnvironmentOverview @@ -165,7 +164,7 @@ fun DashboardScreen( mutableStateOf>(emptyMap()) } var totals by remember { mutableStateOf(null) } - var failedActivities by remember { mutableStateOf>(emptyList()) } + var failedActivityCount by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(false) } var refreshKey by remember { mutableStateOf(0) } var statsRestartKey by remember { mutableStateOf(0) } @@ -243,15 +242,30 @@ fun DashboardScreen( LaunchedEffect(refreshKey) { if (client == null) return@LaunchedEffect loading = true - val overview = runCatching { client.dashboard.environmentsOverview() }.getOrNull() + val overview = try { + client.dashboard.environmentsOverview() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } val overviewEnvironments = overview?.environmentsForDashboard().orEmpty() - val envs = overviewEnvironments.ifEmpty { - runCatching { client.environments.list().data }.getOrElse { + var environmentLoadSucceeded = true + val envs = if (overviewEnvironments.isNotEmpty()) { + overviewEnvironments + } else { + try { + loadCompleteEnvironments { query -> client.environments.list(query) } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + environmentLoadSucceeded = false // Fall back to the active environment so the dashboard still shows a card. listOf(Environment(id = envId.rawValue, name = manager.activeEnvironmentName, apiUrl = "", status = "active")) } } environments = envs + val loadableEnvironments = envs.filter { it.enabled } overviewByEnvironmentId = overview?.environments.orEmpty() .associateBy { it.environmentForDashboard()?.id } .filterKeys { it != null } @@ -259,56 +273,61 @@ fun DashboardScreen( val overviewTotals = overview?.toDashTotals() totals = overviewTotals - // Aggregate the slow overview tiles across ALL environments (parallel, best-effort per env). - val volumesAndUpdates = runCatching { + // Aggregate the slow overview tiles across every environment. A partial fleet result is not + // displayed as a complete total. + val volumesByEnvironment = if (!environmentLoadSucceeded) { + null + } else try { coroutineScope { - envs.map { env -> + loadableEnvironments.map { env -> val e = EnvironmentId(env.id) async { - val vc = runCatching { client.volumes.counts(envId = e) }.getOrNull() - val us = runCatching { client.images.updateSummary(envId = e) }.getOrNull() - intArrayOf( - vc?.total ?: 0, - us?.imagesWithUpdates ?: 0, - ) + client.volumes.counts(envId = e).total } }.awaitAll() } - }.getOrNull() - val volumes = volumesAndUpdates?.sumOf { it[0] } - val updates = volumesAndUpdates?.sumOf { it[1] } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + val volumes = volumesByEnvironment?.sum() + val updates = overview?.imageUpdateActionCount() + ?: if (environmentLoadSucceeded) loadDashboardImageUpdateCount(client, loadableEnvironments) else null totals = overviewTotals?.copy(volumes = volumes, updates = updates) - ?: loadLegacyDashboardTotals(client, envs, volumes = volumes, updates = updates) + ?: loadLegacyDashboardTotals(client, loadableEnvironments, volumes = volumes, updates = updates) - // Surface recent failed background work (RBAC servers only). Best-effort, per environment, - // limited to the 3 most recent failures across environments. Mirrors iOS `loadFailedWork()`. - failedActivities = if (supportsActivities) { - runCatching { + // Count failed background work across every environment. Do not publish a partial count when + // one environment fails, because the toolbar badge and attention row imply a fleet total. + failedActivityCount = if (supportsActivities && environmentLoadSucceeded) { + try { coroutineScope { - envs.map { env -> + loadableEnvironments.map { env -> async { - runCatching { - client.activities.listPaginated( - envId = EnvironmentId(env.id), - order = SortOrder.DESCENDING, - start = 0, - limit = 20, - status = ActivityStatus.FAILED, - ).data - }.getOrDefault(emptyList()) + client.activities.listPaginated( + envId = EnvironmentId(env.id), + order = SortOrder.DESCENDING, + start = 0, + limit = 1, + status = ActivityStatus.FAILED, + ).pagination.totalItems } - }.awaitAll() - }.flatten() - .filter { it.status == ActivityStatus.FAILED } - .sortedByDescending { it.sortTime } - .take(3) - }.getOrDefault(emptyList()) + }.awaitAll().sum() + }.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } } else { - emptyList() + 0 } loading = false } + val displayedImageUpdateCount = completeStreamImageUpdateCount(streamStore.statesByEnvironmentId) + ?: totals?.updates + Scaffold( topBar = { TopAppBar( @@ -316,7 +335,7 @@ fun DashboardScreen( actions = { if (supportsActivities) { IconButton(onClick = { showActivities = true }) { - ActivityCenterToolbarIcon(failedCount = failedActivities.size) + ActivityCenterToolbarIcon(failedCount = failedActivityCount ?: 0) } } IconButton(onClick = { pruneEnvironmentId = envId }) { @@ -354,7 +373,7 @@ fun DashboardScreen( total = aggregate.totalContainers, images = aggregate.totalImages, volumes = totals?.volumes, - updates = totals?.updates, + updates = displayedImageUpdateCount, stopped = aggregate.stoppedContainers, ) } ?: totals @@ -386,7 +405,7 @@ fun DashboardScreen( environments = environments, streamStates = streamStore.statesByEnvironmentId, totals = totals, - failedActivities = failedActivities, + failedActivityCount = failedActivityCount, onOpenEnvironment = { env -> manager.setActiveEnvironment(EnvironmentId(env.id), env.name ?: env.id) }, @@ -505,24 +524,42 @@ fun DashboardScreen( } } +private suspend fun loadDashboardImageUpdateCount( + client: app.getarcane.sdk.ArcaneClient, + envs: List, +): Int? = + try { + coroutineScope { + envs.map { environment -> + async { + client.dashboard.snapshot(EnvironmentId(environment.id)).actionItems.imageUpdateActionCount() + } + }.awaitAll().sum() + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + private suspend fun loadLegacyDashboardTotals( client: app.getarcane.sdk.ArcaneClient, envs: List, volumes: Int?, updates: Int?, ): DashTotals? = - runCatching { + try { coroutineScope { envs.map { env -> val e = EnvironmentId(env.id) async { - val sc = runCatching { client.containers.statusCounts(envId = e) }.getOrNull() - val ic = runCatching { client.images.usageCounts(envId = e) }.getOrNull() + val sc = client.containers.statusCounts(envId = e) + val ic = client.images.usageCounts(envId = e) intArrayOf( - sc?.runningContainers ?: 0, - sc?.totalContainers ?: 0, - ic?.totalImages ?: 0, - ((sc?.totalContainers ?: 0) - (sc?.runningContainers ?: 0)).coerceAtLeast(0), + sc.runningContainers, + sc.totalContainers, + ic.totalImages, + (sc.totalContainers - sc.runningContainers).coerceAtLeast(0), ) } }.awaitAll() @@ -536,7 +573,11 @@ private suspend fun loadLegacyDashboardTotals( stopped = rows.sumOf { it[3] }, ) } - }.getOrNull() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } @Composable private fun ActivityCenterToolbarIcon(failedCount: Int) { @@ -685,7 +726,7 @@ internal fun buildNeedsAttentionItems( environments: List, streamStates: Map, totals: DashTotals?, - failedActivities: List, + failedActivityCount: Int?, onOpenEnvironment: (Environment) -> Unit, onOpenContainers: () -> Unit, onOpenUpdates: () -> Unit, @@ -740,7 +781,7 @@ internal fun buildNeedsAttentionItems( ) } - val updates = totals?.updates ?: 0 + val updates = completeStreamImageUpdateCount(streamStates) ?: totals?.updates ?: 0 if (updates > 0) { items += NeedsAttentionItem( id = "image-updates", @@ -763,11 +804,11 @@ internal fun buildNeedsAttentionItems( ) } - if (failedActivities.isNotEmpty()) { + if (failedActivityCount != null && failedActivityCount > 0) { items += NeedsAttentionItem( id = "failed-activities", title = "Failed activities", - count = failedActivities.size, + count = failedActivityCount, icon = Icons.Filled.Warning, severity = NeedsAttentionSeverity.Critical, action = onOpenActivities, @@ -838,6 +879,23 @@ private fun aggregateStreamActionItems( ) } +internal fun completeStreamImageUpdateCount( + streamStates: Map, +): Int? { + if (streamStates.isEmpty()) return null + var total = 0 + for (state in streamStates.values) { + val snapshot = state.snapshot + if (!state.hasLoaded || state.streamError || snapshot == null) return null + total += snapshot.actionItems.items + .firstOrNull { it.itemKind == DashboardActionItemKind.ImageUpdates } + ?.count + ?.coerceAtLeast(0) + ?: 0 + } + return total +} + private val Environment.displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt index 832c72d..e38722e 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt @@ -55,6 +55,7 @@ import app.getarcane.android.core.LocalPinnedStore import app.getarcane.android.core.Loadable import app.getarcane.android.core.PinnedItemsStore import app.getarcane.android.core.ResourceUpdateFilter +import app.getarcane.android.core.completeListQuery import app.getarcane.android.core.displayName import app.getarcane.android.core.friendlyErrorMessage import app.getarcane.android.core.hasAvailableUpdate @@ -66,7 +67,6 @@ import app.getarcane.android.ui.components.SkeletonListLoadingView import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.StatusRunning import app.getarcane.android.ui.theme.StatusUnknown -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.container.ContainerSummary import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -107,11 +107,12 @@ fun ContainerListScreen(onOpen: (String) -> Unit) { loadAll = { val response = client.containers.list( envId = envId, - query = SearchPaginationSort(start = 0, limit = -1), + query = completeListQuery(), ) CompleteContainerResponse( items = response.data, totalItems = response.pagination.totalItems, + success = response.success, ) }, ), diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerPagination.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerPagination.kt index fd9c751..c5463d2 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerPagination.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerPagination.kt @@ -2,17 +2,16 @@ package app.getarcane.android.ui.screens.containers import app.getarcane.android.core.Loadable import app.getarcane.android.core.ResourceUpdateFilter +import app.getarcane.android.core.CompleteListResponse import app.getarcane.android.core.displayName import app.getarcane.android.core.hasAvailableUpdate +import app.getarcane.android.core.IncompleteCollectionException import app.getarcane.android.core.isRunning +import app.getarcane.android.core.loadCompleteCollection import app.getarcane.sdk.models.container.ContainerSummary -internal data class CompleteContainerResponse( - val items: List, - val totalItems: Long, -) - -internal class IncompleteContainerCollectionException(message: String) : IllegalStateException(message) +internal typealias CompleteContainerResponse = CompleteListResponse +internal typealias IncompleteContainerCollectionException = IncompleteCollectionException /** * Normalizes the server's documented single-response "show all" result. The Arcane container @@ -23,22 +22,7 @@ internal suspend fun loadCompleteContainerCollection( idOf: (T) -> String, loadAll: suspend () -> CompleteContainerResponse, ): List { - val response = loadAll() - val itemsById = linkedMapOf() - response.items.forEach { item -> itemsById.putIfAbsent(idOf(item), item) } - - val rawItemCount = response.items.size.toLong() - val uniqueItemCount = itemsById.size.toLong() - if (response.totalItems >= 0 && - response.totalItems != rawItemCount && - response.totalItems != uniqueItemCount - ) { - throw IncompleteContainerCollectionException( - "Container response counts: raw=$rawItemCount, unique=$uniqueItemCount; " + - "server reported ${response.totalItems}", - ) - } - return itemsById.values.toList() + return loadCompleteCollection("Container", idOf, loadAll) } internal data class ContainerListLoadState( diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt index b45401c..42af7a7 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.Loadable import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteEnvironments import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.SkeletonListLoadingView import app.getarcane.android.ui.components.StatusBadge @@ -53,6 +54,7 @@ import app.getarcane.android.ui.theme.ArcaneGray import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.sdk.EnvironmentId import app.getarcane.sdk.models.environment.Environment +import kotlinx.coroutines.CancellationException /** Online when the status reads "online"/"up". Mirrors iOS `Environment.isOnline`. */ internal val Environment.isOnline: Boolean @@ -78,7 +80,9 @@ fun EnvironmentListScreen(onOpen: (String) -> Unit) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.environments.list().data) + Loadable.Success(loadCompleteEnvironments { query -> client.environments.list(query) }) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitOpsSyncsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitOpsSyncsScreen.kt index 2c2c97b..fe15c37 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitOpsSyncsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitOpsSyncsScreen.kt @@ -53,6 +53,8 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.COMPLETE_LIST_LIMIT +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ResourceIcon import app.getarcane.android.ui.screens.jobs.relativeTime @@ -63,6 +65,7 @@ import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.sdk.models.gitops.CreateGitOpsSync import app.getarcane.sdk.models.gitops.GitOpsSync +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -85,7 +88,13 @@ fun GitOpsSyncsScreen() { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.gitops.listSyncsPaginated(limit = 100, envId = envId).data) + Loadable.Success( + loadCompletePaginatedCollection("GitOps sync", GitOpsSync::id) { + client.gitops.listSyncsPaginated(limit = COMPLETE_LIST_LIMIT, envId = envId) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitRepositoriesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitRepositoriesScreen.kt index 6e2f91b..a7c923b 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitRepositoriesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/gitops/GitRepositoriesScreen.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.COMPLETE_LIST_LIMIT +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ResourceIcon import app.getarcane.android.ui.theme.ArcaneGreen @@ -55,6 +57,7 @@ import app.getarcane.android.ui.theme.ArcaneIndigo import app.getarcane.sdk.models.gitops.CreateGitRepository import app.getarcane.sdk.models.gitops.GitRepository import app.getarcane.sdk.models.gitops.UpdateGitRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** @@ -82,7 +85,13 @@ fun GitRepositoriesScreen() { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.gitops.listRepositoriesPaginated(limit = 100).data) + Loadable.Success( + loadCompletePaginatedCollection("Git repository", GitRepository::id) { + client.gitops.listRepositoriesPaginated(limit = COMPLETE_LIST_LIMIT) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageListScreen.kt index 582f24e..1ecddb0 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageListScreen.kt @@ -60,14 +60,18 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.CompleteListResponse +import app.getarcane.android.core.completeListQuery import app.getarcane.android.core.displayName import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.SkeletonListLoadingView import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcanePurple import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.sdk.models.image.ImageSummary +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch internal enum class TagsFilter(val label: String) { All("All"), Tagged("Tagged"), Untagged("Untagged") } @@ -114,7 +118,10 @@ fun ImageListScreen( if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - val images = client.images.list(envId = envId).data + val images = loadCompleteCollection("Image", ImageSummary::id) { + val response = client.images.list(envId = envId, query = completeListQuery()) + CompleteListResponse(response.data, response.pagination.totalItems, response.success) + } onLoaded(images) // Best-effort update decoration via the persisted by-refs map. val refs = images.flatMap { it.repoTags }.filter { it != ":" } @@ -137,6 +144,8 @@ fun ImageListScreen( } } Loadable.Success(images) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/networks/NetworkListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/networks/NetworkListScreen.kt index 457ba8f..7d92099 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/networks/NetworkListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/networks/NetworkListScreen.kt @@ -53,13 +53,15 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.Loadable import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.completeListQuery +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.SkeletonListLoadingView import app.getarcane.android.ui.theme.ArcaneTeal -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.network.NetworkCreateOptions import app.getarcane.sdk.models.network.NetworkCreateRequest import app.getarcane.sdk.models.network.NetworkSummary +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** Docker built-in networks that can't be deleted. Mirrors iOS `systemNetworkNames`. */ @@ -92,7 +94,13 @@ fun NetworkListScreen(onOpen: (String) -> Unit) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.networks.list(envId = envId, query = SearchPaginationSort(limit = 200)).data) + Loadable.Success( + loadCompletePaginatedCollection("Network", NetworkSummary::id) { + client.networks.list(envId = envId, query = completeListQuery()) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt index 94a8374..cf75fe2 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.completeListQuery +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.SkeletonListLoadingView import app.getarcane.android.ui.theme.ArcaneBlue @@ -53,8 +55,8 @@ import app.getarcane.android.ui.theme.ArcaneGray import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcanePink import app.getarcane.android.ui.theme.ArcanePurple -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.port.PortMapping +import kotlinx.coroutines.CancellationException @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,12 +75,13 @@ fun PortListScreen(onOpen: (String) -> Unit) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - val ports = client.ports.list( - envId = envId, - query = SearchPaginationSort(start = 0, limit = 500) - ).data + val ports = loadCompletePaginatedCollection("Port mapping", PortMapping::id) { + client.ports.list(envId = envId, query = completeListQuery()) + } PortStore.put(serverIdentity, ports) Loadable.Success(ports) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/ApiKeysScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/ApiKeysScreen.kt index 8552b79..aae5148 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/ApiKeysScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/ApiKeysScreen.kt @@ -54,12 +54,15 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.COMPLETE_LIST_LIMIT import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.theme.ArcaneYellow import app.getarcane.sdk.models.apikey.APIKey import app.getarcane.sdk.models.apikey.CreateAPIKey +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** API keys list. Port of iOS `APIKeysView`. */ @@ -81,7 +84,13 @@ fun ApiKeysScreen() { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.apiKeys.listPaginated(limit = 100).data) + Loadable.Success( + loadCompletePaginatedCollection("API key", APIKey::id) { + client.apiKeys.listPaginated(limit = COMPLETE_LIST_LIMIT) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/UsersScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/UsersScreen.kt index 7c37b43..128fa68 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/UsersScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/UsersScreen.kt @@ -38,12 +38,15 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.Loadable import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.COMPLETE_LIST_LIMIT +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.theme.ArcaneBlue import app.getarcane.android.ui.theme.ArcaneIndigo import app.getarcane.sdk.models.user.User import app.getarcane.sdk.models.user.isAdmin +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** Users list. Port of iOS `UsersView`. */ @@ -64,7 +67,13 @@ fun UsersScreen(onOpenUser: (String) -> Unit) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.users.listPaginated(limit = 100).data) + Loadable.Success( + loadCompletePaginatedCollection("User", User::id) { + client.users.listPaginated(limit = COMPLETE_LIST_LIMIT) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/OidcRoleMappingsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/OidcRoleMappingsScreen.kt index d067815..69570aa 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/OidcRoleMappingsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/OidcRoleMappingsScreen.kt @@ -49,7 +49,10 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.COMPLETE_LIST_LIMIT import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteEnvironments +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.screens.settings.FormErrorRow @@ -60,7 +63,6 @@ import app.getarcane.android.ui.screens.settings.SettingsListScaffold import app.getarcane.android.ui.theme.ArcaneIndigo import app.getarcane.sdk.ServerCapabilities import app.getarcane.sdk.errors.ArcaneError -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.environment.Environment import app.getarcane.sdk.models.role.CreateOidcRoleMapping import app.getarcane.sdk.models.role.OidcRoleMapping @@ -70,6 +72,7 @@ import app.getarcane.sdk.models.role.UpdateOidcRoleMapping import app.getarcane.sdk.models.user.hasAnyPermission import app.getarcane.sdk.models.role.Permission import kotlinx.coroutines.async +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -103,10 +106,18 @@ fun OidcRoleMappingsScreen() { state = try { coroutineScope { val mappings = async { client.oidcRoleMappings.list() } - val roles = async { client.roles.listPaginated(limit = 100) } - val envs = async { client.environments.list(SearchPaginationSort(start = 0, limit = 100)) } - Loadable.Success(OidcData(mappings.await(), roles.await().data, envs.await().data)) + val roles = async { + loadCompletePaginatedCollection("Role", Role::id) { + client.roles.listPaginated(limit = COMPLETE_LIST_LIMIT) + } + } + val envs = async { + loadCompleteEnvironments { query -> client.environments.list(query) } + } + Loadable.Success(OidcData(mappings.await(), roles.await(), envs.await())) } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/RolesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/RolesScreen.kt index 0879403..3b0bff5 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/RolesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/RolesScreen.kt @@ -38,6 +38,8 @@ import androidx.compose.ui.unit.dp import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.COMPLETE_LIST_LIMIT +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.screens.settings.CircleIcon @@ -51,6 +53,7 @@ import app.getarcane.sdk.models.role.PermissionsManifest import app.getarcane.sdk.models.role.Role import app.getarcane.sdk.models.user.hasAnyPermission import app.getarcane.sdk.models.role.Permission +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -79,10 +82,16 @@ fun RolesScreen(onOpenRole: (roleId: String) -> Unit, onCreateRole: () -> Unit) if (state !is Loadable.Success) state = Loadable.Loading state = try { coroutineScope { - val rolesPage = async { client.roles.listPaginated(limit = 100) } + val roles = async { + loadCompletePaginatedCollection("Role", Role::id) { + client.roles.listPaginated(limit = COMPLETE_LIST_LIMIT) + } + } val manifest = async { client.roles.availablePermissions() } - Loadable.Success(RolesData(rolesPage.await().data, manifest.await())) + Loadable.Success(RolesData(roles.await(), manifest.await())) } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/UserRoleAssignmentsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/UserRoleAssignmentsScreen.kt index e2dfcbb..ebd9c8b 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/UserRoleAssignmentsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/rbac/UserRoleAssignmentsScreen.kt @@ -46,7 +46,10 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.COMPLETE_LIST_LIMIT import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteEnvironments +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.screens.settings.FormErrorRow @@ -56,13 +59,13 @@ import app.getarcane.android.ui.screens.settings.LabeledPicker import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.IconButton import app.getarcane.sdk.errors.ArcaneError -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.environment.Environment import app.getarcane.sdk.models.role.Role import app.getarcane.sdk.models.role.RoleAssignment import app.getarcane.sdk.models.role.RoleAssignmentSource import app.getarcane.sdk.models.role.UserAssignmentInput import kotlinx.coroutines.async +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -92,10 +95,18 @@ fun UserRoleAssignmentsScreen(userId: String, onBack: () -> Unit) { state = try { coroutineScope { val a = async { client.users.getRoleAssignments(userId) } - val r = async { client.roles.listPaginated(limit = 100) } - val e = async { client.environments.list(SearchPaginationSort(start = 0, limit = 100)) } - Loadable.Success(AssignmentsData(a.await(), r.await().data, e.await().data)) + val r = async { + loadCompletePaginatedCollection("Role", Role::id) { + client.roles.listPaginated(limit = COMPLETE_LIST_LIMIT) + } + } + val e = async { + loadCompleteEnvironments { query -> client.environments.list(query) } + } + Loadable.Success(AssignmentsData(a.await(), r.await(), e.await())) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Throwable) { Loadable.Error(friendlyErrorMessage(ex)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/registries/ContainerRegistriesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/registries/ContainerRegistriesScreen.kt index f789d88..23f60fe 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/registries/ContainerRegistriesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/registries/ContainerRegistriesScreen.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.window.DialogProperties import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.COMPLETE_LIST_LIMIT +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.screens.settings.CircleIcon @@ -62,6 +64,7 @@ import app.getarcane.sdk.models.containerregistry.ContainerRegistry import app.getarcane.sdk.models.containerregistry.CreateContainerRegistry import app.getarcane.sdk.models.containerregistry.UpdateContainerRegistry import app.getarcane.sdk.models.user.isAdmin +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** Container registries list (admin-only) with create/edit/delete. Port of iOS `ContainerRegistriesView`. */ @@ -84,7 +87,13 @@ fun ContainerRegistriesScreen() { if (!isAdmin || client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.registries.listPaginated(limit = 100).data) + Loadable.Success( + loadCompletePaginatedCollection("Container registry", ContainerRegistry::id) { + client.registries.listPaginated(limit = COMPLETE_LIST_LIMIT) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/webhooks/WebhooksScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/webhooks/WebhooksScreen.kt index 299f90d..10f949a 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/webhooks/WebhooksScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/webhooks/WebhooksScreen.kt @@ -59,8 +59,12 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.getarcane.android.core.Loadable import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.CompleteListResponse +import app.getarcane.android.core.completeListQuery import app.getarcane.android.core.displayName import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteCollection +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.screens.settings.FormSectionHeader @@ -71,13 +75,13 @@ import app.getarcane.android.ui.screens.settings.Pill import app.getarcane.android.ui.screens.settings.SettingsListScaffold import app.getarcane.android.ui.screens.settings.CircleIcon import app.getarcane.android.ui.theme.ArcaneGreen -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.container.ContainerSummary import app.getarcane.sdk.models.project.ProjectDetails import app.getarcane.sdk.models.webhook.CreateWebhook import app.getarcane.sdk.models.webhook.UpdateWebhook import app.getarcane.sdk.models.webhook.Webhook import app.getarcane.sdk.models.webhook.WebhookCreated +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -258,11 +262,26 @@ private fun CreateWebhookDialog(onDismiss: () -> Unit, onCreated: (WebhookCreate LaunchedEffect(Unit) { if (client == null) return@LaunchedEffect loadingTargets = true - coroutineScope { - val c = async { runCatching { client.containers.list(envId = envId, query = SearchPaginationSort(start = 0, limit = 500)).data }.getOrDefault(emptyList()) } - val p = async { runCatching { client.projects.list(envId = envId, query = SearchPaginationSort(start = 0, limit = 500)).data }.getOrDefault(emptyList()) } - containers = c.await() - projects = p.await() + try { + coroutineScope { + val c = async { + loadCompleteCollection("Container", ContainerSummary::id) { + val response = client.containers.list(envId = envId, query = completeListQuery()) + CompleteListResponse(response.data, response.pagination.totalItems, response.success) + } + } + val p = async { + loadCompletePaginatedCollection("Project", ProjectDetails::id) { + client.projects.list(envId = envId, query = completeListQuery()) + } + } + containers = c.await() + projects = p.await() + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + error = friendlyErrorMessage(e) } loadingTargets = false } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/AllEnvironmentsImageUpdatesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/AllEnvironmentsImageUpdatesScreen.kt index 9664ebd..a2e161a 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/AllEnvironmentsImageUpdatesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/AllEnvironmentsImageUpdatesScreen.kt @@ -40,24 +40,28 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.CompleteListResponse +import app.getarcane.android.core.completeListQuery import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteCollection +import app.getarcane.android.core.loadCompleteEnvironments +import app.getarcane.android.ui.components.ErrorBanner import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.sdk.ArcaneClient import app.getarcane.sdk.EnvironmentId -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.environment.Environment +import app.getarcane.sdk.models.image.ImageSummary import app.getarcane.sdk.models.image.ImageUpdateInfo import app.getarcane.sdk.models.imageupdate.ImageUpdateResponse import app.getarcane.sdk.models.imageupdate.ImageUpdateSummary import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -private const val ALL_ENV_IMAGES_PAGE_SIZE = 500 - private val ImageUpdateInfo.hasDefinitiveUpdateInfo: Boolean get() = hasUpdate || error.isNotEmpty() || @@ -75,6 +79,7 @@ fun AllEnvironmentsImageUpdatesScreen(onBack: () -> Unit) { var buckets by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } var hasLoadedOnce by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(null) } var refreshKey by remember { mutableStateOf(0) } var checkingKey by remember { mutableStateOf(null) } var rescanningEnvironmentId by remember { mutableStateOf(null) } @@ -82,9 +87,17 @@ fun AllEnvironmentsImageUpdatesScreen(onBack: () -> Unit) { suspend fun load() { if (client == null) return if (!hasLoadedOnce) loading = true - val environments = runCatching { client.environments.list().data } - .getOrDefault(emptyList()) - .filter { it.enabled } + val environments = try { + loadCompleteEnvironments { query -> client.environments.list(query) } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + loadError = friendlyErrorMessage(e) + loading = false + hasLoadedOnce = true + return + }.filter { it.enabled } + loadError = null buckets = coroutineScope { environments.map { environment -> async { @@ -157,6 +170,11 @@ fun AllEnvironmentsImageUpdatesScreen(onBack: () -> Unit) { ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { when { + loadError != null -> { + Box(Modifier.fillMaxSize().padding(16.dp)) { + ErrorBanner(loadError!!, onRetry = { refreshKey++ }) + } + } !hasLoadedOnce && loading -> { Row( Modifier.fillMaxSize(), @@ -176,7 +194,9 @@ fun AllEnvironmentsImageUpdatesScreen(onBack: () -> Unit) { ) } else -> { - val totalUpdates = buckets.sumOf { it.updateRefs.size } + val totalUpdates = buckets + .takeIf { values -> values.none { it.errorMessage != null } } + ?.sumOf { it.updateRefs.size } LazyColumn( Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), @@ -202,7 +222,7 @@ fun AllEnvironmentsImageUpdatesScreen(onBack: () -> Unit) { } @Composable -private fun UpdatesOverviewCard(totalUpdates: Int, environmentCount: Int) { +private fun UpdatesOverviewCard(totalUpdates: Int?, environmentCount: Int) { Card(Modifier.fillMaxWidth()) { Row( Modifier.fillMaxWidth().padding(16.dp), @@ -210,14 +230,18 @@ private fun UpdatesOverviewCard(totalUpdates: Int, environmentCount: Int) { verticalAlignment = Alignment.CenterVertically, ) { Icon( - if (totalUpdates > 0) Icons.Filled.Warning else Icons.Filled.CheckCircle, + if (totalUpdates == null || totalUpdates > 0) Icons.Filled.Warning else Icons.Filled.CheckCircle, contentDescription = null, - tint = if (totalUpdates > 0) ArcaneOrange else ArcaneGreen, + tint = if (totalUpdates == null || totalUpdates > 0) ArcaneOrange else ArcaneGreen, modifier = Modifier.size(28.dp), ) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - if (totalUpdates == 1) "1 update available" else "$totalUpdates updates available", + when (totalUpdates) { + null -> "Update total unavailable" + 1 -> "1 update available" + else -> "$totalUpdates updates available" + }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) @@ -348,22 +372,28 @@ private suspend fun loadImageUpdateBucket( ): ImageUpdateEnvironmentBucket { val envId = EnvironmentId(environment.id) - val summary = runCatching { client.images.updateSummary(envId = envId) }.getOrNull() + val summary = try { + client.images.updateSummary(envId = envId) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } val updateInfoByRef = mutableMapOf() var taggedRefs = emptyList() var totalImages = 0 var errorMessage: String? = null try { - val response = client.images.list( - envId = envId, - query = SearchPaginationSort(start = 0, limit = ALL_ENV_IMAGES_PAGE_SIZE), - ) - totalImages = response.pagination.totalItems.toInt() - taggedRefs = response.data + val images = loadCompleteCollection("Image", ImageSummary::id) { + val response = client.images.list(envId = envId, query = completeListQuery()) + CompleteListResponse(response.data, response.pagination.totalItems, response.success) + } + totalImages = images.size + taggedRefs = images .flatMap { it.repoTags } .filter { it != ":" } - for (image in response.data) { + for (image in images) { val info = image.updateInfo ?: continue if (!info.hasDefinitiveUpdateInfo) continue val updateResponse = info.toUpdateResponse() @@ -371,16 +401,23 @@ private suspend fun loadImageUpdateBucket( if (tag != ":") updateInfoByRef[tag] = updateResponse } } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { errorMessage = friendlyErrorMessage(e) } if (taggedRefs.isNotEmpty()) { - runCatching { client.images.updateInfoByRefs(envId = envId, imageRefs = taggedRefs) } - .getOrNull() - ?.forEach { (ref, info) -> + try { + client.images.updateInfoByRefs(envId = envId, imageRefs = taggedRefs) + .forEach { (ref, info) -> if (info != null) updateInfoByRef[ref] = info.toUpdateResponse() } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // Inline update information remains usable when the cache lookup fails. + } } return ImageUpdateEnvironmentBucket( diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/ImageUpdatesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/ImageUpdatesScreen.kt index 4b10e18..aa76f24 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/ImageUpdatesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/ImageUpdatesScreen.kt @@ -42,19 +42,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.CompleteListResponse +import app.getarcane.android.core.completeListQuery import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteCollection import app.getarcane.android.ui.theme.ArcaneBlue import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcaneRed -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.image.ImageUpdateInfo +import app.getarcane.sdk.models.image.ImageSummary import app.getarcane.sdk.models.imageupdate.ImageUpdateResponse import app.getarcane.sdk.models.imageupdate.ImageUpdateSummary +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch -private const val IMAGES_PAGE_SIZE = 500 - /** True when the server's inline updateInfo carries an actual check result. Mirrors iOS `isDefinitive`. */ private val ImageUpdateInfo.isDefinitive: Boolean get() = hasUpdate || error.isNotEmpty() || currentVersion.isNotEmpty() || currentDigest.isNotEmpty() || latestDigest.isNotEmpty() @@ -81,14 +83,22 @@ fun ImageUpdatesScreen(onRunUpdater: () -> Unit, onHistory: () -> Unit, actionsE suspend fun fetch() { if (client == null) return // Summary (best-effort). - summary = runCatching { client.images.updateSummary(envId = envId) }.getOrNull() + summary = try { + client.images.updateSummary(envId = envId) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } // Image list -> refs + inline update info. val refs: List try { - val response = client.images.list(envId = envId, query = SearchPaginationSort(start = 0, limit = IMAGES_PAGE_SIZE)) - val images = response.data - totalImages = response.pagination.totalItems.toInt() + val images = loadCompleteCollection("Image", ImageSummary::id) { + val response = client.images.list(envId = envId, query = completeListQuery()) + CompleteListResponse(response.data, response.pagination.totalItems, response.success) + } + totalImages = images.size refs = images.flatMap { it.repoTags }.filter { it != ":" } errorMessage = if (summary == null) "Couldn't load images." else null @@ -99,6 +109,8 @@ fun ImageUpdatesScreen(onRunUpdater: () -> Unit, onHistory: () -> Unit, actionsE val response2 = info.asResponse() for (tag in image.repoTags) if (tag != ":") byRef[tag] = response2 } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { taggedRefs = emptyList() totalImages = 0 @@ -109,9 +121,14 @@ fun ImageUpdatesScreen(onRunUpdater: () -> Unit, onHistory: () -> Unit, actionsE // Merge with by-refs cache (its keys take precedence). if (refs.isNotEmpty()) { - runCatching { client.images.updateInfoByRefs(envId = envId, imageRefs = refs) } - .getOrNull() - ?.forEach { (k, v) -> if (v != null) byRef[k] = v.asResponse() } + try { + client.images.updateInfoByRefs(envId = envId, imageRefs = refs) + .forEach { (k, v) -> if (v != null) byRef[k] = v.asResponse() } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // Inline update information remains usable when the cache lookup fails. + } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/UpdatesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/UpdatesScreen.kt index a57b6b4..84f96eb 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/UpdatesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/updates/UpdatesScreen.kt @@ -29,10 +29,12 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.loadCompleteEnvironments import app.getarcane.android.nav.PopToRootOnSignal import app.getarcane.android.ui.components.StatusBadge import app.getarcane.sdk.EnvironmentId import app.getarcane.sdk.models.environment.Environment +import kotlinx.coroutines.CancellationException /** * Updates tab with its own nested back stack. Mirrors the iOS `UpdatesView`: @@ -49,7 +51,13 @@ fun UpdatesScreen(popToRootSignal: Int = 0) { LaunchedEffect(client) { if (client == null) return@LaunchedEffect - environments = runCatching { client.environments.list().data }.getOrDefault(emptyList()) + environments = try { + loadCompleteEnvironments { query -> client.environments.list(query) } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + emptyList() + } } fun launch(mode: PickerMode) { diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeBackupsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeBackupsScreen.kt index a554e7e..7320cd5 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeBackupsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeBackupsScreen.kt @@ -58,10 +58,12 @@ import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.Loadable import app.getarcane.android.core.formatBytes import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.completeListQuery +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.theme.ArcaneTeal -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.volume.BackupEntry +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -93,7 +95,13 @@ fun VolumeBackupsScreen(name: String, onBack: () -> Unit) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.volumes.listBackups(envId = envId, name = name, query = SearchPaginationSort(limit = 100)).data) + Loadable.Success( + loadCompletePaginatedCollection("Volume backup", BackupEntry::id) { + client.volumes.listBackups(envId = envId, name = name, query = completeListQuery()) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeListScreen.kt index cc63a04..5885e1b 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/volumes/VolumeListScreen.kt @@ -57,14 +57,16 @@ import app.getarcane.android.core.Loadable import app.getarcane.android.core.PinnedItemsStore import app.getarcane.android.core.formatBytes import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.completeListQuery +import app.getarcane.android.core.loadCompletePaginatedCollection import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.components.SkeletonListLoadingView import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcaneTeal import app.getarcane.android.ui.theme.ArcaneYellow import app.getarcane.sdk.EnvironmentId -import app.getarcane.sdk.models.base.SearchPaginationSort import app.getarcane.sdk.models.volume.Volume +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch private enum class ScopeFilter(val label: String) { All("All"), Local("Local"), Global("Global") } @@ -94,7 +96,13 @@ fun VolumeListScreen(onOpen: (String) -> Unit) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { - Loadable.Success(client.volumes.list(envId = envId, query = SearchPaginationSort(limit = 200)).data) + Loadable.Success( + loadCompletePaginatedCollection("Volume", Volume::id) { + client.volumes.list(envId = envId, query = completeListQuery()) + }, + ) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) } diff --git a/app/src/test/java/app/getarcane/android/core/CompleteListLoaderTest.kt b/app/src/test/java/app/getarcane/android/core/CompleteListLoaderTest.kt new file mode 100644 index 0000000..c83282f --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/CompleteListLoaderTest.kt @@ -0,0 +1,146 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.models.base.PaginationResponse +import app.getarcane.sdk.models.base.SearchPaginationSort +import app.getarcane.sdk.models.base.SortOrder +import app.getarcane.sdk.models.environment.Environment +import app.getarcane.sdk.pagination.PaginatedResponse +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class CompleteListLoaderTest { + @Test + fun `environment loader requests all rows and returns more than twenty`() = runBlocking { + val environments = (0 until 125).map(::environment) + var capturedQuery: SearchPaginationSort? = null + + val result = loadCompleteEnvironments { query -> + capturedQuery = query + page(environments, totalItems = environments.size.toLong()) + } + + assertEquals(environments, result) + assertEquals(0, capturedQuery?.start) + assertEquals(COMPLETE_LIST_LIMIT, capturedQuery?.limit) + assertEquals("id", capturedQuery?.sortBy) + assertEquals(SortOrder.ASCENDING, capturedQuery?.sortOrder) + } + + @Test + fun `duplicates are removed when total counts raw rows`() = runBlocking { + val result = loadCompletePaginatedCollection("Thing", idOf = { it }) { + page(listOf("one", "two", "two"), totalItems = 3) + } + + assertEquals(listOf("one", "two"), result) + } + + @Test + fun `duplicates are removed when total counts unique identities`() = runBlocking { + val result = loadCompletePaginatedCollection("Thing", idOf = { it }) { + page(listOf("one", "two", "two"), totalItems = 2) + } + + assertEquals(listOf("one", "two"), result) + } + + @Test + fun `empty response is a successful complete collection`() = runBlocking { + assertEquals( + emptyList(), + loadCompletePaginatedCollection("Thing", idOf = { it }) { + page(emptyList(), totalItems = 0) + }, + ) + } + + @Test + fun `short show-all response is accepted as the final response`() = runBlocking { + val result = loadCompletePaginatedCollection("Thing", idOf = { it }) { + page(listOf("one", "two", "three"), totalItems = 3) + } + + assertEquals(listOf("one", "two", "three"), result) + } + + @Test + fun `count mismatch fails atomically`() { + val error = assertThrows(IncompleteCollectionException::class.java) { + runBlocking { + loadCompletePaginatedCollection("Thing", idOf = { it }) { + page(listOf("one"), totalItems = 2) + } + } + } + + assertEquals("Thing response counts: raw=1, unique=1; server reported 2", error.message) + } + + @Test + fun `unsuccessful response fails atomically`() { + val error = assertThrows(IncompleteCollectionException::class.java) { + normalizeCompleteCollection( + resourceName = "Thing", + response = CompleteListResponse(listOf("one"), totalItems = 1, success = false), + idOf = { it }, + ) + } + + assertEquals("Thing response was unsuccessful", error.message) + } + + @Test + fun `load failure is propagated without partial content`() { + val expected = IOException("failed") + + val actual = assertThrows(IOException::class.java) { + runBlocking { + loadCompletePaginatedCollection("Thing", idOf = { it }) { + throw expected + } + } + } + + assertSame(expected, actual) + } + + @Test + fun `cancellation is propagated`() { + val expected = CancellationException("cancelled") + + val actual = assertThrows(CancellationException::class.java) { + runBlocking { + loadCompletePaginatedCollection("Thing", idOf = { it }) { + throw expected + } + } + } + + assertSame(expected, actual) + } + + private fun environment(index: Int): Environment = + Environment( + id = index.toString(), + name = "Environment $index", + apiUrl = "https://environment-$index.example.com", + status = "online", + ) + + private fun page(items: List, totalItems: Long): PaginatedResponse = + PaginatedResponse( + success = true, + data = items, + pagination = PaginationResponse( + totalPages = if (items.isEmpty()) 0 else 1, + totalItems = totalItems, + currentPage = if (items.isEmpty()) 0 else 1, + itemsPerPage = items.size, + ), + ) +} diff --git a/app/src/test/java/app/getarcane/android/ui/screens/DashboardNeedsAttentionMapperTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/DashboardNeedsAttentionMapperTest.kt index a7e97cd..efc6f46 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/DashboardNeedsAttentionMapperTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/DashboardNeedsAttentionMapperTest.kt @@ -14,6 +14,50 @@ import org.junit.Assert.assertTrue import org.junit.Test class DashboardNeedsAttentionMapperTest { + @Test + fun failedActivityRowUsesTheCompleteFleetCount() { + val items = buildNeedsAttentionItems( + environments = emptyList(), + streamStates = emptyMap(), + totals = null, + failedActivityCount = 27, + onOpenEnvironment = {}, + onOpenContainers = {}, + onOpenUpdates = {}, + onOpenVulnerabilities = {}, + onOpenApiKeys = {}, + onOpenActivities = {}, + ) + + assertEquals(27, items.single { it.id == "failed-activities" }.count) + } + + @Test + fun imageUpdateRowUsesDashboardActionItemsInsteadOfRawImageUpdateTotal() { + val streamStates = mapOf( + "0" to streamState( + id = "0", + name = "Local", + actionItems = listOf(DashboardActionItem("image_updates", 4, "warning")), + ), + ) + val items = buildNeedsAttentionItems( + environments = emptyList(), + streamStates = streamStates, + totals = DashTotals(96, 97, 112, 5, 11, 1), + failedActivityCount = 0, + onOpenEnvironment = {}, + onOpenContainers = {}, + onOpenUpdates = {}, + onOpenVulnerabilities = {}, + onOpenApiKeys = {}, + onOpenActivities = {}, + ) + + assertEquals(4, items.single { it.id == "image-updates" }.count) + assertEquals(4, completeStreamImageUpdateCount(streamStates)) + } + @Test fun actionItemsAddVulnerabilitiesAndApiKeysWithoutDroppingExistingRows() { val vulnerabilityTargets = mutableListOf() @@ -27,6 +71,7 @@ class DashboardNeedsAttentionMapperTest { actionItems = listOf( DashboardActionItem("actionable_vulnerabilities", 2, "warning"), DashboardActionItem("expiring_keys", 1, "warning"), + DashboardActionItem("image_updates", 4, "warning"), ), ), "edge" to streamState( @@ -46,7 +91,7 @@ class DashboardNeedsAttentionMapperTest { updates = 4, stopped = 3, ), - failedActivities = emptyList(), + failedActivityCount = 0, onOpenEnvironment = {}, onOpenContainers = {}, onOpenUpdates = {}, @@ -113,7 +158,7 @@ class DashboardNeedsAttentionMapperTest { ), ), totals = null, - failedActivities = emptyList(), + failedActivityCount = null, onOpenEnvironment = {}, onOpenContainers = {}, onOpenUpdates = {}, diff --git a/app/src/test/java/app/getarcane/android/ui/screens/DashboardOverviewSourceTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/DashboardOverviewSourceTest.kt index bb67d96..37f974f 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/DashboardOverviewSourceTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/DashboardOverviewSourceTest.kt @@ -2,6 +2,9 @@ package app.getarcane.android.ui.screens import app.getarcane.sdk.models.base.JsonValue import app.getarcane.sdk.models.container.ContainerStatusCounts +import app.getarcane.sdk.models.dashboard.ActionItem +import app.getarcane.sdk.models.dashboard.ActionItemKind +import app.getarcane.sdk.models.dashboard.ActionItemSeverity import app.getarcane.sdk.models.dashboard.ActionItems import app.getarcane.sdk.models.dashboard.DashboardEnvironmentOverview import app.getarcane.sdk.models.dashboard.DashboardEnvironmentsOverview @@ -65,6 +68,15 @@ class DashboardOverviewSourceTest { overviewRow( containers = ContainerStatusCounts(2, 1, 3), imageUsageCounts = ImageUsageCounts(2, 4, 6, 99), + actionItems = ActionItems( + listOf( + ActionItem( + kind = ActionItemKind.IMAGE_UPDATES, + count = 4, + severity = ActionItemSeverity.WARNING, + ), + ), + ), ), ), ) @@ -77,18 +89,20 @@ class DashboardOverviewSourceTest { DashboardEnvironmentCardOverviewCounts(running = 2, stopped = 1, images = 6), overview.environments.single().cardOverviewCounts(), ) + assertEquals(4, overview.imageUpdateActionCount()) } private fun overviewRow( environment: JsonValue = JsonValue.Obj(mapOf("id" to JsonValue.Str("0"), "name" to JsonValue.Str("Local"))), containers: ContainerStatusCounts = ContainerStatusCounts(0, 0, 0), imageUsageCounts: ImageUsageCounts = ImageUsageCounts(0, 0, 0, 0), + actionItems: ActionItems = ActionItems(), ): DashboardEnvironmentOverview = DashboardEnvironmentOverview( environment = environment, containers = containers, imageUsageCounts = imageUsageCounts, - actionItems = ActionItems(), + actionItems = actionItems, settings = DashboardSnapshotSettings(), snapshotState = EnvironmentSnapshotState.READY, ) diff --git a/docs/complete-list-loading.md b/docs/complete-list-loading.md new file mode 100644 index 0000000..d83e6b4 --- /dev/null +++ b/docs/complete-list-loading.md @@ -0,0 +1,35 @@ +# Complete-list loading + +Arcane list endpoints default to a finite page, usually 20 items. Android callers that present a +complete catalog or calculate a fleet-wide result must not rely on that default or on an arbitrary +large limit. + +At the Android, Kotlin SDK, and Arcane revisions recorded in PAR-004, Arcane documents `limit=-1` +as its finite show-all request. `CompleteListLoader` centralizes that request shape and validates the +single response before publishing it. It preserves server order, de-duplicates stable identities, +accepts totals that count either raw rows or unique identities, and rejects unsuccessful or +count-mismatched responses atomically. Loaders do not catch cancellation. + +Offset traversal is deliberately not the fallback. Several operational collections can change +during a load and do not expose a unique supported sort key, so multiple offset requests could skip +or duplicate resources while still appearing complete. A caller must remain explicitly paged when +the endpoint does not support show-all semantics. + +## Checked caller inventory + +| Classification | Callers | Result | +| --- | --- | --- | +| Fixed: environments | Environment list; dashboard overview fallback; Updates; all-environment image updates; Activity Center fan-out; OIDC role-mapping environment selector; user role-assignment environment selector | All use the shared show-all request and identity/count validation. | +| Fixed: dashboard | Pinned containers, projects, and volumes; fleet volume/update totals; failed-activity badge and attention count; legacy fleet totals | Pins load complete resource collections. Fleet totals fail atomically instead of displaying partial sums. The update total uses action items from each supported per-environment dashboard snapshot (impacted standalone containers plus projects), not the raw image-update count. Failed activities use the filtered pagination total rather than the size of a bounded page. | +| Fixed: updates and images | Image list; per-environment image updates; all-environment image-update buckets | Every local filter and update-ref lookup receives the complete image collection. | +| Fixed: operational resources | Networks; volumes; volume backups; ports | Each non-paged list uses show-all loading with stable identity de-duplication and count validation. | +| Fixed: administration | API keys; users; roles; container registries; webhook container/project selectors | Arbitrary 100/500-item caps were removed. | +| Fixed: GitOps | Git repositories; per-environment GitOps syncs | Arbitrary 100-item caps were removed. | +| Intentionally paged | Active projects; archived projects; events; all-vulnerability results; per-image vulnerability results; Activity Center history; updater history | These screens expose load-more behavior or grow their requested window and do not claim that the current page is a complete total. | +| Intentionally bounded | Activity detail messages (500); Activity Center stream seed (one page); updater-run history probe (5); dashboard failed-activity page (1) | These calls support a detail tail, live seed, or identity/count probe. Displayed complete counts come from pagination metadata, not page length. | +| Endpoint-defined complete | Jobs; webhooks; notification settings; OIDC mappings; template `listAll`; template registries | These SDK operations are non-paginated or use a dedicated all-results endpoint. | + +When adding a list caller, classify it in this inventory. A complete caller should use +`loadCompletePaginatedCollection`, or adapt a custom SDK envelope through `CompleteListResponse` and +`loadCompleteCollection`. An intentionally paged or bounded caller must avoid copy or counters that +imply the loaded slice is the full collection. diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index 9c7dd0d..8c8d315 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -155,9 +155,9 @@ The standard checks are: server origin and manual equivalent-URL check were not performed; those cases are covered by the focused JVM matrix rather than claimed as device evidence. -- [ ] **PAR-003 — Fix complete-container loading before local filtering** +- [x] **PAR-003 — Fix complete-container loading before local filtering** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P0 - **Dependencies:** None - **Scope:** Make the Containers tab filter a complete result set rather than the SDK's default @@ -171,12 +171,11 @@ The standard checks are: - [x] Search/status filters are proven to run after complete loading, or are moved server-side with equivalent semantics. - [x] Loading, partial-page failure, refresh, cancellation, and empty states are covered. - - [ ] A device/emulator against a live server with more than 20 containers confirms display, + - [x] A device/emulator against a live server with more than 20 containers confirms display, filtering, refresh, and environment-change behavior without duplicates or omissions. - **Validation evidence (updated 2026-08-21):** - Review: PR [#41](https://github.com/getarcaneapp/android/pull/41) merged as `fdfabe3`; its focused - Greptile finding was fixed and verified in `2d97dad`. Automated validation is complete and focused - manual device/live-server validation remains pending. + Greptile finding was fixed and verified in `2d97dad`. - Source pins: Android base `ca211804fcb3223b7b65abb0d13a97afad81799e`, libarcane-kotlin `89c8dd58886a099cdbea9cb9362c9262ba5851d9`, and Arcane `b501c49cc9f3d3433494f8334178ac65a59a013d`. @@ -191,10 +190,13 @@ The standard checks are: 14 focused tests (0 failures, 0 errors, 0 skipped). - `.\gradlew.bat :app:testDebugUnitTest :app:assembleDebug` passed all 98 unit tests and produced the debug APK; `git diff --check` passed. + - On 2026-08-21, Michael verified the merged implementation on a physical device against a live + environment with approximately 90 containers. Complete display, search, running/stopped filters, + refresh, and environment switching all passed without duplicates or omissions. -- [ ] **PAR-004 — Audit all complete-list call sites for silent pagination truncation** +- [x] **PAR-004 — Audit all complete-list call sites for silent pagination truncation** -- **Status:** Ready +- **Status:** Done - **Priority:** P0 - **Dependencies:** PAR-003 - **Scope:** Inventory every list call whose UI or calculation claims fleet-wide or complete @@ -204,11 +206,32 @@ The standard checks are: show-all request or stable offset traversal and consistently handles count validation, de-duplication, cancellation, and atomic failure. Keep call-site inventory and changes within PAR-004. -- **Acceptance criteria:** - - [ ] A checked inventory records each caller as intentionally paged, intentionally bounded, or fixed. - - [ ] All complete-environment callers work with more than 20 environments. - - [ ] Shared paging logic has duplicate/empty/short/final-page, cancellation, and error coverage. - - [ ] UI copy does not claim complete totals when a view is intentionally bounded. +- **Audit record:** [Complete-list loading and checked caller inventory](complete-list-loading.md) +- **Acceptance criteria:** + - [x] A checked inventory records each caller as intentionally paged, intentionally bounded, or fixed. + - [x] All complete-environment callers work with more than 20 environments. + - [x] Shared paging logic has duplicate/empty/short/final-page, cancellation, and error coverage. + - [x] UI copy does not claim complete totals when a view is intentionally bounded. +- **Validation evidence (2026-08-21):** + - Source pins: Android base `fb0ac8f91b0acee6f0771a4f880208b74513beb8`, + libarcane-kotlin `991dfdc1ee747c171ebf1b5953fe5fb61ceadfb8`, and Arcane + `0fd8820822f49e2da25739306bc9bc401253fa9e`. + - Arcane's shared pagination contract documents `limit = -1` as a finite show-all request. The + shared Android loader uses that request, de-duplicates stable identities, validates success and + raw/unique totals, rejects malformed responses atomically, and propagates cancellation. + - Focused runs passed 26 tests across `CompleteListLoaderTest`, `ContainerPaginationTest`, and + `DashboardNeedsAttentionMapperTest` (0 failures, 0 errors, 0 skipped). The environment fixture + contains 125 rows and verifies the exact show-all query. + - `./gradlew :app:testDebugUnitTest :app:assembleDebug` passed all 131 unit tests and assembled the + debug APK; `git diff --check` passed. + - A 2026-08-21 physical-device comparison found Android reporting 11 dashboard updates while the + Arcane dashboard reported 4. Android was counting raw images with updates instead of Arcane's + impacted-resource action items. The mapping now uses initial and streamed dashboard action-item + totals and has focused regression coverage. The first retest remained at the loading placeholder + because the SDK's action-items-only and aggregate-overview endpoints are not exposed by the + pinned Arcane server revision; Android now reads action items from the supported per-environment + dashboard snapshot instead. Michael's second physical-device retest showed the expected update + count and verified the remaining PAR-004 items. - [ ] **PAR-005 — Revalidate OIDC and admin-tab navigation**