Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
Expand Down Expand Up @@ -63,7 +65,6 @@ import app.getarcane.android.ui.screens.activities.sortTime
import app.getarcane.android.ui.screens.activities.statusTint
import app.getarcane.android.ui.screens.activities.subtitle
import app.getarcane.android.ui.screens.settings.FormErrorRow
import app.getarcane.android.ui.screens.settings.FormSuccessRow
import app.getarcane.android.ui.screens.settings.LabeledPicker
import app.getarcane.android.ui.screens.settings.LabeledTextField
import app.getarcane.android.ui.screens.settings.SettingsSectionHeader
Expand Down Expand Up @@ -112,6 +113,8 @@ fun DashboardScreen(onOpenTab: ((String) -> Unit)? = null) {
val manager = LocalArcaneManager.current
val client = manager.client
val envId = manager.activeEnvironmentId
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }

val supportsActivities = manager.capabilities.supportsActivities

Expand Down Expand Up @@ -191,6 +194,7 @@ fun DashboardScreen(onOpenTab: ((String) -> Unit)? = null) {
}

Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
title = { Text("Dashboard") },
Expand Down Expand Up @@ -283,7 +287,11 @@ fun DashboardScreen(onOpenTab: ((String) -> Unit)? = null) {
SystemPruneSheet(
envId = envId,
onDismiss = { showPrune = false },
onComplete = { refreshKey++ },
onComplete = { message ->
showPrune = false
refreshKey++
scope.launch { snackbar.showSnackbar(message) }
},
)
}
}
Expand Down Expand Up @@ -394,7 +402,7 @@ private fun DashboardTile(

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComplete: () -> Unit) {
private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComplete: (String) -> Unit) {
val manager = LocalArcaneManager.current
val scope = rememberCoroutineScope()

Expand All @@ -407,10 +415,29 @@ private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComp
var networkUntil by remember { mutableStateOf("") }
var buildCacheMode by remember { mutableStateOf(PruneBuildCacheMode.NONE) }
var buildCacheUntil by remember { mutableStateOf("") }
var loadingDefaults by remember { mutableStateOf(false) }
var submitting by remember { mutableStateOf(false) }
var resultMessage by remember { mutableStateOf<String?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }

LaunchedEffect(envId.rawValue) {
val client = manager.client ?: return@LaunchedEffect
loadingDefaults = true
try {
val defaults = client.settings.getSettings(envId).associate { it.key to it.value }
parsePruneContainerMode(defaults["pruneContainerMode"])?.let { containerMode = it }
defaults["pruneContainerUntil"]?.takeIf { it.isNotBlank() }?.let { containerUntil = it }
parsePruneImageMode(defaults["pruneImageMode"])?.let { imageMode = it }
defaults["pruneImageUntil"]?.takeIf { it.isNotBlank() }?.let { imageUntil = it }
parsePruneVolumeMode(defaults["pruneVolumeMode"])?.let { volumeMode = it }
parsePruneNetworkMode(defaults["pruneNetworkMode"])?.let { networkMode = it }
defaults["pruneNetworkUntil"]?.takeIf { it.isNotBlank() }?.let { networkUntil = it }
parsePruneBuildCacheMode(defaults["pruneBuildCacheMode"])?.let { buildCacheMode = it }
defaults["pruneBuildCacheUntil"]?.takeIf { it.isNotBlank() }?.let { buildCacheUntil = it }
} finally {
loadingDefaults = false
}
}

val selectedCount = listOf(
containerMode != PruneContainerMode.NONE,
imageMode != PruneImageMode.NONE,
Expand Down Expand Up @@ -439,10 +466,8 @@ private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComp
scope.launch {
submitting = true
errorMessage = null
resultMessage = null
try {
resultMessage = formatPruneResult(client.system.prune(request, envId))
onComplete()
onComplete(formatSystemPruneResult(client.system.prune(request, envId)))
} catch (e: Throwable) {
errorMessage = friendlyErrorMessage(e)
} finally {
Expand All @@ -463,8 +488,8 @@ private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComp
}
},
actions = {
TextButton(onClick = { runPrune() }, enabled = selectedCount > 0 && !submitting) {
if (submitting) {
TextButton(onClick = { runPrune() }, enabled = selectedCount > 0 && !submitting && !loadingDefaults) {
if (submitting || loadingDefaults) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
} else {
Text(if (selectedCount > 0) "Prune ($selectedCount)" else "Prune")
Expand Down Expand Up @@ -519,19 +544,20 @@ private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComp
item { LabeledTextField("Older than", buildCacheUntil, { buildCacheUntil = it }, placeholder = "24h") }
}

resultMessage?.let { msg ->
item { FormSuccessRow(msg) }
}
errorMessage?.let { msg ->
item { FormErrorRow(msg) }
}
item {
Button(
onClick = { runPrune() },
enabled = selectedCount > 0 && !submitting,
enabled = selectedCount > 0 && !submitting && !loadingDefaults,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Text(if (selectedCount > 0) "Prune ($selectedCount)" else "Prune")
if (submitting || loadingDefaults) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Text(if (selectedCount > 0) "Prune ($selectedCount)" else "Prune")
}
}
}
}
Expand All @@ -540,6 +566,21 @@ private fun SystemPruneSheet(envId: EnvironmentId, onDismiss: () -> Unit, onComp
}
}

private fun parsePruneContainerMode(value: String?): PruneContainerMode? =
PruneContainerMode.entries.firstOrNull { it.wire == value }

private fun parsePruneImageMode(value: String?): PruneImageMode? =
PruneImageMode.entries.firstOrNull { it.wire == value }

private fun parsePruneVolumeMode(value: String?): PruneVolumeMode? =
PruneVolumeMode.entries.firstOrNull { it.wire == value }

private fun parsePruneNetworkMode(value: String?): PruneNetworkMode? =
PruneNetworkMode.entries.firstOrNull { it.wire == value }

private fun parsePruneBuildCacheMode(value: String?): PruneBuildCacheMode? =
PruneBuildCacheMode.entries.firstOrNull { it.wire == value }

private val PruneContainerMode.label: String
get() = when (this) {
PruneContainerMode.NONE -> "None"
Expand Down Expand Up @@ -577,15 +618,27 @@ private val PruneBuildCacheMode.label: String
PruneBuildCacheMode.OLDER_THAN -> "Older than..."
}

private fun formatPruneResult(result: PruneAllResult): String {
internal fun formatSystemPruneResult(result: PruneAllResult): String {
val parts = buildList {
result.containersPruned?.size?.takeIf { it > 0 }?.let { add("$it container${if (it == 1) "" else "s"}") }
result.imagesDeleted?.size?.takeIf { it > 0 }?.let { add("$it image${if (it == 1) "" else "s"}") }
result.volumesDeleted?.size?.takeIf { it > 0 }?.let { add("$it volume${if (it == 1) "" else "s"}") }
result.networksDeleted?.size?.takeIf { it > 0 }?.let { add("$it network${if (it == 1) "" else "s"}") }
result.buildCacheSpaceReclaimed?.takeIf { it > 0 }?.let { add("build cache") }
}
val reclaimedBytes = result.spaceReclaimed.takeIf { it > 0 }
?: listOfNotNull(
result.containerSpaceReclaimed,
result.imageSpaceReclaimed,
result.volumeSpaceReclaimed,
result.buildCacheSpaceReclaimed,
).sum().takeIf { it > 0 }
val summary = when {
parts.isNotEmpty() -> "Pruned ${parts.joinToString(", ")}."
reclaimedBytes != null -> "Pruned resources."
else -> "No resources pruned."
}
val summary = if (parts.isEmpty()) "No resources pruned." else "Pruned ${parts.joinToString(", ")}."
val reclaimed = result.spaceReclaimed.takeIf { it > 0 }?.let { " Freed ${formatBytes(it)}." }.orEmpty()
val reclaimed = reclaimedBytes?.let { " Freed ${formatBytes(it)}." }.orEmpty()
val errors = result.errors?.takeIf { it.isNotEmpty() }?.joinToString(prefix = " Errors: ")
return summary + reclaimed + (errors ?: "")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package app.getarcane.android.ui.screens

import app.getarcane.sdk.models.system.PruneAllResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test

class DashboardScreenTest {
@Test
fun systemPruneResultIncludesBuildCacheWhenOnlyBuildCacheWasPruned() {
val message = formatSystemPruneResult(
PruneAllResult(
buildCacheSpaceReclaimed = 1024,
spaceReclaimed = 1024,
success = true,
),
)

assertEquals("Pruned build cache. Freed 1.0 KB.", message)
assertFalse(message.contains("No resources pruned"))
}

@Test
fun systemPruneResultUsesPositiveSummaryWhenOnlyReclaimedBytesAreReported() {
val message = formatSystemPruneResult(
PruneAllResult(
spaceReclaimed = 1024,
success = true,
),
)

assertEquals("Pruned resources. Freed 1.0 KB.", message)
}

@Test
fun systemPruneResultKeepsEmptyResultCopyWhenNothingWasPruned() {
val message = formatSystemPruneResult(PruneAllResult(success = true))

assertEquals("No resources pruned.", message)
}
}
Loading