Flow on the phone: a repository's runs, a run's jobs, live logs, rerun and cancel (refs #5) 1c42a70fcba3
A Runs tab lists a repository's runs; a run shows its jobs (where each ran, how long, why one waits, its error), fetched again every few seconds while it is live, with Cancel, Run again, and run one job again; a job's log follows new lines while it runs, stderr and the runner's own lines in their colours.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5f80633a1084
- When
- · 2026-10-25 22:47 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
1c42a70fcba321e9bc60046152644cbe1ee5b7330bebf04c6d8889ce991d0f92- Depends on
- 144cbf7bf2b5 31e4f3755627 4aee5293461d 57c0824300db 5ae4af48ddd2 98dbd1220929 e2df27a81e5d e833eb0a9e7f
- Depended on by
- 821035db5a1c 6e2af63ae616 1dfa631436b6 0ca942a9a1e4 ca672a3ada1f 62429abe5869 6fcaca2bd500 dfbc3e972872
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 23 atoms, 4 tree operations, 8 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/flow/FlowScreens.kt +200 −0
| 1 | + | package io.oxygenhub.android.ui.flow |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.clickable |
|
| 4 | + | import androidx.compose.foundation.horizontalScroll |
|
| 5 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 6 | + | import androidx.compose.foundation.layout.Column |
|
| 7 | + | import androidx.compose.foundation.layout.Row |
|
| 8 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 9 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 10 | + | import androidx.compose.foundation.layout.padding |
|
| 11 | + | import androidx.compose.foundation.layout.width |
|
| 12 | + | import androidx.compose.foundation.lazy.LazyColumn |
|
| 13 | + | import androidx.compose.foundation.lazy.items |
|
| 14 | + | import androidx.compose.foundation.lazy.rememberLazyListState |
|
| 15 | + | import androidx.compose.foundation.rememberScrollState |
|
| 16 | + | import androidx.compose.foundation.text.selection.SelectionContainer |
|
| 17 | + | import androidx.compose.material.icons.Icons |
|
| 18 | + | import androidx.compose.material.icons.outlined.Block |
|
| 19 | + | import androidx.compose.material.icons.outlined.CheckCircle |
|
| 20 | + | import androidx.compose.material.icons.outlined.Error |
|
| 21 | + | import androidx.compose.material.icons.outlined.HourglassEmpty |
|
| 22 | + | import androidx.compose.material.icons.outlined.PlayCircle |
|
| 23 | + | import androidx.compose.material.icons.outlined.RemoveCircleOutline |
|
| 24 | + | import androidx.compose.material.icons.outlined.Replay |
|
| 25 | + | import androidx.compose.material.icons.outlined.RocketLaunch |
|
| 26 | + | import androidx.compose.material3.HorizontalDivider |
|
| 27 | + | import androidx.compose.material3.Icon |
|
| 28 | + | import androidx.compose.material3.IconButton |
|
| 29 | + | import androidx.compose.material3.ListItem |
|
| 30 | + | import androidx.compose.material3.MaterialTheme |
|
| 31 | + | import androidx.compose.material3.Text |
|
| 32 | + | import androidx.compose.runtime.Composable |
|
| 33 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 34 | + | import androidx.compose.runtime.getValue |
|
| 35 | + | import androidx.compose.ui.Modifier |
|
| 36 | + | import androidx.compose.ui.graphics.Color |
|
| 37 | + | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 38 | + | import androidx.compose.ui.text.font.FontFamily |
|
| 39 | + | import androidx.compose.ui.unit.dp |
|
| 40 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 41 | + | import io.oxygenhub.android.api.FlowJob |
|
| 42 | + | import io.oxygenhub.android.api.FlowRun |
|
| 43 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 44 | + | import io.oxygenhub.android.ui.common.EmptyState |
|
| 45 | + | import io.oxygenhub.android.ui.common.ErrorState |
|
| 46 | + | import io.oxygenhub.android.ui.common.Load |
|
| 47 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 48 | + | import io.oxygenhub.android.ui.common.LoadingState |
|
| 49 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 50 | + | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 51 | + | ||
| 52 | + | /** A status's icon and colour, as the hub's run pages show them. */ |
|
| 53 | + | @Composable |
|
| 54 | + | fun statusLook(status: String): Pair<ImageVector, Color> { |
|
| 55 | + | val c = LocalStatusColors.current |
|
| 56 | + | return when (status) { |
|
| 57 | + | "passed" -> Icons.Outlined.CheckCircle to c.ok |
|
| 58 | + | "failed", "error" -> Icons.Outlined.Error to c.bad |
|
| 59 | + | "running" -> Icons.Outlined.PlayCircle to c.warn |
|
| 60 | + | "cancelled" -> Icons.Outlined.Block to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 61 | + | "skipped" -> Icons.Outlined.RemoveCircleOutline to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 62 | + | else -> Icons.Outlined.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /** A repository's runs, for its tab. */ |
|
| 67 | + | @Composable |
|
| 68 | + | fun RunList(vm: RunsViewModel, onOpen: (FlowRun) -> Unit) { |
|
| 69 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 70 | + | LoadContent(state, onRefresh = vm::refresh) { runs -> |
|
| 71 | + | if (runs.isEmpty()) { |
|
| 72 | + | EmptyState(Icons.Outlined.RocketLaunch, "No runs yet", "A push to ${vm.owner}/${vm.name} with a .flow/pipeline.kdl starts one.") |
|
| 73 | + | } else { |
|
| 74 | + | LazyColumn(Modifier.fillMaxSize()) { |
|
| 75 | + | items(runs, key = { it.number }) { r -> |
|
| 76 | + | val (icon, colour) = statusLook(r.status) |
|
| 77 | + | ListItem( |
|
| 78 | + | modifier = Modifier.clickable { onOpen(r) }, |
|
| 79 | + | leadingContent = { Icon(icon, contentDescription = r.status, tint = colour) }, |
|
| 80 | + | headlineContent = { Text("#${r.number} ${r.bookmark}") }, |
|
| 81 | + | supportingContent = { |
|
| 82 | + | Text( |
|
| 83 | + | listOfNotNull( |
|
| 84 | + | r.status, |
|
| 85 | + | "${r.trigger} by ${r.actor}", |
|
| 86 | + | relativeTime(r.createdMs).takeIf { it.isNotEmpty() }, |
|
| 87 | + | r.queuePosition?.let { "#$it in the queue" }, |
|
| 88 | + | ).joinToString(" · "), |
|
| 89 | + | ) |
|
| 90 | + | }, |
|
| 91 | + | ) |
|
| 92 | + | HorizontalDivider() |
|
| 93 | + | } |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | /** A run: its jobs, refreshed while it is live; rerun or cancel it. */ |
|
| 100 | + | @Composable |
|
| 101 | + | fun RunScreen(vm: RunViewModel, canWrite: Boolean, onBack: () -> Unit, onOpenJob: (FlowJob) -> Unit) { |
|
| 102 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 103 | + | val error by vm.error.collectAsStateWithLifecycle() |
|
| 104 | + | val run = (state as? Load.Ready)?.value |
|
| 105 | + | AppScreen( |
|
| 106 | + | title = "Run #${vm.number}", |
|
| 107 | + | subtitle = listOfNotNull("${vm.owner}/${vm.name}", run?.bookmark, run?.status).joinToString(" · "), |
|
| 108 | + | onBack = onBack, |
|
| 109 | + | actions = { |
|
| 110 | + | if (canWrite && run != null) { |
|
| 111 | + | if (run.live) { |
|
| 112 | + | IconButton(onClick = vm::cancel) { Icon(Icons.Outlined.Block, contentDescription = "Cancel the run") } |
|
| 113 | + | } else { |
|
| 114 | + | IconButton(onClick = vm::rerun) { Icon(Icons.Outlined.Replay, contentDescription = "Run it again") } |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | }, |
|
| 118 | + | ) { |
|
| 119 | + | LoadContent(state, onRefresh = vm::refresh) { r -> |
|
| 120 | + | LazyColumn(Modifier.fillMaxSize()) { |
|
| 121 | + | if (r.error.isNotEmpty()) item { Text(r.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } |
|
| 122 | + | error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } } |
|
| 123 | + | items(r.jobs, key = { it.name }) { j -> |
|
| 124 | + | val (icon, colour) = statusLook(j.status) |
|
| 125 | + | ListItem( |
|
| 126 | + | modifier = Modifier.clickable { onOpenJob(j) }, |
|
| 127 | + | leadingContent = { Icon(icon, contentDescription = j.status, tint = colour) }, |
|
| 128 | + | headlineContent = { Text(j.name) }, |
|
| 129 | + | supportingContent = { |
|
| 130 | + | Column { |
|
| 131 | + | Text( |
|
| 132 | + | listOfNotNull( |
|
| 133 | + | j.status, |
|
| 134 | + | j.seconds()?.let { "$it s" }, |
|
| 135 | + | j.runner.takeIf { it.isNotEmpty() }?.let { "on $it" }, |
|
| 136 | + | j.exitCode?.let { "exit $it" }, |
|
| 137 | + | ).joinToString(" · "), |
|
| 138 | + | ) |
|
| 139 | + | if (j.waiting.isNotEmpty()) Text(j.waiting, style = MaterialTheme.typography.bodySmall) |
|
| 140 | + | if (j.error.isNotEmpty()) Text(j.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) |
|
| 141 | + | } |
|
| 142 | + | }, |
|
| 143 | + | trailingContent = if (canWrite && !j.live && !r.live) { |
|
| 144 | + | { IconButton(onClick = { vm.rerunJob(j.name) }) { Icon(Icons.Outlined.Replay, contentDescription = "Run ${j.name} again") } } |
|
| 145 | + | } else { |
|
| 146 | + | null |
|
| 147 | + | }, |
|
| 148 | + | ) |
|
| 149 | + | HorizontalDivider() |
|
| 150 | + | } |
|
| 151 | + | } |
|
| 152 | + | } |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | /** A job's log, following new lines while it runs. */ |
|
| 157 | + | @Composable |
|
| 158 | + | fun JobLogScreen(vm: JobLogViewModel, onBack: () -> Unit) { |
|
| 159 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 160 | + | val list = rememberLazyListState() |
|
| 161 | + | val status = LocalStatusColors.current |
|
| 162 | + | val log = (state as? Load.Ready)?.value |
|
| 163 | + | // Follow the end while the job runs. |
|
| 164 | + | LaunchedEffect(log?.lines?.size) { |
|
| 165 | + | if (log != null && log.live && log.lines.isNotEmpty()) list.scrollToItem(log.lines.size - 1) |
|
| 166 | + | } |
|
| 167 | + | AppScreen(title = vm.job, subtitle = listOfNotNull("${vm.owner}/${vm.name} #${vm.number}", log?.status).joinToString(" · "), onBack = onBack) { |
|
| 168 | + | when (val s = state) { |
|
| 169 | + | Load.Loading -> LoadingState() |
|
| 170 | + | is Load.Failed -> ErrorState(s.message, onRetry = vm::retry) |
|
| 171 | + | is Load.Ready -> SelectionContainer { |
|
| 172 | + | LazyColumn(Modifier.fillMaxSize().horizontalScroll(rememberScrollState()), state = list) { |
|
| 173 | + | items(s.value.lines, key = { it.seq }) { l -> |
|
| 174 | + | Row(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 175 | + | Text( |
|
| 176 | + | "%.1fs".format(l.t / 1000.0), |
|
| 177 | + | modifier = Modifier.width(56.dp), |
|
| 178 | + | fontFamily = FontFamily.Monospace, |
|
| 179 | + | style = MaterialTheme.typography.bodySmall, |
|
| 180 | + | color = MaterialTheme.colorScheme.outline, |
|
| 181 | + | ) |
|
| 182 | + | Text( |
|
| 183 | + | l.text, |
|
| 184 | + | fontFamily = FontFamily.Monospace, |
|
| 185 | + | style = MaterialTheme.typography.bodySmall, |
|
| 186 | + | softWrap = false, |
|
| 187 | + | color = when (l.stream) { |
|
| 188 | + | "stderr" -> status.bad |
|
| 189 | + | "flow" -> MaterialTheme.colorScheme.primary |
|
| 190 | + | else -> MaterialTheme.colorScheme.onSurface |
|
| 191 | + | }, |
|
| 192 | + | ) |
|
| 193 | + | } |
|
| 194 | + | } |
|
| 195 | + | if (s.value.live) item { Text("…", modifier = Modifier.fillMaxWidth().padding(8.dp)) } |
|
| 196 | + | } |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/flow/FlowViewModels.kt +149 −0
| 1 | + | package io.oxygenhub.android.ui.flow |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.FlowRun |
|
| 6 | + | import io.oxygenhub.android.api.HubApi |
|
| 7 | + | import io.oxygenhub.android.api.LogLine |
|
| 8 | + | import io.oxygenhub.android.api.userMessage |
|
| 9 | + | import io.oxygenhub.android.ui.common.Load |
|
| 10 | + | import io.oxygenhub.android.ui.common.load |
|
| 11 | + | import kotlinx.coroutines.CancellationException |
|
| 12 | + | import kotlinx.coroutines.Job |
|
| 13 | + | import kotlinx.coroutines.delay |
|
| 14 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 15 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 16 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 17 | + | import kotlinx.coroutines.isActive |
|
| 18 | + | import kotlinx.coroutines.launch |
|
| 19 | + | ||
| 20 | + | /** A repository's runs, newest first (#5). */ |
|
| 21 | + | class RunsViewModel(private val api: HubApi, val owner: String, val name: String) : ViewModel() { |
|
| 22 | + | private val _state = MutableStateFlow<Load<List<FlowRun>>>(Load.Loading) |
|
| 23 | + | val state: StateFlow<Load<List<FlowRun>>> = _state.asStateFlow() |
|
| 24 | + | private var job: Job? = null |
|
| 25 | + | ||
| 26 | + | init { |
|
| 27 | + | refresh() |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | fun refresh() { |
|
| 31 | + | job = load(_state, job) { api.runs(owner, name) } |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /** |
|
| 36 | + | * One run and its jobs, fetched again every few seconds while it is queued |
|
| 37 | + | * or running, so its jobs' states follow; rerun and cancel it. |
|
| 38 | + | */ |
|
| 39 | + | class RunViewModel( |
|
| 40 | + | private val api: HubApi, |
|
| 41 | + | val owner: String, |
|
| 42 | + | val name: String, |
|
| 43 | + | val number: Long, |
|
| 44 | + | /** How often a live run is fetched again; 0 not at all (tests). */ |
|
| 45 | + | private val pollMs: Long = 3000, |
|
| 46 | + | ) : ViewModel() { |
|
| 47 | + | private val _state = MutableStateFlow<Load<FlowRun>>(Load.Loading) |
|
| 48 | + | val state: StateFlow<Load<FlowRun>> = _state.asStateFlow() |
|
| 49 | + | ||
| 50 | + | private val _error = MutableStateFlow<String?>(null) |
|
| 51 | + | val error: StateFlow<String?> = _error.asStateFlow() |
|
| 52 | + | ||
| 53 | + | private var job: Job? = null |
|
| 54 | + | ||
| 55 | + | init { |
|
| 56 | + | refresh() |
|
| 57 | + | if (pollMs > 0) { |
|
| 58 | + | viewModelScope.launch { |
|
| 59 | + | while (isActive) { |
|
| 60 | + | delay(pollMs) |
|
| 61 | + | if ((_state.value as? Load.Ready)?.value?.live == true) poll() |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | fun refresh() { |
|
| 68 | + | job = load(_state, job) { api.run(owner, name, number) } |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /** Fetch it again without a spinner. */ |
|
| 72 | + | suspend fun poll() { |
|
| 73 | + | try { |
|
| 74 | + | _state.value = Load.Ready(api.run(owner, name, number)) |
|
| 75 | + | } catch (e: CancellationException) { |
|
| 76 | + | throw e |
|
| 77 | + | } catch (_: Exception) { |
|
| 78 | + | // The next tick tries again; the last state stays shown. |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | fun rerun() = act { api.rerunRun(owner, name, number) } |
|
| 83 | + | ||
| 84 | + | fun rerunJob(job: String) = act { api.rerunJob(owner, name, number, job) } |
|
| 85 | + | ||
| 86 | + | fun cancel() = act { api.cancelRun(owner, name, number) } |
|
| 87 | + | ||
| 88 | + | private fun act(step: suspend () -> Unit) { |
|
| 89 | + | _error.value = null |
|
| 90 | + | viewModelScope.launch { |
|
| 91 | + | try { |
|
| 92 | + | step() |
|
| 93 | + | poll() |
|
| 94 | + | } catch (e: CancellationException) { |
|
| 95 | + | throw e |
|
| 96 | + | } catch (e: Exception) { |
|
| 97 | + | _error.value = e.userMessage() |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /** |
|
| 104 | + | * A job's log, followed while the job runs: new lines are asked for after |
|
| 105 | + | * the last one held, every couple of seconds, until the job has finished. |
|
| 106 | + | */ |
|
| 107 | + | class JobLogViewModel( |
|
| 108 | + | private val api: HubApi, |
|
| 109 | + | val owner: String, |
|
| 110 | + | val name: String, |
|
| 111 | + | val number: Long, |
|
| 112 | + | val job: String, |
|
| 113 | + | private val pollMs: Long = 2000, |
|
| 114 | + | ) : ViewModel() { |
|
| 115 | + | data class Log(val lines: List<LogLine>, val status: String, val live: Boolean) |
|
| 116 | + | ||
| 117 | + | private val _state = MutableStateFlow<Load<Log>>(Load.Loading) |
|
| 118 | + | val state: StateFlow<Load<Log>> = _state.asStateFlow() |
|
| 119 | + | ||
| 120 | + | init { |
|
| 121 | + | viewModelScope.launch { |
|
| 122 | + | next() |
|
| 123 | + | while (pollMs > 0 && isActive && (_state.value as? Load.Ready)?.value?.live != false) { |
|
| 124 | + | delay(pollMs) |
|
| 125 | + | next() |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /** The lines after the last one held, and whether the job still runs. */ |
|
| 131 | + | suspend fun next() { |
|
| 132 | + | val held = (_state.value as? Load.Ready)?.value |
|
| 133 | + | try { |
|
| 134 | + | val after = held?.lines?.lastOrNull()?.seq ?: 0 |
|
| 135 | + | val more = api.jobLog(owner, name, number, job, after) |
|
| 136 | + | val j = api.run(owner, name, number).jobs.firstOrNull { it.name == job } |
|
| 137 | + | _state.value = Load.Ready(Log((held?.lines.orEmpty()) + more, j?.status ?: held?.status.orEmpty(), j?.live ?: false)) |
|
| 138 | + | } catch (e: CancellationException) { |
|
| 139 | + | throw e |
|
| 140 | + | } catch (e: Exception) { |
|
| 141 | + | if (held == null) _state.value = Load.failed(e) |
|
| 142 | + | } |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | fun retry() { |
|
| 146 | + | _state.value = Load.Loading |
|
| 147 | + | viewModelScope.launch { next() } |
|
| 148 | + | } |
|
| 149 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/FlowViewModelsTest.kt +64 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 5 | + | import io.oxygenhub.android.api.FlowJob |
|
| 6 | + | import io.oxygenhub.android.api.FlowRun |
|
| 7 | + | import io.oxygenhub.android.api.LogLine |
|
| 8 | + | import io.oxygenhub.android.ui.common.Load |
|
| 9 | + | import io.oxygenhub.android.ui.flow.JobLogViewModel |
|
| 10 | + | import io.oxygenhub.android.ui.flow.RunViewModel |
|
| 11 | + | import io.oxygenhub.android.ui.flow.RunsViewModel |
|
| 12 | + | import kotlinx.coroutines.test.runTest |
|
| 13 | + | import org.junit.Assert.assertEquals |
|
| 14 | + | import org.junit.Assert.assertFalse |
|
| 15 | + | import org.junit.Assert.assertTrue |
|
| 16 | + | import org.junit.Rule |
|
| 17 | + | import org.junit.Test |
|
| 18 | + | ||
| 19 | + | class FlowViewModelsTest { |
|
| 20 | + | @get:Rule val main = MainDispatcherRule() |
|
| 21 | + | ||
| 22 | + | private val api = FakeHubApi().apply { |
|
| 23 | + | runList = listOf(FlowRun(56, "main", "passed"), FlowRun(55, "main", "failed")) |
|
| 24 | + | runStore[56] = FlowRun(56, "main", "running", jobs = listOf(FlowJob("count", "running", runner = "android-pixel-10-pro-fold"))) |
|
| 25 | + | logs["count"] = mutableListOf(LogLine(1, 0, "flow", "fetching"), LogLine(2, 1800, "stdout", "step 1 of 120")) |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | @Test |
|
| 29 | + | fun listsTheRepositorysRuns() { |
|
| 30 | + | val runs = (RunsViewModel(api, "andymccall", "phone-runner-demo").state.value as Load.Ready).value |
|
| 31 | + | assertEquals(listOf(56L, 55L), runs.map { it.number }) |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | @Test |
|
| 35 | + | fun aRunFollowsItsJobsAndCanBeCancelled() = runTest { |
|
| 36 | + | val vm = RunViewModel(api, "andymccall", "phone-runner-demo", 56, pollMs = 0) |
|
| 37 | + | assertTrue((vm.state.value as Load.Ready).value.live) |
|
| 38 | + | api.runStore[56] = api.runStore.getValue(56).copy(status = "passed", jobs = listOf(FlowJob("count", "passed", runner = "plex-1"))) |
|
| 39 | + | vm.poll() |
|
| 40 | + | assertEquals("plex-1", (vm.state.value as Load.Ready).value.jobs[0].runner) |
|
| 41 | + | vm.cancel() |
|
| 42 | + | assertTrue("cancel 56" in api.calls) |
|
| 43 | + | vm.rerunJob("count") |
|
| 44 | + | assertTrue("rerun 56 count" in api.calls) |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | @Test |
|
| 48 | + | fun aLogGrowsFromTheLastLineHeldUntilTheJobEnds() = runTest { |
|
| 49 | + | val vm = JobLogViewModel(api, "andymccall", "phone-runner-demo", 56, "count", pollMs = 0) |
|
| 50 | + | var log = (vm.state.value as Load.Ready).value |
|
| 51 | + | assertEquals(listOf("fetching", "step 1 of 120"), log.lines.map { it.text }) |
|
| 52 | + | assertTrue(log.live) |
|
| 53 | + | api.logs.getValue("count") += LogLine(3, 2500, "flow", "── handed back by android-pixel-10-pro-fold: off Wi-Fi") |
|
| 54 | + | api.runStore[56] = api.runStore.getValue(56).copy(status = "running", jobs = listOf(FlowJob("count", "queued"))) |
|
| 55 | + | vm.next() |
|
| 56 | + | log = (vm.state.value as Load.Ready).value |
|
| 57 | + | assertEquals(3, log.lines.size) |
|
| 58 | + | assertTrue("log count after 2" in api.calls) |
|
| 59 | + | api.runStore[56] = api.runStore.getValue(56).copy(status = "passed", jobs = listOf(FlowJob("count", "passed"))) |
|
| 60 | + | vm.next() |
|
| 61 | + | assertFalse((vm.state.value as Load.Ready).value.live) |
|
| 62 | + | assertEquals("passed", (vm.state.value as Load.Ready).value.status) |
|
| 63 | + | } |
|
| 64 | + | } |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +40 −0
| … | 68 unchanged lines |
||
| 69 | 69 | ||
| 70 | 70 | suspend fun issue(owner: String, name: String, number: Long): Issue |
|
| 71 | 71 | ||
| 72 | + | /** A repository's runs, newest first. */ |
|
| 73 | + | suspend fun runs(owner: String, name: String): List<FlowRun> |
|
| 74 | + | ||
| 75 | + | /** One run, with its jobs. */ |
|
| 76 | + | suspend fun run(owner: String, name: String, number: Long): FlowRun |
|
| 77 | + | ||
| 78 | + | /** A job's log lines after [after] (0: from the start). */ |
|
| 79 | + | suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine> |
|
| 80 | + | ||
| 81 | + | suspend fun rerunRun(owner: String, name: String, number: Long) |
|
| 82 | + | ||
| 83 | + | /** Run [job] again in the same run, with the jobs that depend on it. */ |
|
| 84 | + | suspend fun rerunJob(owner: String, name: String, number: Long, job: String) |
|
| 85 | + | ||
| 86 | + | suspend fun cancelRun(owner: String, name: String, number: Long) |
|
| 87 | + | ||
| 72 | 88 | /** [owner]'s project boards the caller can see. */ |
|
| 73 | 89 | suspend fun projects(owner: String): List<Project> |
|
| 74 | 90 | ||
| … | 63 unchanged lines |
||
| 138 | 154 | get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer()) |
|
| 139 | 155 | ||
| 140 | 156 | override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer()) |
|
| 157 | + | ||
| 158 | + | override suspend fun runs(owner: String, name: String): List<FlowRun> = |
|
| 159 | + | get(url("repos", owner, name, "flow", "runs"), ListSerializer(FlowRun.serializer())) |
|
| 160 | + | ||
| 161 | + | override suspend fun run(owner: String, name: String, number: Long): FlowRun = |
|
| 162 | + | get(url("repos", owner, name, "flow", "runs", number.toString()), FlowRun.serializer()) |
|
| 163 | + | ||
| 164 | + | override suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long): List<LogLine> = |
|
| 165 | + | get( |
|
| 166 | + | url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "log") { addQueryParameter("after", after.toString()) }, |
|
| 167 | + | ListSerializer(LogLine.serializer()), |
|
| 168 | + | ) |
|
| 169 | + | ||
| 170 | + | override suspend fun rerunRun(owner: String, name: String, number: Long) { |
|
| 171 | + | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "rerun")) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) { |
|
| 175 | + | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, "rerun")) |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | override suspend fun cancelRun(owner: String, name: String, number: Long) { |
|
| 179 | + | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "cancel")) |
|
| 180 | + | } |
|
| 141 | 181 | ||
| 142 | 182 | override suspend fun projects(owner: String): List<Project> = get(url("projects", owner), ListSerializer(Project.serializer())) |
|
| 143 | 183 | ||
| … | 192 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +44 −0
| … | 76 unchanged lines |
||
| 77 | 77 | val isOpen: Boolean get() = state == "open" |
|
| 78 | 78 | } |
|
| 79 | 79 | ||
| 80 | + | /** A Flow run of a repository's pipeline (#5). */ |
|
| 81 | + | @Serializable |
|
| 82 | + | data class FlowRun( |
|
| 83 | + | val number: Long, |
|
| 84 | + | val bookmark: String = "", |
|
| 85 | + | /** `queued`, `running`, `passed`, `failed`, `error` or `cancelled`. */ |
|
| 86 | + | val status: String = "queued", |
|
| 87 | + | /** `push`, `manual`, `schedule`, `proposal` or `rerun:N`. */ |
|
| 88 | + | val trigger: String = "", |
|
| 89 | + | val actor: String = "", |
|
| 90 | + | val error: String = "", |
|
| 91 | + | @SerialName("created_ms") val createdMs: Long = 0, |
|
| 92 | + | @SerialName("finished_ms") val finishedMs: Long? = null, |
|
| 93 | + | val url: String = "", |
|
| 94 | + | @SerialName("queue_position") val queuePosition: Long? = null, |
|
| 95 | + | val jobs: List<FlowJob> = emptyList(), |
|
| 96 | + | ) { |
|
| 97 | + | val live: Boolean get() = status == "queued" || status == "running" |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | /** A job of a run. */ |
|
| 101 | + | @Serializable |
|
| 102 | + | data class FlowJob( |
|
| 103 | + | val name: String, |
|
| 104 | + | val status: String = "queued", |
|
| 105 | + | @SerialName("depends_on") val dependsOn: List<String> = emptyList(), |
|
| 106 | + | val runner: String = "", |
|
| 107 | + | @SerialName("exit_code") val exitCode: Long? = null, |
|
| 108 | + | val error: String = "", |
|
| 109 | + | @SerialName("started_ms") val startedMs: Long? = null, |
|
| 110 | + | @SerialName("finished_ms") val finishedMs: Long? = null, |
|
| 111 | + | @SerialName("log_lines") val logLines: Long = 0, |
|
| 112 | + | val waiting: String = "", |
|
| 113 | + | ) { |
|
| 114 | + | val live: Boolean get() = status == "queued" || status == "running" |
|
| 115 | + | ||
| 116 | + | /** How long it ran, or has been running, in seconds. */ |
|
| 117 | + | fun seconds(now: Long = System.currentTimeMillis()): Long? = startedMs?.let { ((finishedMs ?: now) - it) / 1000 } |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | /** A line of a job's log: `stdout`, `stderr` or `flow` (the runner's own). */ |
|
| 121 | + | @Serializable |
|
| 122 | + | data class LogLine(val seq: Long, val t: Long = 0, val stream: String = "stdout", val text: String = "") |
|
| 123 | + | ||
| 80 | 124 | /** A project board's summary (`GET /projects/{owner}`). */ |
|
| 81 | 125 | @Serializable |
|
| 82 | 126 | data class Project( |
|
| … | 286 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +27 −0
| … | 47 unchanged lines |
||
| 48 | 48 | import io.oxygenhub.android.ui.common.rememberAvatar |
|
| 49 | 49 | import io.oxygenhub.android.ui.dashboard.DashboardScreen |
|
| 50 | 50 | import io.oxygenhub.android.ui.dashboard.DashboardViewModel |
|
| 51 | + | import io.oxygenhub.android.ui.flow.JobLogScreen |
|
| 52 | + | import io.oxygenhub.android.ui.flow.JobLogViewModel |
|
| 53 | + | import io.oxygenhub.android.ui.flow.RunScreen |
|
| 54 | + | import io.oxygenhub.android.ui.flow.RunViewModel |
|
| 55 | + | import io.oxygenhub.android.ui.flow.RunsViewModel |
|
| 51 | 56 | import io.oxygenhub.android.ui.inbox.InboxScreen |
|
| 52 | 57 | import io.oxygenhub.android.ui.inbox.InboxViewModel |
|
| 53 | 58 | import io.oxygenhub.android.ui.issues.IssueScreen |
|
| … | 51 unchanged lines |
||
| 105 | 110 | ||
| 106 | 111 | @Serializable data class NewIssueRoute(val owner: String, val name: String) |
|
| 107 | 112 | ||
| 113 | + | /** A Flow run, and a job's log (#5). */ |
|
| 114 | + | @Serializable data class RunRoute(val owner: String, val name: String, val number: Long) |
|
| 115 | + | ||
| 116 | + | @Serializable data class JobLogRoute(val owner: String, val name: String, val number: Long, val job: String) |
|
| 117 | + | ||
| 108 | 118 | enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) { |
|
| 109 | 119 | Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class), |
|
| 110 | 120 | Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class), |
|
| … | 71 unchanged lines |
||
| 182 | 192 | rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) }, |
|
| 183 | 193 | rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) }, |
|
| 184 | 194 | rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) }, |
|
| 195 | + | rememberViewModel(key = "runs:$slug") { RunsViewModel(api, owner, name) }, |
|
| 185 | 196 | onBack, |
|
| 186 | 197 | onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) }, |
|
| 187 | 198 | onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) }, |
|
| 188 | 199 | onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) }, |
|
| 189 | 200 | onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) }, |
|
| 190 | 201 | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null, |
|
| 202 | + | onOpenRun = { nav.navigate(RunRoute(owner, name, it)) }, |
|
| 191 | 203 | ) |
|
| 192 | 204 | } |
|
| 193 | 205 | } |
|
| … | 3 unchanged lines |
||
| 197 | 209 | rememberViewModel { CodeViewModel(api, r.owner, r.name) }, |
|
| 198 | 210 | rememberViewModel { IssuesViewModel(api, r.owner, r.name) }, |
|
| 199 | 211 | rememberViewModel { ProposalsViewModel(api, r.owner, r.name) }, |
|
| 212 | + | rememberViewModel { RunsViewModel(api, r.owner, r.name) }, |
|
| 200 | 213 | onBack = { nav.popBackStack() }, |
|
| 201 | 214 | onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) }, |
|
| 202 | 215 | onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) }, |
|
| 203 | 216 | onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) }, |
|
| 204 | 217 | onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) }, |
|
| 205 | 218 | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null, |
|
| 219 | + | onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) }, |
|
| 206 | 220 | ) |
|
| 207 | 221 | } |
|
| 208 | 222 | composable<ProjectsRoute> { |
|
| … | 11 unchanged lines |
||
| 220 | 234 | } |
|
| 221 | 235 | }, |
|
| 222 | 236 | ) |
|
| 237 | + | } |
|
| 238 | + | composable<RunRoute> { e -> |
|
| 239 | + | val r = e.toRoute<RunRoute>() |
|
| 240 | + | RunScreen( |
|
| 241 | + | rememberViewModel { RunViewModel(api, r.owner, r.name, r.number) }, |
|
| 242 | + | canWrite = session.canWrite, |
|
| 243 | + | onBack = { nav.popBackStack() }, |
|
| 244 | + | onOpenJob = { j -> nav.navigate(JobLogRoute(r.owner, r.name, r.number, j.name)) }, |
|
| 245 | + | ) |
|
| 246 | + | } |
|
| 247 | + | composable<JobLogRoute> { e -> |
|
| 248 | + | val r = e.toRoute<JobLogRoute>() |
|
| 249 | + | JobLogScreen(rememberViewModel { JobLogViewModel(api, r.owner, r.name, r.number, r.job) }, onBack = { nav.popBackStack() }) |
|
| 223 | 250 | } |
|
| 224 | 251 | composable<IssueRoute> { e -> |
|
| 225 | 252 | val r = e.toRoute<IssueRoute>() |
|
| … | 65 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +10 −4
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | import androidx.compose.foundation.layout.Column |
|
| 4 | 4 | import androidx.compose.foundation.layout.fillMaxSize |
|
| 5 | - | import androidx.compose.material3.PrimaryTabRow |
|
| 5 | + | import androidx.compose.material3.PrimaryScrollableTabRow |
|
| 6 | 6 | import androidx.compose.material3.Tab |
|
| 7 | 7 | import androidx.compose.material3.Text |
|
| 8 | 8 | import androidx.compose.runtime.Composable |
|
| … | 2 unchanged lines |
||
| 11 | 11 | import androidx.compose.runtime.saveable.rememberSaveable |
|
| 12 | 12 | import androidx.compose.runtime.setValue |
|
| 13 | 13 | import androidx.compose.ui.Modifier |
|
| 14 | + | import androidx.compose.ui.unit.dp |
|
| 14 | 15 | import io.oxygenhub.android.api.Proposal |
|
| 15 | 16 | import io.oxygenhub.android.ui.code.CodeBrowser |
|
| 16 | 17 | import io.oxygenhub.android.ui.code.CodeViewModel |
|
| 18 | + | import io.oxygenhub.android.ui.flow.RunList |
|
| 19 | + | import io.oxygenhub.android.ui.flow.RunsViewModel |
|
| 17 | 20 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 18 | 21 | import io.oxygenhub.android.ui.issues.IssueList |
|
| 19 | 22 | import io.oxygenhub.android.ui.issues.IssuesViewModel |
|
| 20 | 23 | import io.oxygenhub.android.ui.proposals.ProposalList |
|
| 21 | 24 | import io.oxygenhub.android.ui.proposals.ProposalsViewModel |
|
| 22 | 25 | ||
| 23 | - | /** A repository's tabs: its code, its issues and its proposals (#3). */ |
|
| 24 | - | enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals") } |
|
| 26 | + | /** A repository's tabs: its code, issues, proposals and Flow runs (#3, #5). */ |
|
| 27 | + | enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals"), Runs("Runs") } |
|
| 25 | 28 | ||
| 26 | 29 | @Composable |
|
| 27 | 30 | fun RepositoryScreen( |
|
| 28 | 31 | code: CodeViewModel, |
|
| 29 | 32 | issues: IssuesViewModel, |
|
| 30 | 33 | proposals: ProposalsViewModel, |
|
| 34 | + | runs: RunsViewModel, |
|
| 31 | 35 | onBack: (() -> Unit)?, |
|
| 32 | 36 | onOpenProposal: (Proposal) -> Unit, |
|
| 33 | 37 | onOpenFile: (ref: String, path: String) -> Unit, |
|
| 34 | 38 | onOpenHistory: (ref: String) -> Unit, |
|
| 35 | 39 | onOpenIssue: (Long) -> Unit, |
|
| 36 | 40 | onNewIssue: (() -> Unit)?, |
|
| 41 | + | onOpenRun: (Long) -> Unit, |
|
| 37 | 42 | initialTab: RepositoryTab = RepositoryTab.Code, |
|
| 38 | 43 | ) { |
|
| 39 | 44 | var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } |
|
| 40 | 45 | AppScreen(title = "${issues.owner}/${issues.name}", onBack = onBack) { |
|
| 41 | 46 | Column(Modifier.fillMaxSize()) { |
|
| 42 | - | PrimaryTabRow(selectedTabIndex = tab) { |
|
| 47 | + | PrimaryScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { |
|
| 43 | 48 | RepositoryTab.entries.forEach { t -> |
|
| 44 | 49 | Tab(selected = tab == t.ordinal, onClick = { tab = t.ordinal }, text = { Text(t.label) }) |
|
| 45 | 50 | } |
|
| … | 2 unchanged lines |
||
| 48 | 53 | RepositoryTab.Code -> CodeBrowser(code, onOpenFile, onOpenHistory) |
|
| 49 | 54 | RepositoryTab.Issues -> IssueList(issues, onOpenIssue, onNewIssue) |
|
| 50 | 55 | RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal) |
|
| 56 | + | RepositoryTab.Runs -> RunList(runs) { onOpenRun(it.number) } |
|
| 51 | 57 | } |
|
| 52 | 58 | } |
|
| 53 | 59 | } |
|
| … | 1 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +17 −0
| … | 58 unchanged lines |
||
| 59 | 59 | } |
|
| 60 | 60 | var runnerToken: () -> io.oxygenhub.android.api.RunnerToken = { io.oxygenhub.android.api.RunnerToken("flowreg_test", "andymccall") } |
|
| 61 | 61 | override suspend fun runnerToken() = runnerToken.invoke().also { calls += "runner token" } |
|
| 62 | + | var runList: List<io.oxygenhub.android.api.FlowRun> = emptyList() |
|
| 63 | + | override suspend fun runs(owner: String, name: String) = runList.also { calls += "runs" } |
|
| 64 | + | val runStore = mutableMapOf<Long, io.oxygenhub.android.api.FlowRun>() |
|
| 65 | + | override suspend fun run(owner: String, name: String, number: Long) = runStore.getValue(number).also { calls += "run $number" } |
|
| 66 | + | val logs = mutableMapOf<String, MutableList<io.oxygenhub.android.api.LogLine>>() |
|
| 67 | + | override suspend fun jobLog(owner: String, name: String, number: Long, job: String, after: Long) = |
|
| 68 | + | logs[job].orEmpty().filter { it.seq > after }.also { calls += "log $job after $after" } |
|
| 69 | + | override suspend fun rerunRun(owner: String, name: String, number: Long) { |
|
| 70 | + | calls += "rerun $number" |
|
| 71 | + | } |
|
| 72 | + | override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) { |
|
| 73 | + | calls += "rerun $number $job" |
|
| 74 | + | } |
|
| 75 | + | override suspend fun cancelRun(owner: String, name: String, number: Long) { |
|
| 76 | + | calls += "cancel $number" |
|
| 77 | + | runStore[number]?.let { runStore[number] = it.copy(status = "cancelled") } |
|
| 78 | + | } |
|
| 62 | 79 | var projectsOf: (String) -> List<io.oxygenhub.android.api.Project> = { emptyList() } |
|
| 63 | 80 | override suspend fun projects(owner: String) = projectsOf(owner).also { calls += "projects $owner" } |
|
| 64 | 81 | var boards = mutableMapOf<Long, io.oxygenhub.android.api.Board>() |
|
| … | 81 unchanged lines |