- New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate - Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms) - Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
80be168801
commit
442e868ce2
6 files changed
+740
-118
No files matched your search
@@ -0,0 +1,58 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
@Serializable
|
||||
data class CronBlueprintListResponse(
|
||||
val blueprints: List<CronBlueprint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CronBlueprint(
|
||||
val key: String,
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val category: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
val fields: List<CronBlueprintField> = emptyList(),
|
||||
val schedule: String = "",
|
||||
val scheduleHuman: String = "",
|
||||
val command: String = "",
|
||||
val appUrl: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CronBlueprintField(
|
||||
val name: String,
|
||||
val type: String = "text",
|
||||
val label: String = "",
|
||||
val default: JsonElement? = null,
|
||||
val options: List<String> = emptyList(),
|
||||
val optional: Boolean = false,
|
||||
val strict: Boolean = true,
|
||||
val help: String = "",
|
||||
) {
|
||||
val defaultText: String
|
||||
get() = (default as? JsonPrimitive)?.content.orEmpty()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DeliveryTargetsResponse(
|
||||
val targets: List<DeliveryTarget> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeliveryTarget(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
val home_target_set: Boolean = false,
|
||||
val home_env_var: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InstantiateBlueprintRequest(
|
||||
val blueprint: String,
|
||||
val values: Map<String, String>,
|
||||
)
|
||||
@@ -19,10 +19,12 @@ import com.m57.hermescontrol.data.model.CreateProfileRequest
|
||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||
import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
||||
import com.m57.hermescontrol.data.model.CredentialPoolResponse
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.CuratorResponse
|
||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
||||
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
||||
@@ -31,6 +33,7 @@ import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
||||
import com.m57.hermescontrol.data.model.HealthStatus
|
||||
import com.m57.hermescontrol.data.model.HookResponse
|
||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||
@@ -308,6 +311,17 @@ interface HermesApiService {
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/cron/blueprints")
|
||||
suspend fun getCronBlueprints(): Response<CronBlueprintListResponse>
|
||||
|
||||
@POST("api/cron/blueprints/instantiate")
|
||||
suspend fun instantiateBlueprint(
|
||||
@Body body: InstantiateBlueprintRequest,
|
||||
): Response<CronJob>
|
||||
|
||||
@GET("api/cron/delivery-targets")
|
||||
suspend fun getCronDeliveryTargets(): Response<DeliveryTargetsResponse>
|
||||
|
||||
@POST("api/gateway/start")
|
||||
suspend fun startGateway(): Response<Unit>
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -50,10 +55,14 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
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.DeliveryTarget
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
@@ -241,6 +250,8 @@ fun CronJobsScreen(
|
||||
state = state.editorState,
|
||||
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
|
||||
onToggleNoAgent = { viewModel.toggleNoAgent() },
|
||||
onSelectBlueprint = { viewModel.selectBlueprint(it) },
|
||||
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
|
||||
onSave = { viewModel.saveEditor() },
|
||||
onDismiss = { viewModel.closeEditor() },
|
||||
onClearToast = { viewModel.clearEditorToast() },
|
||||
@@ -275,6 +286,8 @@ fun CronJobEditorDialog(
|
||||
state: CronJobEditorState,
|
||||
onFieldChange: (String, String) -> Unit,
|
||||
onToggleNoAgent: () -> Unit,
|
||||
onSelectBlueprint: (String?) -> Unit,
|
||||
onBlueprintFieldChange: (String, String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onClearToast: () -> Unit,
|
||||
@@ -360,127 +373,155 @@ fun CronJobEditorDialog(
|
||||
.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,
|
||||
)
|
||||
val blueprint = state.selectedBlueprint
|
||||
|
||||
// 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 = {
|
||||
// Start-from blueprint picker (new jobs only; hidden when the
|
||||
// catalog failed to load)
|
||||
if (state.isNew && state.blueprints.isNotEmpty()) {
|
||||
BlueprintStartFromDropdown(
|
||||
blueprints = state.blueprints,
|
||||
selectedKey = state.selectedBlueprintKey,
|
||||
onSelect = onSelectBlueprint,
|
||||
)
|
||||
blueprint?.let {
|
||||
Text(
|
||||
stringResource(R.string.cron_edit_hint_deliver),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
text = it.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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() },
|
||||
if (blueprint != null) {
|
||||
// Blueprint mode: typed slots only — the backend renders the
|
||||
// prompt and schedule from the filled values.
|
||||
blueprint.fields.forEach { field ->
|
||||
BlueprintSlotField(
|
||||
field = field,
|
||||
value = state.blueprintValues[field.name].orEmpty(),
|
||||
deliveryOptions = state.deliveryOptions,
|
||||
deliveryTargets = state.deliveryTargets,
|
||||
onValueChange = { onBlueprintFieldChange(field.name, it) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Name
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = { onFieldChange("name", it) },
|
||||
label = { Text(stringResource(R.string.cron_edit_field_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.cron_edit_field_no_agent),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
// 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
|
||||
DeliverField(
|
||||
label = stringResource(R.string.cron_edit_field_deliver),
|
||||
value = state.deliver,
|
||||
options = state.deliveryOptions,
|
||||
deliveryTargets = state.deliveryTargets,
|
||||
onValueChange = { onFieldChange("deliver", it) },
|
||||
hint = stringResource(R.string.cron_edit_hint_deliver),
|
||||
)
|
||||
|
||||
// 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))
|
||||
@@ -537,3 +578,175 @@ private fun RunDetailRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BlueprintStartFromDropdown(
|
||||
blueprints: List<CronBlueprint>,
|
||||
selectedKey: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedTitle =
|
||||
blueprints.firstOrNull { it.key == selectedKey }?.title
|
||||
?: stringResource(R.string.cron_edit_blank_job)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedTitle,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.cron_edit_start_from)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.cron_edit_blank_job)) },
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
blueprints.forEach { blueprint ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(blueprint.title) },
|
||||
onClick = {
|
||||
onSelect(blueprint.key)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlueprintSlotField(
|
||||
field: CronBlueprintField,
|
||||
value: String,
|
||||
deliveryOptions: List<String>,
|
||||
deliveryTargets: List<DeliveryTarget>,
|
||||
onValueChange: (String) -> Unit,
|
||||
) {
|
||||
when {
|
||||
field.name == "deliver" -> {
|
||||
DeliverField(
|
||||
label = field.label,
|
||||
value = value,
|
||||
options = deliveryOptions,
|
||||
deliveryTargets = deliveryTargets,
|
||||
onValueChange = onValueChange,
|
||||
)
|
||||
}
|
||||
|
||||
field.type == "enum" || field.type == "weekdays" -> {
|
||||
ExposedDropdownField(
|
||||
label = field.label,
|
||||
options = field.options,
|
||||
selectedValue = value,
|
||||
onOptionSelected = onValueChange,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(field.label) },
|
||||
placeholder =
|
||||
if (field.type == "time") {
|
||||
{ Text(stringResource(R.string.cron_edit_hint_time)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
supportingText =
|
||||
if (field.help.isNotBlank()) {
|
||||
{
|
||||
Text(
|
||||
text = field.help,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery target picker: an editable dropdown fed by the backend's
|
||||
* delivery-targets endpoint (origin + local + connected platforms). Picking
|
||||
* fills the field; typing keeps the legacy free-text behaviour (e.g.
|
||||
* `telegram:chat_id`).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DeliverField(
|
||||
label: String,
|
||||
value: String,
|
||||
options: List<String>,
|
||||
deliveryTargets: List<DeliveryTarget>,
|
||||
onValueChange: (String) -> Unit,
|
||||
hint: String? = null,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val displayNames =
|
||||
deliveryTargets.associate { it.id to it.name.ifBlank { it.id } } + ("origin" to "origin")
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
supportingText =
|
||||
hint?.let {
|
||||
{
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { id ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(displayNames[id] ?: id) },
|
||||
onClick = {
|
||||
onValueChange(id)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package com.m57.hermescontrol.ui.cron
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.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.NetworkResult
|
||||
@@ -51,7 +54,26 @@ data class CronJobEditorState(
|
||||
val workdir: String = "",
|
||||
val enabled: Boolean = true,
|
||||
val no_agent: Boolean = false,
|
||||
)
|
||||
// Blueprint start-from state (new jobs only)
|
||||
val blueprints: List<CronBlueprint> = emptyList(),
|
||||
val deliveryTargets: List<DeliveryTarget> = emptyList(),
|
||||
val selectedBlueprintKey: String? = null,
|
||||
val blueprintValues: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
val selectedBlueprint: CronBlueprint?
|
||||
get() = blueprints.firstOrNull { it.key == selectedBlueprintKey }
|
||||
|
||||
/** Delivery ids offered by the picker: origin, then local + connected platforms. */
|
||||
val deliveryOptions: List<String>
|
||||
get() =
|
||||
buildList {
|
||||
add("origin")
|
||||
addAll(deliveryTargets.map { it.id })
|
||||
if (deliveryTargets.none { it.id == "local" }) {
|
||||
add("local")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CronJobsViewModel :
|
||||
ViewModel(),
|
||||
@@ -169,6 +191,50 @@ class CronJobsViewModel :
|
||||
),
|
||||
)
|
||||
}
|
||||
loadEditorBlueprints()
|
||||
loadDeliveryTargets()
|
||||
}
|
||||
|
||||
fun loadEditorBlueprints() {
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getCronBlueprints() }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(editorState = it.editorState.copy(blueprints = result.data.blueprints))
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
editorState =
|
||||
it.editorState.copy(
|
||||
toastMessage = "Failed to load blueprints: ${result.error.message}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDeliveryTargets() {
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() }
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
_uiState.update {
|
||||
it.copy(editorState = it.editorState.copy(deliveryTargets = result.data.targets))
|
||||
}
|
||||
}
|
||||
// Failure: editor keeps working; the deliver picker falls back to origin/local.
|
||||
}
|
||||
}
|
||||
|
||||
fun openEditJobDialog(id: String) {
|
||||
@@ -230,12 +296,45 @@ class CronJobsViewModel :
|
||||
}
|
||||
}
|
||||
}
|
||||
loadDeliveryTargets()
|
||||
}
|
||||
|
||||
fun closeEditor() {
|
||||
_uiState.update { it.copy(editorState = CronJobEditorState()) }
|
||||
}
|
||||
|
||||
/** Pick a start-from blueprint (null = blank job); seeds its slot defaults. */
|
||||
fun selectBlueprint(key: String?) {
|
||||
_uiState.update { state ->
|
||||
val editor = state.editorState
|
||||
val blueprint = key?.let { k -> editor.blueprints.firstOrNull { it.key == k } }
|
||||
val values = blueprint?.fields?.associate { it.name to it.defaultText }.orEmpty()
|
||||
state.copy(
|
||||
editorState =
|
||||
editor.copy(
|
||||
selectedBlueprintKey = key,
|
||||
blueprintValues = values,
|
||||
toastMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBlueprintValue(
|
||||
name: String,
|
||||
value: String,
|
||||
) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
editorState =
|
||||
state.editorState.copy(
|
||||
blueprintValues = state.editorState.blueprintValues + (name to value),
|
||||
toastMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEditorField(
|
||||
name: String,
|
||||
value: String,
|
||||
@@ -252,7 +351,7 @@ class CronJobsViewModel :
|
||||
|
||||
fun saveEditor() {
|
||||
val editor = _uiState.value.editorState
|
||||
if (editor.schedule.isBlank()) {
|
||||
if (editor.selectedBlueprintKey == null && editor.schedule.isBlank()) {
|
||||
_uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) }
|
||||
return
|
||||
}
|
||||
@@ -261,7 +360,16 @@ class CronJobsViewModel :
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (editor.isNew) {
|
||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.instantiateBlueprint(
|
||||
InstantiateBlueprintRequest(
|
||||
blueprint = editor.selectedBlueprintKey,
|
||||
values = editor.blueprintValues,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (editor.isNew) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.createCronJob(
|
||||
CreateCronJobRequest(
|
||||
|
||||
@@ -743,6 +743,8 @@
|
||||
<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_start_from">Start from</string>
|
||||
<string name="cron_edit_blank_job">Blank 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>
|
||||
@@ -756,6 +758,7 @@
|
||||
<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_time">HH:MM</string>
|
||||
<string name="cron_edit_hint_skills">One per line</string>
|
||||
<string name="cron_edit_action_save">Save</string>
|
||||
<string name="cron_discard_title">Discard changes?</string>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CronJobsViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
||||
|
||||
private val morningBrief =
|
||||
CronBlueprint(
|
||||
key = "morning-brief",
|
||||
title = "Morning briefing",
|
||||
description = "A short daily briefing.",
|
||||
fields =
|
||||
listOf(
|
||||
CronBlueprintField(
|
||||
name = "time",
|
||||
type = "time",
|
||||
label = "What time?",
|
||||
default = JsonPrimitive("08:00"),
|
||||
help = "24h local time, e.g. 08:00",
|
||||
),
|
||||
CronBlueprintField(
|
||||
name = "deliver",
|
||||
type = "enum",
|
||||
label = "Where to deliver?",
|
||||
default = JsonPrimitive("origin"),
|
||||
options = listOf("origin", "local", "telegram"),
|
||||
strict = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createViewModel(): CronJobsViewModel {
|
||||
val vm = CronJobsViewModel()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
return vm
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump the test scheduler while letting the real Dispatchers.IO hops
|
||||
* (safeLaunchLoad / withContext(IO)) land their resumptions.
|
||||
*/
|
||||
private fun settle() {
|
||||
repeat(20) {
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
Thread.sleep(10)
|
||||
}
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
}
|
||||
|
||||
private fun stubEditorData() {
|
||||
coEvery { mockApi.getCronBlueprints() } returns
|
||||
Response.success(CronBlueprintListResponse(blueprints = listOf(morningBrief)))
|
||||
coEvery { mockApi.getCronDeliveryTargets() } returns
|
||||
Response.success(
|
||||
DeliveryTargetsResponse(
|
||||
targets =
|
||||
listOf(
|
||||
DeliveryTarget(id = "local", name = "Local (save only)", home_target_set = true),
|
||||
DeliveryTarget(id = "telegram", name = "Telegram", home_target_set = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockkObject(ApiClient)
|
||||
every { ApiClient.hermesApi } returns mockApi
|
||||
stubEditorData()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openNewJobDialog loads blueprints and delivery targets`() {
|
||||
val vm = createViewModel()
|
||||
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
|
||||
val editor = vm.uiState.value.editorState
|
||||
assertTrue(editor.isOpen)
|
||||
assertTrue(editor.isNew)
|
||||
assertEquals(listOf("morning-brief"), editor.blueprints.map { it.key })
|
||||
assertEquals(listOf("local", "telegram"), editor.deliveryTargets.map { it.id })
|
||||
// origin first, then local + connected platforms
|
||||
assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectBlueprint seeds slot defaults`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
|
||||
vm.selectBlueprint("morning-brief")
|
||||
|
||||
val editor = vm.uiState.value.editorState
|
||||
assertEquals("morning-brief", editor.selectedBlueprintKey)
|
||||
assertEquals(mapOf("time" to "08:00", "deliver" to "origin"), editor.blueprintValues)
|
||||
assertEquals("Morning briefing", editor.selectedBlueprint?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectBlueprint null returns to blank job`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
|
||||
vm.selectBlueprint("morning-brief")
|
||||
vm.selectBlueprint(null)
|
||||
|
||||
val editor = vm.uiState.value.editorState
|
||||
assertNull(editor.selectedBlueprintKey)
|
||||
assertTrue(editor.blueprintValues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateBlueprintValue overrides a seeded slot`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
vm.selectBlueprint("morning-brief")
|
||||
|
||||
vm.updateBlueprintValue("time", "09:30")
|
||||
|
||||
assertEquals("09:30", vm.uiState.value.editorState.blueprintValues["time"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveEditor with blueprint instantiated`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
vm.selectBlueprint("morning-brief")
|
||||
coEvery { mockApi.instantiateBlueprint(any()) } returns
|
||||
Response.success(CronJob(id = "j1", name = "Morning briefing"))
|
||||
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||
|
||||
vm.saveEditor()
|
||||
settle()
|
||||
|
||||
val requestSlot = slot<InstantiateBlueprintRequest>()
|
||||
coVerify { mockApi.instantiateBlueprint(capture(requestSlot)) }
|
||||
assertEquals("morning-brief", requestSlot.captured.blueprint)
|
||||
assertEquals(mapOf("time" to "08:00", "deliver" to "origin"), requestSlot.captured.values)
|
||||
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveEditor blueprint validation error surfaces backend detail`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
vm.selectBlueprint("morning-brief")
|
||||
coEvery { mockApi.instantiateBlueprint(any()) } returns
|
||||
Response.error(422, """{"detail":"invalid time '25:00' - use HH:MM (24h)"}""".toResponseBody())
|
||||
|
||||
vm.saveEditor()
|
||||
settle()
|
||||
|
||||
val toast = vm.uiState.value.editorState.toastMessage.orEmpty()
|
||||
assertTrue(toast, toast.contains("invalid time '25:00'"))
|
||||
assertFalse(vm.uiState.value.editorState.isSaving)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveEditor blank job still creates via createCronJob`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
vm.updateEditorField("name", "Test job")
|
||||
vm.updateEditorField("schedule", "0 9 * * *")
|
||||
vm.updateEditorField("prompt", "Say hi")
|
||||
coEvery { mockApi.createCronJob(any()) } returns
|
||||
Response.success(CronJob(id = "j2", name = "Test job"))
|
||||
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||
|
||||
vm.saveEditor()
|
||||
settle()
|
||||
|
||||
coVerify { mockApi.createCronJob(any()) }
|
||||
coVerify(exactly = 0) { mockApi.instantiateBlueprint(any()) }
|
||||
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user