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
@@ -0,0 +1,81 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Durable record of the most recent `hermes update` run, returned by
|
||||
* `GET /api/hermes/update/receipt` (hermes-agent #91277). This is the
|
||||
* authoritative outcome — it survives gateway restarts and log rotation,
|
||||
* unlike heuristic connection-retry polling.
|
||||
*
|
||||
* All fields are nullable: the backend may emit a partial receipt, and the
|
||||
* response is decoded with `ignoreUnknownKeys = true`, so future additions
|
||||
* won't break parsing.
|
||||
*
|
||||
* The same data is also attached (flattened) to
|
||||
* `GET /api/actions/hermes-update/status` under a `receipt` key.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateReceiptResponse(
|
||||
val receipt: UpdateReceipt? = null,
|
||||
val summary: UpdateReceiptSummary? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateReceipt(
|
||||
val schema: Int? = null,
|
||||
val startedAt: String? = null,
|
||||
val finishedAt: String? = null,
|
||||
val argv: List<String>? = null,
|
||||
val pid: Int? = null,
|
||||
/** `success` | `partial` | `running`. `running` means the update is still mid-flight. */
|
||||
val outcome: String? = null,
|
||||
val preUpdate: UpdateReceiptVersion? = null,
|
||||
val postUpdate: UpdateReceiptVersion? = null,
|
||||
val steps: List<UpdateReceiptStep>? = null,
|
||||
val skips: List<String>? = null,
|
||||
val gatewayRestart: UpdateReceiptGatewayRestart? = null,
|
||||
val fleet: List<UpdateReceiptFleetEntry>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateReceiptVersion(
|
||||
val sha: String? = null,
|
||||
val version: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateReceiptStep(
|
||||
val name: String? = null,
|
||||
val ok: Boolean? = null,
|
||||
val detail: String? = null,
|
||||
val at: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateReceiptGatewayRestart(
|
||||
val at: String? = null,
|
||||
val ok: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateReceiptFleetEntry(
|
||||
val profile: String? = null,
|
||||
val pid: Int? = null,
|
||||
val codeSha: String? = null,
|
||||
val state: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Flattened highlights of [UpdateReceipt], returned alongside it by the
|
||||
* endpoint. Mirrors the backend's `summary` object so the popup can show the
|
||||
* essentials without walking the full receipt.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateReceiptSummary(
|
||||
val outcome: String? = null,
|
||||
val preSha: String? = null,
|
||||
val postSha: String? = null,
|
||||
val postVersion: String? = null,
|
||||
val fleetStates: List<String>? = null,
|
||||
)
|
||||
@@ -128,6 +128,7 @@ import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateReceiptResponse
|
||||
import com.m57.hermescontrol.data.model.WebhookSubscription
|
||||
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||
@@ -800,6 +801,12 @@ interface HermesApiService {
|
||||
@POST("api/hermes/update")
|
||||
suspend fun updateHermes(): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Hermes update receipt (issue #958) ──
|
||||
// Returns the durable record of the last `hermes update` run. 404 when no
|
||||
// receipt exists yet (handled as "no info to show", not an error).
|
||||
@GET("api/hermes/update/receipt")
|
||||
suspend fun getUpdateReceipt(): Response<UpdateReceiptResponse>
|
||||
|
||||
// ── Admin: Portal ─────────────────────────────────────────────────
|
||||
@GET("api/portal")
|
||||
suspend fun getPortal(): Response<PortalResponse>
|
||||
|
||||
@@ -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