Adopt GET /api/hermes/update/receipt and append its summary to the end of the hermes-update action progress popup after it finishes. Additive only: existing checkHermesUpdate + connection-retry polling is untouched. 404 (old backend) is handled gracefully. - add UpdateReceiptResponse data model matching backend schema - add getUpdateReceipt() to HermesApiService - ActionProgressController gains trailingLines + pushTrailingLines - ActionProgressDialog renders trailingLines after the log - SystemViewModel fetches receipt on action finish and formats it - extract pure formatUpdateReceiptLines for mockk-free testing
This commit is contained in:
8 files changed
+342
-1
No files matched your search
@@ -40,6 +40,11 @@ data class ActionProgressState(
|
||||
val lines: List<String> = emptyList(),
|
||||
val exitCode: Int? = null,
|
||||
val error: String? = null,
|
||||
/**
|
||||
* Host-appended lines shown after the log tail once the action finishes
|
||||
* (e.g. a structured update receipt). Empty unless the host pushes them.
|
||||
*/
|
||||
val trailingLines: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -150,6 +155,16 @@ class ActionProgressController(
|
||||
_state.update { it.copy(phase = ActionProgressPhase.FAILED, error = error) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Append host-supplied lines after the log tail (e.g. an update receipt
|
||||
* summary). Safe to call after the action has finished; only affects
|
||||
* [ActionProgressState.trailingLines], never the poll loop.
|
||||
*/
|
||||
fun pushTrailingLines(lines: List<String>) {
|
||||
if (lines.isEmpty()) return
|
||||
_state.update { it.copy(trailingLines = it.trailingLines + lines) }
|
||||
}
|
||||
|
||||
/** Stop tracking and hide the dialog. Safe mid-run (action keeps going). */
|
||||
fun dismiss() {
|
||||
pollJob?.cancel()
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -169,6 +170,26 @@ fun ActionProgressDialog(
|
||||
color = statusColors.error,
|
||||
)
|
||||
}
|
||||
|
||||
// Host-appended trailing block (e.g. update receipt summary),
|
||||
// shown after the log tail once the action has finished.
|
||||
if (state.trailingLines.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = spacing.sm),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
state.trailingLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -110,7 +110,7 @@ class SystemViewModel(application: Application) :
|
||||
val actionProgress =
|
||||
ActionProgressController(
|
||||
scope = viewModelScope,
|
||||
onFinished = { loadAll() },
|
||||
onFinished = { status -> onActionFinished(status) },
|
||||
)
|
||||
|
||||
private var actionPollingJob: Job? = null
|
||||
@@ -223,6 +223,38 @@ class SystemViewModel(application: Application) :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when [actionProgress] finishes tracking a backend action. For the
|
||||
* `hermes-update` action we additionally fetch the durable update receipt
|
||||
* (issue #958) and append its summary to the end of the progress popup,
|
||||
* then refresh the screen so the version row reflects the new build. Other
|
||||
* actions just refresh. A 404 / missing receipt is silently skipped — the
|
||||
* popup already shows success from the action status.
|
||||
*/
|
||||
private fun onActionFinished(status: ActionStatusResponse?) {
|
||||
if (status?.name == "hermes-update") {
|
||||
viewModelScope.launch {
|
||||
val receipt = fetchUpdateReceiptLines()
|
||||
if (receipt.isNotEmpty()) actionProgress.pushTrailingLines(receipt)
|
||||
}
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
|
||||
* for the popup. Returns an empty list when the endpoint is unavailable
|
||||
* (old backend, no receipt yet, or any error) so the caller appends nothing.
|
||||
*/
|
||||
private suspend fun fetchUpdateReceiptLines(): List<String> {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getUpdateReceipt() }
|
||||
}
|
||||
val data = (result as? NetworkResult.Success)?.data ?: return emptyList()
|
||||
return formatUpdateReceiptLines(data)
|
||||
}
|
||||
|
||||
// ── Gateway actions ────────────────────────────────────────────────
|
||||
|
||||
fun startGateway() {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.m57.hermescontrol.ui.system
|
||||
|
||||
import com.m57.hermescontrol.data.model.UpdateReceipt
|
||||
import com.m57.hermescontrol.data.model.UpdateReceiptResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateReceiptSummary
|
||||
|
||||
/**
|
||||
* Render a compact, popup-friendly summary of an update receipt (issue #958).
|
||||
*
|
||||
* Returns an empty list when there is nothing to show (no receipt, old
|
||||
* backend, or any malformed payload) so callers can skip appending entirely.
|
||||
*
|
||||
* Pure + [internal] so it is unit-testable without mocking the API layer.
|
||||
*/
|
||||
internal fun formatUpdateReceiptLines(receipt: UpdateReceiptResponse?): List<String> {
|
||||
if (receipt == null) return emptyList()
|
||||
val summary: UpdateReceiptSummary? = receipt.summary
|
||||
val full: UpdateReceipt? = receipt.receipt
|
||||
if (summary == null && full == null) return emptyList()
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
lines.add("Update receipt")
|
||||
|
||||
val outcome = summary?.outcome ?: full?.outcome
|
||||
if (outcome != null) lines.add("Outcome: $outcome")
|
||||
|
||||
val pre = full?.preUpdate
|
||||
val post = full?.postUpdate
|
||||
if (pre != null || post != null) {
|
||||
val from = pre?.version ?: pre?.sha?.take(8) ?: "?"
|
||||
val to = post?.version ?: post?.sha?.take(8) ?: "?"
|
||||
lines.add("Version: $from → $to")
|
||||
} else if (summary?.postVersion != null) {
|
||||
lines.add("Version: → ${summary.postVersion}")
|
||||
}
|
||||
|
||||
val fleet = full?.fleet
|
||||
if (!fleet.isNullOrEmpty()) {
|
||||
val states = fleet.map { "${it.profile ?: "?"}: ${it.state ?: "?"}" }
|
||||
lines.add("Fleet: ${states.joinToString(", ")}")
|
||||
} else if (summary != null && !summary.fleetStates.isNullOrEmpty()) {
|
||||
lines.add("Fleet: ${summary.fleetStates.joinToString(", ")}")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
Reference in new issue
Block a user