feat(#1216): add cron run history (#1226)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 14:57:42 +03:00
1 parent a78b20bc99
commit 2e6ae95fe0
8 files changed
+517 -2

No files matched your search

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -18,6 +19,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
@@ -32,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -49,6 +52,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -59,6 +63,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.CronRun
import com.m57.hermescontrol.data.model.DeliveryTarget
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
@@ -75,6 +80,10 @@ import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import com.m57.hermescontrol.util.CronExpressionFormatter
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun CronJobsScreen(
@@ -224,6 +233,15 @@ fun CronJobsScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = { viewModel.openRunHistory(job) },
) {
Icon(
imageVector = Icons.Filled.History,
contentDescription = stringResource(R.string.cron_action_history),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = { viewModel.openEditJobDialog(job.id) },
) {
@@ -341,6 +359,14 @@ fun CronJobsScreen(
)
}
if (state.runHistoryState.isOpen) {
CronRunHistoryDialog(
state = state.runHistoryState,
onRetry = viewModel::retryRunHistory,
onDismiss = viewModel::closeRunHistory,
)
}
// ── Delete Confirm Dialog ──
state.deleteTarget?.let { job ->
AlertDialog(
@@ -361,6 +387,139 @@ fun CronJobsScreen(
}
}
@Composable
private fun CronRunHistoryDialog(
state: CronRunHistoryState,
onRetry: () -> Unit,
onDismiss: () -> Unit,
) {
val spacing = LocalSpacing.current
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.cron_history_title, state.jobName),
style = MaterialTheme.typography.titleLarge,
)
},
text = {
when {
state.isLoading -> {
LoadingState(
modifier = Modifier.height(240.dp),
subtitle = stringResource(R.string.cron_history_loading),
)
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage,
onRetry = onRetry,
modifier = Modifier.height(240.dp),
)
}
state.runs.isEmpty() -> {
EmptyState(
icon = Icons.Filled.History,
title = stringResource(R.string.cron_history_empty_title),
subtitle = stringResource(R.string.cron_history_empty_desc),
modifier = Modifier.height(240.dp),
)
}
else -> {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(state.runs, key = { it.id }) { run ->
CronRunRow(run)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cron_history_close))
}
},
)
}
@Composable
private fun CronRunRow(run: CronRun) {
val spacing = LocalSpacing.current
val (statusText, statusType) =
when {
run.is_active -> stringResource(R.string.cron_history_status_active) to StatusBadgeType.SUCCESS
run.archived -> stringResource(R.string.cron_history_status_archived) to StatusBadgeType.NEUTRAL
run.ended_at != null -> stringResource(R.string.cron_history_status_completed) to StatusBadgeType.INFO
else -> stringResource(R.string.cron_history_status_inactive) to StatusBadgeType.NEUTRAL
}
val startedAt = formatCronRunTime(run.started_at)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = spacing.xs),
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = startedAt ?: run.id,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
StatusBadge(text = statusText, status = statusType)
}
if (!run.profile.isNullOrBlank()) {
Text(
text = stringResource(R.string.cron_history_profile, run.profile),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (run.message_count != null) {
Text(
text = stringResource(R.string.cron_history_messages, run.message_count),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!run.preview.isNullOrBlank()) {
Text(
text = run.preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun formatCronRunTime(epochSeconds: Double?): String? {
if (epochSeconds == null) return null
return runCatching {
Instant
.ofEpochMilli((epochSeconds * 1000).toLong())
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
}.getOrNull()
}
@Composable
fun CronJobEditorDialog(
state: CronJobEditorState,
@@ -3,22 +3,22 @@ package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.CronRun
import com.m57.hermescontrol.data.model.DeliveryTarget
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@@ -32,6 +32,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private const val CRON_RUN_HISTORY_LIMIT = 20
data class CronJobsUiState(
val isLoading: Boolean = false,
val jobs: List<CronJob> = emptyList(),
@@ -41,6 +43,16 @@ data class CronJobsUiState(
val deleteTarget: CronJob? = null,
// Editor state
val editorState: CronJobEditorState = CronJobEditorState(),
val runHistoryState: CronRunHistoryState = CronRunHistoryState(),
)
data class CronRunHistoryState(
val isOpen: Boolean = false,
val jobId: String? = null,
val jobName: String = "",
val isLoading: Boolean = false,
val runs: List<CronRun> = emptyList(),
val errorMessage: String? = null,
)
data class CronJobEditorState(
@@ -100,10 +112,13 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private var runHistoryJob: Job? = null
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val jobsCache = SwrCache<String, List<CronJob>>()
fun clearScopeOwnedState() {
runHistoryJob?.cancel()
runHistoryJob = null
_uiState.update {
it.copy(
isLoading = false,
@@ -111,6 +126,7 @@ class CronJobsViewModel :
errorMessage = null,
deleteTarget = null,
editorState = CronJobEditorState(),
runHistoryState = CronRunHistoryState(),
)
}
}
@@ -155,6 +171,100 @@ class CronJobsViewModel :
)
}
fun openRunHistory(job: CronJob) {
runHistoryJob?.cancel()
_uiState.update {
it.copy(
runHistoryState =
CronRunHistoryState(
isOpen = true,
jobId = job.id,
jobName = job.name,
isLoading = true,
),
)
}
loadRunHistory(job.id)
}
fun retryRunHistory() {
val jobId = _uiState.value.runHistoryState.jobId ?: return
_uiState.update {
it.copy(
runHistoryState =
it.runHistoryState.copy(
isLoading = true,
errorMessage = null,
),
)
}
loadRunHistory(jobId)
}
fun closeRunHistory() {
runHistoryJob?.cancel()
runHistoryJob = null
_uiState.update { it.copy(runHistoryState = CronRunHistoryState()) }
}
private fun loadRunHistory(jobId: String) {
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
runHistoryJob =
viewModelScope.launch {
val result =
withContext(ioDispatcher) {
safeApiCall {
ApiClient.hermesApi.getCronJobRuns(
id = jobId,
limit = CRON_RUN_HISTORY_LIMIT,
)
}
}
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
val history = _uiState.value.runHistoryState
if (!history.isOpen ||
history.jobId != jobId ||
(requestScope != null && currentScope != requestScope)
) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
runHistoryState =
it.runHistoryState.copy(
isLoading = false,
runs = result.data.runs,
errorMessage = null,
),
)
}
}
is NetworkResult.Failure -> {
val message =
if (result.error is NetworkError.Http && result.error.code == 404) {
"Run history is unavailable on this Hermes Agent version."
} else {
"Failed to load run history: ${result.error.message}"
}
_uiState.update {
it.copy(
runHistoryState =
it.runHistoryState.copy(
isLoading = false,
runs = emptyList(),
errorMessage = message,
),
)
}
}
}
}
}
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()