diff --git a/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt index c79822267..494bcba5a 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt @@ -11,10 +11,25 @@ import app.cash.backfila.dashboard.UpdateBackfillRequest import app.cash.backfila.service.persistence.BackfillState import app.cash.backfila.ui.components.AlertError import app.cash.backfila.ui.components.DashboardPageLayout +import app.cash.backfila.ui.pages.BackfillShowAction.Companion.CANCEL_STATE_BUTTON_LABEL +import app.cash.backfila.ui.pages.BackfillShowAction.Companion.DELETE_STATE_BUTTON_LABEL +import app.cash.backfila.ui.pages.BackfillShowAction.Companion.PAUSE_STATE_BUTTON_LABEL +import app.cash.backfila.ui.pages.BackfillShowAction.Companion.START_STATE_BUTTON_LABEL +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton +import kotlinx.html.ButtonType +import kotlinx.html.InputType +import kotlinx.html.TagConsumer +import kotlinx.html.button import kotlinx.html.div +import kotlinx.html.form +import kotlinx.html.h2 +import kotlinx.html.input +import kotlinx.html.span import misk.security.authz.Authenticated +import misk.tailwind.Link +import misk.turbo.turbo_frame import misk.web.Get import misk.web.PathParam import misk.web.QueryParam @@ -46,73 +61,125 @@ class BackfillShowButtonHandlerAction @Inject constructor( ): Response { try { if (!field_id.isNullOrBlank()) { - when (field_id) { - "state" -> { - when (field_value) { - BackfillState.PAUSED.name -> { - stopBackfillAction.stop(id.toLong(), StopBackfillRequest()) - } - BackfillState.RUNNING.name -> { - startBackfillAction.start(id.toLong(), StartBackfillRequest()) - } - BackfillState.CANCELLED.name -> { - cancelBackfillAction.cancel(id.toLong()) - } - "soft_delete" -> { - softDeleteBackfillAction.softDelete(id.toLong()) - } - } - } + handleFieldUpdate(id.toLong(), field_id, field_value) + } + } catch (e: Exception) { + return handleError(e) + } - "num_threads" -> { - val numThreads = field_value?.toIntOrNull() - if (numThreads != null) { - updateBackfillAction.update(id.toLong(), UpdateBackfillRequest(num_threads = numThreads)) - } - } + return when (field_id) { + "state" -> handleStateFrameResponse(id, field_value) + else -> handleRedirectResponse(id) + } + } - "scan_size" -> { - val scanSize = field_value?.toLongOrNull() - if (scanSize != null) { - updateBackfillAction.update(id.toLong(), UpdateBackfillRequest(scan_size = scanSize)) - } - } + private fun handleFieldUpdate(id: Long, fieldId: String, fieldValue: String?) { + when (fieldId) { + "state" -> handleStateUpdate(id, fieldValue) + else -> handleConfigUpdate(id, fieldId, fieldValue) + } + } - "batch_size" -> { - val batchSize = field_value?.toLongOrNull() - if (batchSize != null) { - updateBackfillAction.update(id.toLong(), UpdateBackfillRequest(batch_size = batchSize)) - } - } + private fun handleStateUpdate(id: Long, value: String?) { + when (value) { + BackfillState.PAUSED.name -> stopBackfillAction.stop(id, StopBackfillRequest()) + BackfillState.RUNNING.name -> startBackfillAction.start(id, StartBackfillRequest()) + BackfillState.CANCELLED.name -> cancelBackfillAction.cancel(id) + "soft_delete" -> softDeleteBackfillAction.softDelete(id) + } + } - "extra_sleep_ms" -> { - val extraSleepMs = field_value?.toLongOrNull() - if (extraSleepMs != null) { - updateBackfillAction.update(id.toLong(), UpdateBackfillRequest(extra_sleep_ms = extraSleepMs)) - } - } + private fun handleConfigUpdate(id: Long, fieldId: String, value: String?) { + val request = when (fieldId) { + "num_threads" -> value?.toIntOrNull()?.let { UpdateBackfillRequest(num_threads = it) } + "scan_size" -> value?.toLongOrNull()?.let { UpdateBackfillRequest(scan_size = it) } + "batch_size" -> value?.toLongOrNull()?.let { UpdateBackfillRequest(batch_size = it) } + "extra_sleep_ms" -> value?.toLongOrNull()?.let { UpdateBackfillRequest(extra_sleep_ms = it) } + "backoff_schedule" -> value?.let { UpdateBackfillRequest(backoff_schedule = it) } + else -> null + } + request?.let { updateBackfillAction.update(id, it) } + } - "backoff_schedule" -> { - updateBackfillAction.update(id.toLong(), UpdateBackfillRequest(backoff_schedule = field_value)) - } + private fun handleError(e: Exception): Response { + logger.error(e) { "Update backfill field failed $e" } + val errorHtmlResponseBody = dashboardPageLayout.newBuilder() + .buildHtmlResponseBody { + div("py-20") { + AlertError(message = "Update backfill field failed: $e", label = "Try Again", onClick = "history.back(); return false;") } } - } catch (e: Exception) { - // Since this action is only hit from the UI, catch any validation errors and show them to the user - val errorHtmlResponseBody = dashboardPageLayout.newBuilder() - .buildHtmlResponseBody { - div("py-20") { - AlertError(message = "Update backfill field failed: $e", label = "Try Again", onClick = "history.back(); return false;") + return Response( + body = errorHtmlResponseBody, + statusCode = 200, + headers = Headers.headersOf("Content-Type", MediaTypes.TEXT_HTML), + ) + } + + private fun handleStateFrameResponse(id: String, fieldValue: String?): Response { + val currentState = when (fieldValue) { + BackfillState.PAUSED.name -> BackfillState.PAUSED + BackfillState.RUNNING.name -> BackfillState.RUNNING + BackfillState.CANCELLED.name -> BackfillState.CANCELLED + "soft_delete" -> BackfillState.COMPLETE + else -> BackfillState.RUNNING + } + + val frameContent = dashboardPageLayout.newBuilder() + .buildHtmlResponseBody { + turbo_frame("backfill-$id-state") { + div("flex justify-between items-center") { + h2("text-base font-semibold leading-6 text-gray-900") { +"State" } + div("flex gap-2") { + span("text-gray-700") { +currentState.name } + renderStateButtons(id, currentState) + } } } - logger.error(e) { "Update backfill field failed $e" } - return Response( - body = errorHtmlResponseBody, - statusCode = 200, - headers = Headers.headersOf("Content-Type", MediaTypes.TEXT_HTML), - ) + } + + return Response( + body = frameContent, + statusCode = 200, + headers = Headers.headersOf("Content-Type", MediaTypes.TEXT_HTML), + ) + } + + fun TagConsumer<*>.renderStateButtons(id: String, currentState: BackfillState, deletedAt: Instant? = null) { + getStateButton(currentState)?.let { button -> + renderButton(id, "state", button, if (button.label == START_STATE_BUTTON_LABEL) "green" else "yellow") + } + getCancelButton(currentState)?.let { button -> + renderButton(id, "state", button, "red") + } + getDeleteButton(currentState, deletedAt)?.let { button -> + renderButton(id, "state", button, "gray") + } + } + + fun TagConsumer<*>.renderButton(id: String, fieldId: String, button: Link, color: String) { + form { + action = path(id) + input { + type = InputType.hidden + name = "field_id" + value = fieldId + } + input { + type = InputType.hidden + name = "field_value" + value = button.href + } + button( + classes = "rounded-full bg-$color-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-$color-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-$color-600", + ) { + type = ButtonType.submit + +button.label + } } + } + private fun handleRedirectResponse(id: String): Response { return Response( body = "go to /backfills/$id".toResponseBody(), statusCode = 303, @@ -120,6 +187,45 @@ class BackfillShowButtonHandlerAction @Inject constructor( ) } + private fun getStateButton(state: BackfillState): Link? { + return when (state) { + BackfillState.PAUSED -> Link( + label = START_STATE_BUTTON_LABEL, + href = BackfillState.RUNNING.name, + ) + // COMPLETE and CANCELLED represent final states. + BackfillState.COMPLETE -> null + BackfillState.CANCELLED -> null + else -> Link( + label = PAUSE_STATE_BUTTON_LABEL, + href = BackfillState.PAUSED.name, + ) + } + } + + private fun getCancelButton(state: BackfillState): Link? { + return when (state) { + BackfillState.PAUSED -> Link( + label = CANCEL_STATE_BUTTON_LABEL, + href = BackfillState.CANCELLED.name, + ) + else -> null + } + } + + private fun getDeleteButton(state: BackfillState, deletedAt: Instant?): Link? { + if (deletedAt != null) { + return null + } + return when (state) { + BackfillState.COMPLETE, BackfillState.CANCELLED -> Link( + label = DELETE_STATE_BUTTON_LABEL, + href = "soft_delete", + ) + else -> null + } + } + companion object { private val logger = getLogger() diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt index f3b06e0b3..adaa489fc 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt @@ -12,7 +12,6 @@ import app.cash.backfila.ui.components.PageTitle import app.cash.backfila.ui.components.Pagination import app.cash.backfila.ui.components.ProgressBar import app.cash.backfila.ui.pages.BackfillCreateAction.BackfillCreateField.CUSTOM_PARAMETER_PREFIX -import java.time.Instant import javax.inject.Inject import javax.inject.Singleton import kotlinx.html.ButtonType @@ -37,6 +36,7 @@ import kotlinx.html.thead import kotlinx.html.tr import misk.security.authz.Authenticated import misk.tailwind.Link +import misk.turbo.turbo_frame import misk.web.Get import misk.web.PathParam import misk.web.QueryParam @@ -51,6 +51,7 @@ class BackfillShowAction @Inject constructor( private val getBackfillStatusAction: GetBackfillStatusAction, private val dashboardPageLayout: DashboardPageLayout, private val viewLogsAction: ViewLogsAction, + private val backfillShowButtonHandlerAction: BackfillShowButtonHandlerAction, ) : WebAction { @Get(PATH) @ResponseContentType(MediaTypes.TEXT_HTML) @@ -102,6 +103,24 @@ class BackfillShowAction @Inject constructor( } } + // State section with its own auto-reload + AutoReload(frameId = "backfill-$id-state") { + Card { + turbo_frame("backfill-$id-state") { + div("flex justify-between items-center") { + h2("text-base font-semibold leading-6 text-gray-900") { +"State" } + div("flex gap-2") { + span("text-gray-700") { +backfill.state.name } + with(backfillShowButtonHandlerAction) { + renderStateButtons(id.toString(), backfill.state, backfill.deleted_at) + } + } + } + } + } + } + + // Configuration section Card { div("mx-auto grid max-w-2xl grid-cols-1 grid-rows-1 items-start gap-x-24 gap-y-8 lg:mx-0 lg:max-w-none lg:grid-cols-2") { // """ @@ -298,54 +317,7 @@ class BackfillShowAction @Inject constructor( val deleteButton: Link? = null, ) - private fun getStateButton(state: BackfillState): Link? { - return when (state) { - BackfillState.PAUSED -> Link( - label = START_STATE_BUTTON_LABEL, - href = BackfillState.RUNNING.name, - ) - // COMPLETE and CANCELLED represent final states. - BackfillState.COMPLETE -> null - BackfillState.CANCELLED -> null - else -> Link( - label = PAUSE_STATE_BUTTON_LABEL, - href = BackfillState.PAUSED.name, - ) - } - } - - private fun getCancelButton(state: BackfillState): Link? { - return when (state) { - BackfillState.PAUSED -> Link( - label = CANCEL_STATE_BUTTON_LABEL, - href = BackfillState.CANCELLED.name, - ) - else -> null - } - } - - private fun getDeleteButton(state: BackfillState, deletedAt: Instant?): Link? { - if (deletedAt != null) { - return null - } - return when (state) { - BackfillState.COMPLETE, BackfillState.CANCELLED -> Link( - label = DELETE_STATE_BUTTON_LABEL, - href = "soft_delete", - ) - else -> null - } - } - private fun GetBackfillStatusResponse.toConfigurationRows(id: Long) = listOf( - DescriptionListRow( - label = "State", - description = state.name, - button = getStateButton(state), - updateFieldId = "state", - cancelButton = getCancelButton(state), - deleteButton = getDeleteButton(state, deleted_at), - ), DescriptionListRow( label = "Dry Run", description = if (dry_run) "dry run" else "wet run", diff --git a/service/src/main/resources/web/static/js/auto_reload_controller.js b/service/src/main/resources/web/static/js/auto_reload_controller.js index 9743ee933..45761b832 100644 --- a/service/src/main/resources/web/static/js/auto_reload_controller.js +++ b/service/src/main/resources/web/static/js/auto_reload_controller.js @@ -19,7 +19,7 @@ Stimulus.register("auto-reload", class extends Controller { const pathname = window.location.pathname const url = new URL(pathname, window.location.origin) url.searchParams.set('frame', 'true') - + Turbo.visit(url.toString(), { frame: this.frameTarget.id }) }, 10000) }