| 1 |
package io.oxygenhub.android.ui.flow |
| 2 |
|
| 3 |
import androidx.compose.material.icons.outlined.Bolt |
| 4 |
import androidx.compose.material3.Button |
| 5 |
import androidx.compose.material3.OutlinedButton |
| 6 |
import androidx.compose.material3.TextButton |
| 7 |
import androidx.compose.material3.AlertDialog |
| 8 |
import androidx.compose.material3.OutlinedTextField |
| 9 |
import androidx.compose.runtime.saveable.rememberSaveable |
| 10 |
import androidx.compose.runtime.mutableStateOf |
| 11 |
import androidx.compose.runtime.setValue |
| 12 |
import androidx.compose.foundation.clickable |
| 13 |
import androidx.compose.foundation.horizontalScroll |
| 14 |
import androidx.compose.foundation.layout.Arrangement |
| 15 |
import androidx.compose.foundation.layout.Column |
| 16 |
import androidx.compose.foundation.layout.Row |
| 17 |
import androidx.compose.foundation.layout.fillMaxSize |
| 18 |
import androidx.compose.foundation.layout.fillMaxWidth |
| 19 |
import androidx.compose.foundation.layout.padding |
| 20 |
import androidx.compose.foundation.layout.width |
| 21 |
import androidx.compose.foundation.lazy.LazyColumn |
| 22 |
import androidx.compose.foundation.lazy.items |
| 23 |
import androidx.compose.foundation.lazy.rememberLazyListState |
| 24 |
import androidx.compose.foundation.rememberScrollState |
| 25 |
import androidx.compose.foundation.text.selection.SelectionContainer |
| 26 |
import androidx.compose.material.icons.Icons |
| 27 |
import androidx.compose.material.icons.outlined.Block |
| 28 |
import androidx.compose.material.icons.outlined.CheckCircle |
| 29 |
import androidx.compose.material.icons.outlined.Error |
| 30 |
import androidx.compose.material.icons.outlined.HourglassEmpty |
| 31 |
import androidx.compose.material.icons.outlined.PlayCircle |
| 32 |
import androidx.compose.material.icons.outlined.RemoveCircleOutline |
| 33 |
import androidx.compose.material.icons.outlined.Replay |
| 34 |
import androidx.compose.material.icons.outlined.RocketLaunch |
| 35 |
import androidx.compose.material3.HorizontalDivider |
| 36 |
import androidx.compose.material3.Icon |
| 37 |
import androidx.compose.material3.IconButton |
| 38 |
import androidx.compose.material3.ListItem |
| 39 |
import androidx.compose.material3.MaterialTheme |
| 40 |
import androidx.compose.material3.Text |
| 41 |
import androidx.compose.runtime.Composable |
| 42 |
import androidx.compose.runtime.LaunchedEffect |
| 43 |
import androidx.compose.runtime.getValue |
| 44 |
import androidx.compose.ui.Modifier |
| 45 |
import androidx.compose.ui.graphics.Color |
| 46 |
import androidx.compose.ui.graphics.vector.ImageVector |
| 47 |
import androidx.compose.ui.text.font.FontFamily |
| 48 |
import androidx.compose.ui.unit.dp |
| 49 |
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 50 |
import io.oxygenhub.android.api.FlowJob |
| 51 |
import io.oxygenhub.android.api.FlowRun |
| 52 |
import io.oxygenhub.android.ui.common.AppScreen |
| 53 |
import io.oxygenhub.android.ui.common.EmptyState |
| 54 |
import io.oxygenhub.android.ui.common.ErrorState |
| 55 |
import io.oxygenhub.android.ui.common.Load |
| 56 |
import io.oxygenhub.android.ui.common.LoadContent |
| 57 |
import io.oxygenhub.android.ui.common.LoadingState |
| 58 |
import io.oxygenhub.android.ui.common.relativeTime |
| 59 |
import io.oxygenhub.android.ui.theme.LocalStatusColors |
| 60 |
|
| 61 |
/** A status's icon and colour, as the hub's run pages show them. */ |
| 62 |
@Composable |
| 63 |
fun statusLook(status: String): Pair<ImageVector, Color> { |
| 64 |
val c = LocalStatusColors.current |
| 65 |
return when (status) { |
| 66 |
"passed" -> Icons.Outlined.CheckCircle to c.ok |
| 67 |
"failed", "error" -> Icons.Outlined.Error to c.bad |
| 68 |
"running" -> Icons.Outlined.PlayCircle to c.warn |
| 69 |
"cancelled" -> Icons.Outlined.Block to MaterialTheme.colorScheme.onSurfaceVariant |
| 70 |
"skipped" -> Icons.Outlined.RemoveCircleOutline to MaterialTheme.colorScheme.onSurfaceVariant |
| 71 |
else -> Icons.Outlined.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant |
| 72 |
} |
| 73 |
} |
| 74 |
|
| 75 |
/** A repository's runs, for its tab. */ |
| 76 |
@Composable |
| 77 |
fun RunList(vm: RunsViewModel, onOpen: (FlowRun) -> Unit) { |
| 78 |
val state by vm.state.collectAsStateWithLifecycle() |
| 79 |
LoadContent(state, onRefresh = vm::refresh) { runs -> |
| 80 |
if (runs.isEmpty()) { |
| 81 |
EmptyState(Icons.Outlined.RocketLaunch, "No runs yet", "A push to ${vm.owner}/${vm.name} with a .flow/pipeline.kdl starts one.") |
| 82 |
} else { |
| 83 |
LazyColumn(Modifier.fillMaxSize()) { |
| 84 |
items(runs, key = { it.number }) { r -> |
| 85 |
val (icon, colour) = statusLook(r.status) |
| 86 |
ListItem( |
| 87 |
modifier = Modifier.clickable { onOpen(r) }, |
| 88 |
leadingContent = { Icon(icon, contentDescription = r.status, tint = colour) }, |
| 89 |
headlineContent = { Text("#${r.number} ${r.bookmark}") }, |
| 90 |
supportingContent = { |
| 91 |
Text( |
| 92 |
listOfNotNull( |
| 93 |
r.status, |
| 94 |
"${r.trigger} by ${r.actor}", |
| 95 |
relativeTime(r.createdMs).takeIf { it.isNotEmpty() }, |
| 96 |
r.queuePosition?.let { "#$it in the queue" }, |
| 97 |
).joinToString(" · "), |
| 98 |
) |
| 99 |
}, |
| 100 |
) |
| 101 |
HorizontalDivider() |
| 102 |
} |
| 103 |
} |
| 104 |
} |
| 105 |
} |
| 106 |
} |
| 107 |
|
| 108 |
/** A run: its jobs, refreshed while it is live; rerun or cancel it. */ |
| 109 |
@Composable |
| 110 |
fun RunScreen(vm: RunViewModel, canWrite: Boolean, onBack: () -> Unit, onOpenJob: (FlowJob) -> Unit) { |
| 111 |
val state by vm.state.collectAsStateWithLifecycle() |
| 112 |
val error by vm.error.collectAsStateWithLifecycle() |
| 113 |
val run = (state as? Load.Ready)?.value |
| 114 |
var expediting by rememberSaveable { mutableStateOf(false) } |
| 115 |
AppScreen( |
| 116 |
title = "Run #${vm.number}", |
| 117 |
subtitle = listOfNotNull("${vm.owner}/${vm.name}", run?.bookmark, run?.status).joinToString(" · "), |
| 118 |
onBack = onBack, |
| 119 |
actions = { |
| 120 |
if (canWrite && run != null) { |
| 121 |
if (run.live) { |
| 122 |
IconButton(onClick = { expediting = true }) { Icon(Icons.Outlined.Bolt, contentDescription = "Expedite the run") } |
| 123 |
IconButton(onClick = vm::cancel) { Icon(Icons.Outlined.Block, contentDescription = "Cancel the run") } |
| 124 |
} else { |
| 125 |
IconButton(onClick = vm::rerun) { Icon(Icons.Outlined.Replay, contentDescription = "Run it again") } |
| 126 |
} |
| 127 |
} |
| 128 |
}, |
| 129 |
) { |
| 130 |
LoadContent(state, onRefresh = vm::refresh) { r -> |
| 131 |
LazyColumn(Modifier.fillMaxSize()) { |
| 132 |
if (r.error.isNotEmpty()) item { Text(r.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } |
| 133 |
error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } } |
| 134 |
items(r.jobs, key = { it.name }) { j -> |
| 135 |
val (icon, colour) = statusLook(j.status) |
| 136 |
ListItem( |
| 137 |
modifier = Modifier.clickable { onOpenJob(j) }, |
| 138 |
leadingContent = { Icon(icon, contentDescription = j.status, tint = colour) }, |
| 139 |
headlineContent = { Text(j.name) }, |
| 140 |
supportingContent = { |
| 141 |
Column { |
| 142 |
Text( |
| 143 |
listOfNotNull( |
| 144 |
j.status, |
| 145 |
j.seconds()?.let { "$it s" }, |
| 146 |
j.runner.takeIf { it.isNotEmpty() }?.let { "on $it" }, |
| 147 |
j.exitCode?.let { "exit $it" }, |
| 148 |
).joinToString(" · "), |
| 149 |
) |
| 150 |
if (j.waiting.isNotEmpty()) Text(j.waiting, style = MaterialTheme.typography.bodySmall) |
| 151 |
// A job that waits for a person (#5): the hub's rules decide who may. |
| 152 |
if (canWrite && j.waitsForPerson) { |
| 153 |
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 154 |
Button(onClick = { vm.decide(j.name, approve = true) }) { Text("Approve") } |
| 155 |
OutlinedButton(onClick = { vm.decide(j.name, approve = false) }) { Text("Reject") } |
| 156 |
} |
| 157 |
} |
| 158 |
if (j.error.isNotEmpty()) Text(j.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) |
| 159 |
} |
| 160 |
}, |
| 161 |
trailingContent = if (canWrite && !j.live && !r.live) { |
| 162 |
{ IconButton(onClick = { vm.rerunJob(j.name) }) { Icon(Icons.Outlined.Replay, contentDescription = "Run ${j.name} again") } } |
| 163 |
} else { |
| 164 |
null |
| 165 |
}, |
| 166 |
) |
| 167 |
HorizontalDivider() |
| 168 |
} |
| 169 |
} |
| 170 |
} |
| 171 |
} |
| 172 |
if (expediting) { |
| 173 |
ExpediteDialog(onDismiss = { expediting = false }, onExpedite = { reason -> expediting = false; vm.expedite(reason) }) |
| 174 |
} |
| 175 |
} |
| 176 |
|
| 177 |
/** Make a run urgent, saying why (#5): a maintainer's, within the owner's cap of urgent runs. */ |
| 178 |
@Composable |
| 179 |
private fun ExpediteDialog(onDismiss: () -> Unit, onExpedite: (String) -> Unit) { |
| 180 |
var reason by rememberSaveable { mutableStateOf("") } |
| 181 |
AlertDialog( |
| 182 |
onDismissRequest = onDismiss, |
| 183 |
title = { Text("Expedite") }, |
| 184 |
text = { |
| 185 |
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
| 186 |
Text("It goes ahead of everyday work until it ends. Say why: it is shown on the run and audited.", style = MaterialTheme.typography.bodySmall) |
| 187 |
OutlinedTextField(value = reason, onValueChange = { reason = it.take(200) }, label = { Text("Why it is urgent") }, singleLine = true) |
| 188 |
} |
| 189 |
}, |
| 190 |
confirmButton = { TextButton(onClick = { onExpedite(reason) }, enabled = reason.isNotBlank()) { Text("Expedite") } }, |
| 191 |
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, |
| 192 |
) |
| 193 |
} |
| 194 |
|
| 195 |
/** A job's log, following new lines while it runs. */ |
| 196 |
@Composable |
| 197 |
fun JobLogScreen(vm: JobLogViewModel, onBack: () -> Unit) { |
| 198 |
val state by vm.state.collectAsStateWithLifecycle() |
| 199 |
val list = rememberLazyListState() |
| 200 |
val status = LocalStatusColors.current |
| 201 |
val log = (state as? Load.Ready)?.value |
| 202 |
// Follow the end while the job runs. |
| 203 |
LaunchedEffect(log?.lines?.size) { |
| 204 |
if (log != null && log.live && log.lines.isNotEmpty()) list.scrollToItem(log.lines.size - 1) |
| 205 |
} |
| 206 |
AppScreen(title = vm.job, subtitle = listOfNotNull("${vm.owner}/${vm.name} #${vm.number}", log?.status).joinToString(" · "), onBack = onBack) { |
| 207 |
when (val s = state) { |
| 208 |
Load.Loading -> LoadingState() |
| 209 |
is Load.Failed -> ErrorState(s.message, onRetry = vm::retry) |
| 210 |
is Load.Ready -> SelectionContainer { |
| 211 |
LazyColumn(Modifier.fillMaxSize().horizontalScroll(rememberScrollState()), state = list) { |
| 212 |
items(s.value.lines, key = { it.seq }) { l -> |
| 213 |
Row(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 214 |
Text( |
| 215 |
"%.1fs".format(l.t / 1000.0), |
| 216 |
modifier = Modifier.width(56.dp), |
| 217 |
fontFamily = FontFamily.Monospace, |
| 218 |
style = MaterialTheme.typography.bodySmall, |
| 219 |
color = MaterialTheme.colorScheme.outline, |
| 220 |
) |
| 221 |
Text( |
| 222 |
l.text, |
| 223 |
fontFamily = FontFamily.Monospace, |
| 224 |
style = MaterialTheme.typography.bodySmall, |
| 225 |
softWrap = false, |
| 226 |
color = when (l.stream) { |
| 227 |
"stderr" -> status.bad |
| 228 |
"flow" -> MaterialTheme.colorScheme.primary |
| 229 |
else -> MaterialTheme.colorScheme.onSurface |
| 230 |
}, |
| 231 |
) |
| 232 |
} |
| 233 |
} |
| 234 |
if (s.value.live) item { Text("…", modifier = Modifier.fillMaxWidth().padding(8.dp)) } |
| 235 |
} |
| 236 |
} |
| 237 |
} |
| 238 |
} |
| 239 |
} |