feat: cron job editor dialog (closes #150)
Implements a full-screen cron job editor dialog following the skills editor pattern: - Create/edit/delete cron jobs with all backend fields - CronJobRepeat data class matching backend object shape - Fixed Gson crash on cron tab load
This commit is contained in:
6 files changed
+608
-10
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
data class CronJobRepeat(
|
||||||
|
val times: Int? = null,
|
||||||
|
val completed: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
data class CronJob(
|
data class CronJob(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -10,6 +15,20 @@ data class CronJob(
|
|||||||
val schedule_display: String? = null,
|
val schedule_display: String? = null,
|
||||||
val last_status: String? = null,
|
val last_status: String? = null,
|
||||||
val next_run_at: String? = null,
|
val next_run_at: String? = null,
|
||||||
|
// Full editor fields — all optional with defaults for backward compat
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val prompt: String? = null,
|
||||||
|
val deliver: String? = null,
|
||||||
|
val skills: List<String>? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
val provider: String? = null,
|
||||||
|
val base_url: String? = null,
|
||||||
|
val script: String? = null,
|
||||||
|
val context_from: List<String>? = null,
|
||||||
|
val enabled_toolsets: List<String>? = null,
|
||||||
|
val workdir: String? = null,
|
||||||
|
val no_agent: Boolean? = null,
|
||||||
|
val repeat: CronJobRepeat? = null,
|
||||||
) {
|
) {
|
||||||
val scheduleText: String
|
val scheduleText: String
|
||||||
get() =
|
get() =
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
data class CreateCronJobRequest(
|
||||||
|
val name: String = "",
|
||||||
|
val schedule: String,
|
||||||
|
val prompt: String = "",
|
||||||
|
val deliver: String = "local",
|
||||||
|
val skills: List<String>? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
val provider: String? = null,
|
||||||
|
val base_url: String? = null,
|
||||||
|
val script: String? = null,
|
||||||
|
val context_from: List<String>? = null,
|
||||||
|
val enabled_toolsets: List<String>? = null,
|
||||||
|
val workdir: String? = null,
|
||||||
|
val no_agent: Boolean = false,
|
||||||
|
val repeat: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateCronJobRequest(
|
||||||
|
val updates: Map<String, Any?>,
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
|
|||||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
import com.m57.hermescontrol.data.model.DoctorResponse
|
||||||
@@ -38,6 +39,7 @@ import com.m57.hermescontrol.data.model.SystemStatsResponse
|
|||||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||||
import com.m57.hermescontrol.data.model.Toolset
|
import com.m57.hermescontrol.data.model.Toolset
|
||||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||||
@@ -103,6 +105,22 @@ interface HermesApiService {
|
|||||||
@Path("id") id: String,
|
@Path("id") id: String,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/cron/jobs/{id}")
|
||||||
|
suspend fun getCronJob(
|
||||||
|
@Path("id") id: String,
|
||||||
|
): Response<CronJob>
|
||||||
|
|
||||||
|
@POST("api/cron/jobs")
|
||||||
|
suspend fun createCronJob(
|
||||||
|
@Body body: CreateCronJobRequest,
|
||||||
|
): Response<CronJob>
|
||||||
|
|
||||||
|
@PUT("api/cron/jobs/{id}")
|
||||||
|
suspend fun updateCronJob(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Body body: UpdateCronJobRequest,
|
||||||
|
): Response<CronJob>
|
||||||
|
|
||||||
@POST("api/cron/jobs/{id}/trigger")
|
@POST("api/cron/jobs/{id}/trigger")
|
||||||
suspend fun triggerCronJob(
|
suspend fun triggerCronJob(
|
||||||
@Path("id") id: String,
|
@Path("id") id: String,
|
||||||
|
|||||||
@@ -4,29 +4,48 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -63,6 +82,14 @@ fun CronJobsScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadCronJobs() },
|
onRefresh = { viewModel.loadCronJobs() },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.openNewJobDialog() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = stringResource(R.string.cron_action_add),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.isLoading && state.jobs.isEmpty() -> {
|
state.isLoading && state.jobs.isEmpty() -> {
|
||||||
@@ -102,7 +129,7 @@ fun CronJobsScreen(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = job.name,
|
text = job.name,
|
||||||
@@ -113,9 +140,7 @@ fun CronJobsScreen(
|
|||||||
StatusBadge(
|
StatusBadge(
|
||||||
text =
|
text =
|
||||||
if (job.state == "active") {
|
if (job.state == "active") {
|
||||||
stringResource(
|
stringResource(R.string.cron_status_active)
|
||||||
R.string.cron_status_active,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.cron_status_paused)
|
stringResource(R.string.cron_status_paused)
|
||||||
},
|
},
|
||||||
@@ -144,6 +169,15 @@ fun CronJobsScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
) {
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.openEditJobDialog(job.id) },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Edit,
|
||||||
|
contentDescription = stringResource(R.string.cron_action_edit),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (job.state == "active") {
|
if (job.state == "active") {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.pauseCronJob(job.id) },
|
onClick = { viewModel.pauseCronJob(job.id) },
|
||||||
@@ -191,4 +225,261 @@ fun CronJobsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Editor Dialog ──
|
||||||
|
if (state.editorState.isOpen) {
|
||||||
|
CronJobEditorDialog(
|
||||||
|
state = state.editorState,
|
||||||
|
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
|
||||||
|
onToggleNoAgent = { viewModel.toggleNoAgent() },
|
||||||
|
onSave = { viewModel.saveEditor() },
|
||||||
|
onDismiss = { viewModel.closeEditor() },
|
||||||
|
onClearToast = { viewModel.clearEditorToast() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CronJobEditorDialog(
|
||||||
|
state: CronJobEditorState,
|
||||||
|
onFieldChange: (String, String) -> Unit,
|
||||||
|
onToggleNoAgent: () -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onClearToast: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showDiscardConfirm by remember { mutableStateOf(false) }
|
||||||
|
val hasChanges =
|
||||||
|
state.name.isNotEmpty() || state.schedule.isNotEmpty() ||
|
||||||
|
state.prompt.isNotEmpty() || state.skills.isNotEmpty()
|
||||||
|
|
||||||
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
if (hasChanges && !state.isNew) {
|
||||||
|
showDiscardConfirm = true
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
HermesScaffold(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
if (state.isNew) {
|
||||||
|
stringResource(R.string.cron_edit_title_new)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.cron_edit_title)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon =
|
||||||
|
NavIcon.Back(
|
||||||
|
onBack = {
|
||||||
|
if (hasChanges && !state.isNew) {
|
||||||
|
showDiscardConfirm = true
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions = {
|
||||||
|
if (!state.isLoading) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onSave,
|
||||||
|
enabled = !state.isSaving,
|
||||||
|
) {
|
||||||
|
if (state.isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxSize(0.6f),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_edit_action_save),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (state.isLoading) {
|
||||||
|
LoadingState()
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Name
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = { onFieldChange("name", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_name)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schedule (required)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.schedule,
|
||||||
|
onValueChange = { onFieldChange("schedule", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prompt
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.prompt,
|
||||||
|
onValueChange = { onFieldChange("prompt", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp),
|
||||||
|
textStyle =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.deliver,
|
||||||
|
onValueChange = { onFieldChange("deliver", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_deliver)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.cron_edit_hint_deliver),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.skills,
|
||||||
|
onValueChange = { onFieldChange("skills", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.model,
|
||||||
|
onValueChange = { onFieldChange("model", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_model)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.provider,
|
||||||
|
onValueChange = { onFieldChange("provider", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_provider)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.base_url,
|
||||||
|
onValueChange = { onFieldChange("base_url", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_base_url)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Script path
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.script,
|
||||||
|
onValueChange = { onFieldChange("script", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_script)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Work directory
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.workdir,
|
||||||
|
onValueChange = { onFieldChange("workdir", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_workdir)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// No Agent toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.no_agent,
|
||||||
|
onCheckedChange = { onToggleNoAgent() },
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_edit_field_no_agent),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDiscardConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDiscardConfirm = false },
|
||||||
|
title = { Text(stringResource(R.string.cron_discard_title)) },
|
||||||
|
text = { Text(stringResource(R.string.cron_discard_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDiscardConfirm = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_discard))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDiscardConfirm = false }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.cron
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -22,6 +24,30 @@ data class CronJobsUiState(
|
|||||||
val jobs: List<CronJob> = emptyList(),
|
val jobs: List<CronJob> = emptyList(),
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
|
// Editor state
|
||||||
|
val editorState: CronJobEditorState = CronJobEditorState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CronJobEditorState(
|
||||||
|
val isOpen: Boolean = false,
|
||||||
|
val isNew: Boolean = false,
|
||||||
|
val jobId: String? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val toastMessage: String? = null,
|
||||||
|
// Form fields
|
||||||
|
val name: String = "",
|
||||||
|
val schedule: String = "",
|
||||||
|
val prompt: String = "",
|
||||||
|
val deliver: String = "local",
|
||||||
|
val skills: String = "",
|
||||||
|
val model: String = "",
|
||||||
|
val provider: String = "",
|
||||||
|
val base_url: String = "",
|
||||||
|
val script: String = "",
|
||||||
|
val workdir: String = "",
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val no_agent: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class CronJobsViewModel :
|
class CronJobsViewModel :
|
||||||
@@ -54,7 +80,6 @@ class CronJobsViewModel :
|
|||||||
|
|
||||||
fun pauseCronJob(id: String) {
|
fun pauseCronJob(id: String) {
|
||||||
val originalJobs = _uiState.value.jobs
|
val originalJobs = _uiState.value.jobs
|
||||||
// Optimistic update
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
jobs =
|
jobs =
|
||||||
@@ -76,7 +101,6 @@ class CronJobsViewModel :
|
|||||||
|
|
||||||
fun resumeCronJob(id: String) {
|
fun resumeCronJob(id: String) {
|
||||||
val originalJobs = _uiState.value.jobs
|
val originalJobs = _uiState.value.jobs
|
||||||
// Optimistic update
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
jobs =
|
jobs =
|
||||||
@@ -106,7 +130,6 @@ class CronJobsViewModel :
|
|||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
|
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
|
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
|
||||||
}
|
}
|
||||||
@@ -116,7 +139,6 @@ class CronJobsViewModel :
|
|||||||
|
|
||||||
fun deleteCronJob(id: String) {
|
fun deleteCronJob(id: String) {
|
||||||
val originalJobs = _uiState.value.jobs
|
val originalJobs = _uiState.value.jobs
|
||||||
// Optimistic update
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(jobs = state.jobs.filter { it.id != id })
|
state.copy(jobs = state.jobs.filter { it.id != id })
|
||||||
}
|
}
|
||||||
@@ -131,6 +153,176 @@ class CronJobsViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Editor ──
|
||||||
|
|
||||||
|
fun openNewJobDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
CronJobEditorState(
|
||||||
|
isOpen = true,
|
||||||
|
isNew = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openEditJobDialog(id: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
CronJobEditorState(
|
||||||
|
isOpen = true,
|
||||||
|
isNew = false,
|
||||||
|
jobId = id,
|
||||||
|
isLoading = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
safeApiCall { ApiClient.hermesApi.getCronJob(id) }
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val job = result.data ?: return@launch
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
CronJobEditorState(
|
||||||
|
isOpen = true,
|
||||||
|
isNew = false,
|
||||||
|
jobId = id,
|
||||||
|
name = job.name,
|
||||||
|
schedule = extractScheduleString(job),
|
||||||
|
prompt = job.prompt.orEmpty(),
|
||||||
|
deliver = job.deliver ?: "local",
|
||||||
|
skills = (job.skills ?: emptyList()).joinToString("\n"),
|
||||||
|
model = job.model.orEmpty(),
|
||||||
|
provider = job.provider.orEmpty(),
|
||||||
|
base_url = job.base_url.orEmpty(),
|
||||||
|
script = job.script.orEmpty(),
|
||||||
|
workdir = job.workdir.orEmpty(),
|
||||||
|
enabled = job.enabled ?: true,
|
||||||
|
no_agent = job.no_agent ?: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
CronJobEditorState(
|
||||||
|
isOpen = true,
|
||||||
|
isNew = false,
|
||||||
|
jobId = id,
|
||||||
|
toastMessage = "Failed to load job: ${result.error.message}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeEditor() {
|
||||||
|
_uiState.update { it.copy(editorState = CronJobEditorState()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEditorField(
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
editorState =
|
||||||
|
state.editorState.applyFieldChange(name, value).copy(
|
||||||
|
toastMessage = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEditor() {
|
||||||
|
val editor = _uiState.value.editorState
|
||||||
|
if (editor.schedule.isBlank()) {
|
||||||
|
_uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(editorState = editor.copy(isSaving = true)) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (editor.isNew) {
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.createCronJob(
|
||||||
|
CreateCronJobRequest(
|
||||||
|
name = editor.name,
|
||||||
|
schedule = editor.schedule,
|
||||||
|
prompt = editor.prompt,
|
||||||
|
deliver = editor.deliver,
|
||||||
|
skills = parseLines(editor.skills),
|
||||||
|
model = editor.model.ifBlank { null },
|
||||||
|
provider = editor.provider.ifBlank { null },
|
||||||
|
base_url = editor.base_url.ifBlank { null },
|
||||||
|
script = editor.script.ifBlank { null },
|
||||||
|
workdir = editor.workdir.ifBlank { null },
|
||||||
|
no_agent = editor.no_agent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val updates = mutableMapOf<String, Any?>()
|
||||||
|
if (editor.name.isNotEmpty()) updates["name"] = editor.name
|
||||||
|
updates["schedule"] = editor.schedule
|
||||||
|
updates["prompt"] = editor.prompt
|
||||||
|
updates["deliver"] = editor.deliver
|
||||||
|
updates["skills"] = parseLines(editor.skills)
|
||||||
|
updates["model"] = editor.model.ifBlank { null }
|
||||||
|
updates["provider"] = editor.provider.ifBlank { null }
|
||||||
|
updates["base_url"] = editor.base_url.ifBlank { null }
|
||||||
|
updates["script"] = editor.script.ifBlank { null }
|
||||||
|
updates["workdir"] = editor.workdir.ifBlank { null }
|
||||||
|
updates["no_agent"] = editor.no_agent
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.updateCronJob(
|
||||||
|
editor.jobId ?: "",
|
||||||
|
UpdateCronJobRequest(updates = updates),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
closeEditor()
|
||||||
|
loadCronJobs()
|
||||||
|
}
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
it.editorState.copy(
|
||||||
|
isSaving = false,
|
||||||
|
toastMessage = "Failed to save: ${result.error.message}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearEditorToast() {
|
||||||
|
_uiState.update { it.copy(editorState = it.editorState.copy(toastMessage = null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToast() {
|
||||||
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun revertJobs(
|
private fun revertJobs(
|
||||||
originalJobs: List<CronJob>,
|
originalJobs: List<CronJob>,
|
||||||
errorMsg: String,
|
errorMsg: String,
|
||||||
@@ -138,7 +330,41 @@ class CronJobsViewModel :
|
|||||||
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
|
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearToast() {
|
private fun extractScheduleString(job: CronJob): String {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
val s = job.schedule
|
||||||
|
return when (s) {
|
||||||
|
is String -> s
|
||||||
|
is Map<*, *> -> (s["value"] as? String) ?: job.scheduleText
|
||||||
|
else -> job.scheduleText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLines(skills: String): List<String> =
|
||||||
|
skills.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun CronJobEditorState.applyFieldChange(
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
): CronJobEditorState =
|
||||||
|
when (name) {
|
||||||
|
"name" -> copy(name = value)
|
||||||
|
"schedule" -> copy(schedule = value)
|
||||||
|
"prompt" -> copy(prompt = value)
|
||||||
|
"deliver" -> copy(deliver = value)
|
||||||
|
"skills" -> copy(skills = value)
|
||||||
|
"model" -> copy(model = value)
|
||||||
|
"provider" -> copy(provider = value)
|
||||||
|
"base_url" -> copy(base_url = value)
|
||||||
|
"script" -> copy(script = value)
|
||||||
|
"workdir" -> copy(workdir = value)
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleNoAgent() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(editorState = it.editorState.copy(no_agent = !it.editorState.no_agent))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +364,28 @@
|
|||||||
<string name="cron_action_pause">Pause</string>
|
<string name="cron_action_pause">Pause</string>
|
||||||
<string name="cron_action_resume">Resume</string>
|
<string name="cron_action_resume">Resume</string>
|
||||||
<string name="cron_action_run">Run</string>
|
<string name="cron_action_run">Run</string>
|
||||||
|
<string name="cron_action_edit">Edit</string>
|
||||||
|
<string name="cron_action_add">Add cron job</string>
|
||||||
|
<string name="cron_edit_title">Edit Cron Job</string>
|
||||||
|
<string name="cron_edit_title_new">New Cron Job</string>
|
||||||
|
<string name="cron_edit_field_name">Name</string>
|
||||||
|
<string name="cron_edit_field_schedule">Schedule</string>
|
||||||
|
<string name="cron_edit_field_prompt">Prompt</string>
|
||||||
|
<string name="cron_edit_field_deliver">Delivery</string>
|
||||||
|
<string name="cron_edit_field_skills">Skills</string>
|
||||||
|
<string name="cron_edit_field_model">Model</string>
|
||||||
|
<string name="cron_edit_field_provider">Provider</string>
|
||||||
|
<string name="cron_edit_field_base_url">Base URL</string>
|
||||||
|
<string name="cron_edit_field_script">Script</string>
|
||||||
|
<string name="cron_edit_field_workdir">Work Directory</string>
|
||||||
|
<string name="cron_edit_field_no_agent">No agent (script-only)</string>
|
||||||
|
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
|
||||||
|
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
|
||||||
|
<string name="cron_edit_hint_skills">One per line</string>
|
||||||
|
<string name="cron_edit_action_save">Save</string>
|
||||||
|
<string name="cron_edit_error_schedule_required">Schedule is required</string>
|
||||||
|
<string name="cron_discard_title">Discard changes?</string>
|
||||||
|
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
||||||
|
|
||||||
<!-- Achievements Screen -->
|
<!-- Achievements Screen -->
|
||||||
<string name="achievements_empty_title">No achievements yet</string>
|
<string name="achievements_empty_title">No achievements yet</string>
|
||||||
|
|||||||
Reference in new issue
Block a user