diff --git a/service/src/main/kotlin/app/cash/backfila/ui/DashboardUrls.kt b/service/src/main/kotlin/app/cash/backfila/ui/DashboardUrls.kt new file mode 100644 index 000000000..021965e71 --- /dev/null +++ b/service/src/main/kotlin/app/cash/backfila/ui/DashboardUrls.kt @@ -0,0 +1,8 @@ +package app.cash.backfila.ui + +object DashboardUrls { + fun app(appName: String) = "/app/$appName" + fun createDeploy(appName: String) = "/app/$appName/deploy" + fun deploy(appName: String, deployName: String) = "/app/$appName/deploy/$deployName" + fun setMinimalCommitTimestamp(appName: String) = "/app/$appName/set-minimal-commit-timestamp" +} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/PathBuilder.kt b/service/src/main/kotlin/app/cash/backfila/ui/PathBuilder.kt deleted file mode 100644 index 54ffe5dad..000000000 --- a/service/src/main/kotlin/app/cash/backfila/ui/PathBuilder.kt +++ /dev/null @@ -1,69 +0,0 @@ -package app.cash.backfila.ui - -import app.cash.backfila.ui.pages.ServiceShowAction -import app.cash.protos.backfila.ui.SortCol - -// TODO add tests! -data class PathBuilder( - val frame: String? = null, - val boolean: Boolean? = null, - val query: String? = null, - val path: String? = null, - val limit: Int? = null, - val page: Int? = null, - val service: String? = null, - val variant: String? = null, - /** Could be comma separated list of tokens */ - val token: String? = null, - val sortColumn: SortCol? = null, - val sortDesc: Boolean? = null, - val platform: String? = null, - val project: String? = null, - val client_key: String? = null, - val version: String? = null, - val locale: String? = null, -) { - fun countFilters() = 0 - - fun build(): String = StringBuilder().apply { - append("/") - path?.removePrefix("/")?.let { - append(it.split("{").first()) - - if (path == ServiceShowAction.PATH && service != null) { - append(service) - append("/") - } - } - platform?.removePrefix("/")?.let { append("/$it") } - project?.removePrefix("/")?.let { append("/$it") } - client_key?.removePrefix("/")?.let { append("/$it") } - version?.removePrefix("/")?.let { append("/$it") } - locale?.removePrefix("/")?.let { append("/$it") } - - if (!this.contains("?") && this.last() != '&') append("?") - boolean?.let { append("$BooleanParam=$it&") } - limit?.let { append("$LimitParam=$it&") } - page?.let { append("$PageParam=$it&") } - sortDesc?.let { append("$SortDescParam=$it&") } - if (!service.isNullOrBlank()) append("$ServiceParam=$service&") - if (!variant.isNullOrBlank()) append("$VariantParam=$variant&") - if (!frame.isNullOrBlank()) { append("$FrameParam=$frame&") } - if (!query.isNullOrBlank()) { append("$SearchParam=$query&") } - if (!token.isNullOrBlank()) { append("$TokenParam=$token&") } - if (sortColumn != null) { append("$SortColumnParam=${sortColumn.value}&") } - }.toString().removeSuffix("?").removeSuffix("&") - - companion object { - const val FrameParam = "frame" - const val BooleanParam = "boolean" - const val SearchParam = "q" - const val LimitParam = "limit" - const val PageParam = "p" - const val ServiceParam = "s" - const val VariantParam = "v" - const val TokenParam = "t" - const val SortColumnParam = "sc" - const val SortDescParam = "sd" - } -} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/UiModule.kt b/service/src/main/kotlin/app/cash/backfila/ui/UiModule.kt index 36e21da17..5170babe4 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/UiModule.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/UiModule.kt @@ -2,9 +2,9 @@ package app.cash.backfila.ui import app.cash.backfila.ui.actions.BackfillCreateHandlerAction import app.cash.backfila.ui.actions.BackfillShowButtonHandlerAction -import app.cash.backfila.ui.actions.ServiceAutocompleteAction import app.cash.backfila.ui.pages.BackfillCreateAction import app.cash.backfila.ui.pages.BackfillCreateIndexAction +import app.cash.backfila.ui.pages.BackfillCreateServiceIndexAction import app.cash.backfila.ui.pages.BackfillIndexAction import app.cash.backfila.ui.pages.BackfillShowAction import app.cash.backfila.ui.pages.IndexAction @@ -20,6 +20,7 @@ class UiModule : KAbstractModule() { install(WebActionModule.create()) install(WebActionModule.create()) install(WebActionModule.create()) + install(WebActionModule.create()) install(WebActionModule.create()) install(WebActionModule.create()) install(WebActionModule.create()) @@ -27,6 +28,5 @@ class UiModule : KAbstractModule() { // Other install(WebActionModule.create()) install(WebActionModule.create()) - install(WebActionModule.create()) } } diff --git a/service/src/main/kotlin/app/cash/backfila/ui/actions/ServiceAutocompleteAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/actions/ServiceAutocompleteAction.kt deleted file mode 100644 index e027f7bb1..000000000 --- a/service/src/main/kotlin/app/cash/backfila/ui/actions/ServiceAutocompleteAction.kt +++ /dev/null @@ -1,54 +0,0 @@ -package app.cash.backfila.ui.actions - -import app.cash.backfila.dashboard.GetServicesAction -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.html.li -import kotlinx.html.role -import misk.hotwire.buildHtml -import misk.security.authz.Authenticated -import misk.web.Get -import misk.web.QueryParam -import misk.web.ResponseContentType -import misk.web.actions.WebAction -import misk.web.mediatype.MediaTypes - -@Singleton -class ServiceAutocompleteAction @Inject constructor( - private val getServicesAction: GetServicesAction, -) : WebAction { - @Get(PATH) - @ResponseContentType(MediaTypes.TEXT_HTML) - @Authenticated(capabilities = ["users"]) - fun get( - @QueryParam q: String?, - ): String { - val services = getFlattenedServices() - - return buildHtml { - services.filter { (path, _) -> - q.isNullOrBlank() || path.lowercase().contains(q.lowercase()) - }.map { (path, service) -> - li("list-group-item cursor-default select-none px-4 py-2 text-left") { - role = "option" - attributes["data-autocomplete-value"] = path - - // Don't include default variant in label, only for unique variants - val label = if (path.split("/").last() == "default") service.name else path - +"""$label (${service.running_backfills})""" - } - } - } - } - - fun getFlattenedServices(): Map { - val services = getServicesAction.services().services - return services.flatMap { service -> - service.variants.map { variant -> "${service.name}/$variant" to service } - }.toMap() - } - - companion object { - const val PATH = "/api/autocomplete/services" - } -} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/actions/ServiceDataHelper.kt b/service/src/main/kotlin/app/cash/backfila/ui/actions/ServiceDataHelper.kt new file mode 100644 index 000000000..b78b27739 --- /dev/null +++ b/service/src/main/kotlin/app/cash/backfila/ui/actions/ServiceDataHelper.kt @@ -0,0 +1,17 @@ +package app.cash.backfila.ui.actions + +import app.cash.backfila.dashboard.GetServicesAction +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ServiceDataHelper @Inject constructor( + private val getServicesAction: GetServicesAction, +) { + fun getFlattenedServices(): Map { + val services = getServicesAction.services().services + return services.flatMap { service -> + service.variants.map { variant -> "${service.name}/$variant" to service } + }.toMap() + } +} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/components/BackfillsTable.kt b/service/src/main/kotlin/app/cash/backfila/ui/components/BackfillsTable.kt index 229dae74c..040e39d1c 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/components/BackfillsTable.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/components/BackfillsTable.kt @@ -1,7 +1,6 @@ package app.cash.backfila.ui.components import app.cash.backfila.dashboard.UiBackfillRun -import app.cash.backfila.ui.PathBuilder import app.cash.backfila.ui.pages.BackfillShowAction import kotlinx.html.TagConsumer import kotlinx.html.a @@ -48,7 +47,7 @@ fun TagConsumer<*>.BackfillsTable(running: Boolean, backfills: List.() -> Unit) = apply { this.headBlock = block } - fun breadcrumbLinks(links: List) = apply { this.breadcrumbLinks = links } + fun breadcrumbLinks(vararg links: Link?) = apply { this.breadcrumbLinks = links.toList().filterNotNull() } @JvmOverloads fun build(block: TagConsumer<*>.() -> Unit = { }): String { diff --git a/service/src/main/kotlin/app/cash/backfila/ui/components/PageTitle.kt b/service/src/main/kotlin/app/cash/backfila/ui/components/PageTitle.kt index 0ef2724f6..68244a0a7 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/components/PageTitle.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/components/PageTitle.kt @@ -5,7 +5,7 @@ import kotlinx.html.div import kotlinx.html.header import kotlinx.html.span -fun TagConsumer<*>.PageTitle(title: String, subtitle: String? = null, floatRightBlock: TagConsumer<*>.() -> Unit = {}) { +fun TagConsumer<*>.PageTitle(title: String, subtitle: String? = null, smallerSubtitle: String? = null, floatRightBlock: TagConsumer<*>.() -> Unit = {}) { header { div("mx-auto max-w-7xl px-200 sm:px-6 lg:px-8s py-10") { val maybeSubtitleSuffix = subtitle?.let { ": " } ?: "" @@ -14,6 +14,11 @@ fun TagConsumer<*>.PageTitle(title: String, subtitle: String? = null, floatRight div("float-right") { floatRightBlock() } + div { + smallerSubtitle?.let { + span("text-sm font-medium text-gray-500") { +it } + } + } } } } diff --git a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceAutocomplete.kt b/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceAutocomplete.kt deleted file mode 100644 index d797e97e7..000000000 --- a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceAutocomplete.kt +++ /dev/null @@ -1,68 +0,0 @@ -package app.cash.backfila.ui.components - -import app.cash.backfila.ui.PathBuilder -import app.cash.backfila.ui.actions.ServiceAutocompleteAction -import kotlinx.html.InputType -import kotlinx.html.TagConsumer -import kotlinx.html.div -import kotlinx.html.form -import kotlinx.html.id -import kotlinx.html.input -import kotlinx.html.ul -import misk.tailwind.icons.Heroicons -import misk.tailwind.icons.heroicon - -/** - * Autocomplete input bar for search - * Source: https://github.com/afcapel/stimulus-autocomplete - */ -fun TagConsumer<*>.ServiceAutocomplete( - pagePathBuilder: PathBuilder, - inputPlaceholder: String = "Type a service name, use arrow keys to select, then enter to search...", -) { - div { - attributes["data-controller"] = "autocomplete" - attributes["data-autocomplete-url-value"] = ServiceAutocompleteAction.PATH - attributes["data-autocomplete-submit-on-enter"] = "true" - attributes["data-autocomplete-selected-class"] = "bg-green-600 text-white" - attributes["combobox"] - - form("relative mt-1 rounded-md shadow-sm pt") { - action = pagePathBuilder.build() - // Updates browser URL for permalinks - attributes["data-turbo-action"] = "replace" - - div("relative mt-1 rounded-md shadow-sm") { - // TODO fix CSS issue in production - div("pointer-events-none absolute inset-y-0 left-0 mt-2.5 items-center pl-3") { - heroicon(Heroicons.MAGNIFYING_GLASS) - } - input(classes = "block w-full rounded-md border-gray-300 pl-10 focus:border-green-500 focus:ring-green-500 sm:text-sm") { - attributes["data-autocomplete-target"] = "input" - - type = InputType.text - name = PathBuilder.SearchParam - id = PathBuilder.SearchParam - placeholder = inputPlaceholder - } - input { - attributes["data-autocomplete-target"] = "hidden" - - type = InputType.hidden - name = PathBuilder.ServiceParam - id = PathBuilder.ServiceParam - } - input { - attributes["data-autocomplete-target"] = "hidden" - - type = InputType.hidden - name = PathBuilder.VariantParam - id = PathBuilder.VariantParam - } - ul("list-group absolute z-10 mt-2 rounded-md bg-white shadow-md ring-1 ring-black ring-opacity-5 focus:outline-none text-left") { - attributes["data-autocomplete-target"] = "results" - } - } - } - } -} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceAutocompleteWrapper.kt b/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceAutocompleteWrapper.kt deleted file mode 100644 index 1c2f2defd..000000000 --- a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceAutocompleteWrapper.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.cash.backfila.ui.components - -import app.cash.backfila.ui.PathBuilder -import kotlinx.html.TagConsumer -import kotlinx.html.div -import kotlinx.html.label - -fun TagConsumer<*>.ServiceAutocompleteWrapper(redirectPath: String) { - div("rounded-lg bg-gray-100 my-5") { - div("px-4 py-5 sm:p-6") { - div { - label("block text-sm font-medium leading-6 text-gray-900") { - htmlFor = "location" - +"""Service Name""" - } - ServiceAutocomplete( - pagePathBuilder = PathBuilder(path = redirectPath), - // TODO delete if don't want URL query paramter to pre-fill the search bar - // query = serviceQuery?.lowercase(), - ) - } - } - } -} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceSearch.kt b/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceSearch.kt deleted file mode 100644 index d15d9a211..000000000 --- a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceSearch.kt +++ /dev/null @@ -1,54 +0,0 @@ -package app.cash.backfila.ui.components - -import app.cash.backfila.ui.PathBuilder -import app.cash.backfila.ui.actions.ServiceAutocompleteAction -import kotlinx.html.InputType -import kotlinx.html.TagConsumer -import kotlinx.html.div -import kotlinx.html.form -import kotlinx.html.id -import kotlinx.html.input -import misk.tailwind.icons.Heroicons -import misk.tailwind.icons.heroicon - -/** - * Autocomplete input bar for search - * Source: https://github.com/afcapel/stimulus-autocomplete - */ -fun TagConsumer<*>.ServiceSearch( - pagePathBuilder: PathBuilder, - inputPlaceholder: String = "Type a service name, use arrow keys to select, then enter to search...", -) { - div { - attributes["data-controller"] = "autocomplete" - attributes["data-autocomplete-url-value"] = ServiceAutocompleteAction.PATH - attributes["data-autocomplete-submit-on-enter"] = "true" - attributes["data-autocomplete-selected-class"] = "bg-green-600 text-white" - attributes["combobox"] - - form("relative mt-1 rounded-md shadow-sm pt") { - action = pagePathBuilder.build() - // Updates browser URL for permalinks - - div("relative mt-1 rounded-md shadow-sm") { - div("pointer-events-none absolute inset-y-0 left-0 mt-2.5 items-center pl-3") { - heroicon(Heroicons.MAGNIFYING_GLASS) - } - input(classes = "block w-full rounded-md border-gray-300 pl-10 focus:border-green-500 focus:ring-green-500 sm:text-sm") { - type = InputType.text - name = PathBuilder.SearchParam - id = PathBuilder.SearchParam - placeholder = inputPlaceholder - } - input { - type = InputType.text - name = PathBuilder.ServiceParam - id = PathBuilder.ServiceParam - } - input { - type = InputType.submit - } - } - } - } -} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceSearchWrapper.kt b/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceSearchWrapper.kt deleted file mode 100644 index 0d36b3499..000000000 --- a/service/src/main/kotlin/app/cash/backfila/ui/components/ServiceSearchWrapper.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.cash.backfila.ui.components - -import app.cash.backfila.ui.PathBuilder -import kotlinx.html.TagConsumer -import kotlinx.html.div -import kotlinx.html.label - -fun TagConsumer<*>.ServiceSearchWrapper(redirectPath: String) { - div("rounded-lg bg-gray-100 my-5") { - div("px-4 py-5 sm:p-6") { - div { - label("block text-sm font-medium leading-6 text-gray-900") { - htmlFor = "location" - +"""Service Name""" - } - ServiceSearch( - pagePathBuilder = PathBuilder(path = redirectPath), - // TODO delete if don't want URL query paramter to pre-fill the search bar - // query = serviceQuery?.lowercase(), - ) - } - } - } -} diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateAction.kt index 1bc46920d..ed596e2fa 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateAction.kt @@ -4,7 +4,7 @@ import app.cash.backfila.dashboard.GetBackfillRunsAction import app.cash.backfila.dashboard.GetBackfillStatusAction import app.cash.backfila.dashboard.GetRegisteredBackfillsAction import app.cash.backfila.ui.actions.BackfillCreateHandlerAction -import app.cash.backfila.ui.actions.ServiceAutocompleteAction +import app.cash.backfila.ui.actions.ServiceDataHelper import app.cash.backfila.ui.components.AlertError import app.cash.backfila.ui.components.DashboardPageLayout import app.cash.backfila.ui.components.PageTitle @@ -12,25 +12,20 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.html.ButtonType import kotlinx.html.InputType -import kotlinx.html.a import kotlinx.html.button import kotlinx.html.div import kotlinx.html.form import kotlinx.html.h2 -import kotlinx.html.h3 import kotlinx.html.id import kotlinx.html.input import kotlinx.html.label import kotlinx.html.legend -import kotlinx.html.li import kotlinx.html.p -import kotlinx.html.role import kotlinx.html.span -import kotlinx.html.ul import misk.security.authz.Authenticated +import misk.tailwind.Link import misk.web.Get import misk.web.PathParam -import misk.web.QueryParam import misk.web.Response import misk.web.ResponseBody import misk.web.ResponseContentType @@ -39,7 +34,7 @@ import misk.web.mediatype.MediaTypes @Singleton class BackfillCreateAction @Inject constructor( - private val serviceAutocompleteAction: ServiceAutocompleteAction, + private val serviceDataHelper: ServiceDataHelper, private val getBackfillStatusAction: GetBackfillStatusAction, private val getRegisteredBackfillsAction: GetRegisteredBackfillsAction, private val getBackfillRunsAction: GetBackfillRunsAction, @@ -50,367 +45,338 @@ class BackfillCreateAction @Inject constructor( @Authenticated(capabilities = ["users"]) fun get( @PathParam service: String, - @PathParam variantOrBlank: String? = "", - @QueryParam backfillName: String? = "", - @QueryParam backfillIdToClone: String? = null, + @PathParam variantOrBackfillNameOrId: String, + @PathParam backfillNameOrId: String? = "", ): Response { + val variantOrBlank = if (backfillNameOrId.isNullOrBlank()) { + // This means variant is null or default + null + } else { + variantOrBackfillNameOrId + } + + val variant = variantOrBlank.orEmpty().ifBlank { "default" } + val label = if (variant == "default") service else "$service ($variant)" + val backfillName: String? = + listOf(variantOrBackfillNameOrId, backfillNameOrId).firstOrNull { it?.contains(".") == true } + val backfillIdToClone: String? = + listOf(variantOrBackfillNameOrId, backfillNameOrId).firstOrNull { it?.toIntOrNull() != null } + + // If service + variant + backfill id to clone are valid, pre-fill form with backfill details + val backfillRuns = getBackfillRunsAction.backfillRuns(service, variant) + val backfillToClone = + (backfillRuns.paused_backfills + backfillRuns.running_backfills).firstOrNull { it.id == backfillIdToClone } + val backfillToCloneStatus = backfillToClone?.id?.toLongOrNull()?.let { getBackfillStatusAction.status(it) } + + val registeredBackfills = getRegisteredBackfillsAction.backfills(service, variant) + val registeredBackfill = + registeredBackfills.backfills.firstOrNull { it.name == backfillName || it.name == backfillToClone?.name.orEmpty() } + + val resolvedBackfillName = registeredBackfill?.name ?: backfillToClone?.name ?: "" + + val cloneOrCreate = if (backfillToClone != null) { + "Clone" + } else { + "Create" + } + val htmlResponseBody = dashboardPageLayout.newBuilder() - .title("Create Backfill | Backfila") + .title("$cloneOrCreate Backfill | Backfila") + .breadcrumbLinks( + Link("Services", ServiceIndexAction.PATH), + Link( + label, + ServiceShowAction.PATH + .replace("{service}", service) + .replace("{variantOrBlank}", if (variant != "default") variant else ""), + ), + if (backfillToClone != null) { + Link( + "Backfill #${backfillToClone.id}", + BackfillShowAction.PATH.replace("{id}", backfillToClone.id), + ) + } else if (registeredBackfill != null) { + Link( + "Create", + BackfillCreateServiceIndexAction.PATH + .replace("{service}", service) + .replace("{variantOrBlank}", variantOrBlank ?: ""), + ) + } else { + null + }, + if ((backfillToClone?.id ?: registeredBackfill?.name) != null) { + Link( + backfillToClone?.id?.let { "Clone" } ?: registeredBackfill?.name ?: "", + PATH + .replace("{service}", service) + .replace("{variantOrBackfillNameOrId}", variantOrBackfillNameOrId) + .replace("{backfillNameOrId}", backfillNameOrId ?: ""), + ) + } else { + null + }, + ) .buildHtmlResponseBody { - val variant = variantOrBlank.orEmpty().ifBlank { "default" } - - // If service + variant + backfill id to clone are valid, pre-fill form with backfill details - val backfillRuns = getBackfillRunsAction.backfillRuns(service, variant) - val backfillToClone = - (backfillRuns.paused_backfills + backfillRuns.running_backfills).firstOrNull { it.id == backfillIdToClone } - val backfillToCloneStatus = backfillToClone?.id?.toLongOrNull()?.let { getBackfillStatusAction.status(it) } - - val registeredBackfills = getRegisteredBackfillsAction.backfills(service, variant) - val registeredBackfill = registeredBackfills.backfills.firstOrNull { it.name == backfillName || backfillToClone?.name == backfillName } - - val resolvedBackfillName = registeredBackfill?.name ?: backfillToClone?.name ?: "" - - PageTitle("Create Backfill", resolvedBackfillName) { + PageTitle("$cloneOrCreate Backfill", backfillToClone?.id, smallerSubtitle = registeredBackfill?.name) { span("inline-flex shrink-0 items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-s font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20") { val suffix = if (variantOrBlank.isNullOrBlank()) "" else "/$variantOrBlank" +"$service$suffix" } } - if (service.isNotBlank() && registeredBackfill == null && backfillIdToClone == null) { - // If service + variant is set and valid, show registered backfills drop down - p { - +"Service: $service" - } - p { - +"Variant: $variant" - } - div("py-10") { - ul("grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3") { - role = "list" - - registeredBackfills.backfills.map { - a { - // TODO redirect to same page but with backfill filled in - href = PATH.replace("{service}", service) - .replace("{variantOrBlank}", variantOrBlank ?: "") + "?backfillName=${it.name}" - - // TODO make full width - this@ul.li("registration col-span-1 divide-y divide-gray-200 rounded-lg bg-white shadow") { - div("flex w-full items-center justify-between space-x-6 p-6") { - div("flex-1 truncate") { - div("flex items-center space-x-3") { - // Don't include default variant in label, only for unique variants -// val label = if (variant == "default") service else "$service/$variant" - h3("truncate text-sm font-medium text-gray-900") { - +it.name - } -// variant?.let { span("inline-flex shrink-0 items-center rounded-full bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20") { +it } } - } - // p("mt-1 truncate text-sm text-gray-500") { +"""Regional Paradigm Technician""" } - } - } - } - // Buttons -// div { -// div("-mt-px flex divide-x divide-gray-200") { -// div("flex w-0 flex-1") { -// a(classes = "relative -mr-px inline-flex w-0 flex-1 items-center justify-center gap-x-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-gray-900") { -// href = "mailto:janecooper@example.com" -// // svg("size-5 text-gray-400") { -// // viewbox = "0 0 20 20" -// // fill = "currentColor" -// // attributes["aria-hidden"] = "true" -// // attributes["data-slot"] = "icon" -// // path { -// // d = -// // "M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z" -// // } -// // path { -// // d = -// // "m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z" -// // } -// // } -// +"""Email""" -// } -// } -// div("-ml-px flex w-0 flex-1") { -// a(classes = "relative inline-flex w-0 flex-1 items-center justify-center gap-x-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-gray-900") { -// href = "tel:+1-202-555-0170" -// // svg("size-5 text-gray-400") { -// // viewbox = "0 0 20 20" -// // fill = "currentColor" -// // attributes["aria-hidden"] = "true" -// // attributes["data-slot"] = "icon" -// // path { -// // attributes["fill-rule"] = "evenodd" -// // d = -// // "M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" -// // attributes["clip-rule"] = "evenodd" -// // } -// // } -// +"""Call""" -// } -// } -// } -// } - } + if (registeredBackfill == null && backfillToClone == null) { + AlertError( + message = "Invalid backfill name to create or ID to clone provided.", + label = "Create a Backfill", + link = BackfillCreateServiceIndexAction.PATH.replace("{service}", service) + .replace("{variantOrBlank}", variantOrBlank ?: ""), + ) + } else { + // TODO add Header buttons / metrics + + // TODO add backfill name and back button to select a different backfill, or select/options + + div("mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8") { + form { + action = BackfillCreateHandlerAction.PATH + + input { + type = InputType.hidden + name = BackfillCreateField.SERVICE.fieldId + value = service } - } - } - } else if (registeredBackfill != null || backfillIdToClone != null) { - if (registeredBackfill == null && backfillToClone == null) { - AlertError( - message = "Backfill to clone not found [id=$backfillIdToClone].", - label = "Create a Backfill", - link = PATH.replace("{service}", service).replace("{variantOrBlank}", variantOrBlank ?: ""), - ) - } else { - // TODO add Header buttons / metrics - - // TODO add backfill name and back button to select a different backfill, or select/options - - div("mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8") { - form { - action = BackfillCreateHandlerAction.PATH - - input { - type = InputType.hidden - name = BackfillCreateField.SERVICE.fieldId - value = service - } - input { - type = InputType.hidden - name = BackfillCreateField.VARIANT.fieldId - value = variant - } + input { + type = InputType.hidden + name = BackfillCreateField.VARIANT.fieldId + value = variant + } - input { - type = InputType.hidden - name = BackfillCreateField.BACKFILL_NAME.fieldId - value = resolvedBackfillName - } + input { + type = InputType.hidden + name = BackfillCreateField.BACKFILL_NAME.fieldId + value = resolvedBackfillName + } - div("space-y-12") { - div("border-b border-gray-900/10 pb-12") { - h2("text-base/7 font-semibold text-gray-900") { +"""Immutable Options""" } - p("mt-1 text-sm/6 text-gray-600") { +"""These options can't be changed once the backfill is created.""" } - div { - div("mt-6 space-y-6") { - div("flex gap-3") { - val field = BackfillCreateField.DRY_RUN.fieldId - div("flex h-6 shrink-0 items-center") { - div("group grid size-4 grid-cols-1") { - input(classes = "col-start-1 row-start-1 appearance-none rounded border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto") { - id = field - attributes["aria-describedby"] = "dry-run-description" - name = field - type = InputType.checkBox - // Checked by default to force user to opt-in to non-dry-run - checked = true - placeholder = "on" - backfillToCloneStatus?.let { - if (it.dry_run) { - value = "on" - checked = true - } else { - value = "off" - checked = false - } + div("space-y-12") { + div("border-b border-gray-900/10 pb-12") { + h2("text-base/7 font-semibold text-gray-900") { +"""Immutable Options""" } + p("mt-1 text-sm/6 text-gray-600") { +"""These options can't be changed once the backfill is created.""" } + div { + div("mt-6 space-y-6") { + div("flex gap-3") { + val field = BackfillCreateField.DRY_RUN.fieldId + div("flex h-6 shrink-0 items-center") { + div("group grid size-4 grid-cols-1") { + input(classes = "col-start-1 row-start-1 appearance-none rounded border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto") { + id = field + attributes["aria-describedby"] = "dry-run-description" + name = field + type = InputType.checkBox + // Checked by default to force user to opt-in to non-dry-run + checked = true + placeholder = "on" + backfillToCloneStatus?.let { + if (it.dry_run) { + value = "on" + checked = true + } else { + value = "off" + checked = false } } } } - div("text-sm/6") { - label("font-medium text-gray-900") { - htmlFor = field - +"""Dry Run""" - } - p("text-gray-500") { - id = "dry-run-description" - +"""Anything within your not Dry Run block will not be run.""" - } + } + div("text-sm/6") { + label("font-medium text-gray-900") { + htmlFor = field + +"""Dry Run""" + } + p("text-gray-500") { + id = "dry-run-description" + +"""Anything within your not Dry Run block will not be run.""" } } } } + } - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = BackfillCreateField.RANGE_START.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Range Start (optional)""" - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.number - name = field - id = field - attributes["autocomplete"] = field - // TODO is this how to clone? - backfillToCloneStatus?.partitions?.firstOrNull()?.pkey_start?.let { value = it } - } - } + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = BackfillCreateField.RANGE_START.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Range Start (optional)""" } - div("sm:col-span-3") { - val field = BackfillCreateField.RANGE_END.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Range End (optional)""" + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.number + name = field + id = field + attributes["autocomplete"] = field + // TODO is this how to clone? + backfillToCloneStatus?.partitions?.firstOrNull()?.pkey_start?.let { value = it } } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.number - name = field - id = field - attributes["autocomplete"] = field - // TODO is this how to clone? - backfillToCloneStatus?.partitions?.firstOrNull()?.pkey_end?.let { value = it } - } + } + } + div("sm:col-span-3") { + val field = BackfillCreateField.RANGE_END.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Range End (optional)""" + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.number + name = field + id = field + attributes["autocomplete"] = field + // TODO is this how to clone? + backfillToCloneStatus?.partitions?.firstOrNull()?.pkey_end?.let { value = it } } } } } } + } - div("pt-12 space-y-12") { - div("border-b border-gray-900/10 pb-12") { - h2("text-base/7 font-semibold text-gray-900") { +"""Mutable Options""" } - p("mt-1 text-sm/6 text-gray-600") { +"""These options can be changed once the backfill is created.""" } + div("pt-12 space-y-12") { + div("border-b border-gray-900/10 pb-12") { + h2("text-base/7 font-semibold text-gray-900") { +"""Mutable Options""" } + p("mt-1 text-sm/6 text-gray-600") { +"""These options can be changed once the backfill is created.""" } - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = BackfillCreateField.BATCH_SIZE.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Batch Size""" + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = BackfillCreateField.BATCH_SIZE.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Batch Size""" - legend("text-sm/6 font-normal text-gray-900") { +"""How many *matching* records to send per call to RunBatch.""" } - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.text - name = field - id = field - attributes["autocomplete"] = field - value = "100" - backfillToCloneStatus?.batch_size?.let { value = it.toString() } - } + legend("text-sm/6 font-normal text-gray-900") { +"""How many *matching* records to send per call to RunBatch.""" } + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.text + name = field + id = field + attributes["autocomplete"] = field + value = "100" + backfillToCloneStatus?.batch_size?.let { value = it.toString() } } } } + } - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = BackfillCreateField.SCAN_SIZE.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Scan Size""" + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = BackfillCreateField.SCAN_SIZE.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Scan Size""" - legend("text-sm/6 font-normal text-gray-900") { +"""How many records to scan when computing batches.""" } - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.number - name = field - id = field - attributes["autocomplete"] = field - value = "10000" - backfillToCloneStatus?.scan_size?.let { value = it.toString() } - } + legend("text-sm/6 font-normal text-gray-900") { +"""How many records to scan when computing batches.""" } + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.number + name = field + id = field + attributes["autocomplete"] = field + value = "10000" + backfillToCloneStatus?.scan_size?.let { value = it.toString() } } } } + } - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = BackfillCreateField.THREADS_PER_PARTITION.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Threads Per Partition""" - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.number - name = field - id = field - attributes["autocomplete"] = field - value = "1" - backfillToCloneStatus?.num_threads?.let { value = it.toString() } - } + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = BackfillCreateField.THREADS_PER_PARTITION.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Threads Per Partition""" + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.number + name = field + id = field + attributes["autocomplete"] = field + value = "1" + backfillToCloneStatus?.num_threads?.let { value = it.toString() } } } } + } - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = BackfillCreateField.EXTRA_SLEEP_MS.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Extra Sleep (ms)""" - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.number - name = field - id = field - attributes["autocomplete"] = field - value = "1" - backfillToCloneStatus?.extra_sleep_ms?.let { value = it.toString() } - } + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = BackfillCreateField.EXTRA_SLEEP_MS.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Extra Sleep (ms)""" + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.number + name = field + id = field + attributes["autocomplete"] = field + value = "1" + backfillToCloneStatus?.extra_sleep_ms?.let { value = it.toString() } } } } + } - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = BackfillCreateField.BACKOFF_SCHEDULE.fieldId - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +"""Backoff Schedule (optional)""" + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = BackfillCreateField.BACKOFF_SCHEDULE.fieldId + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +"""Backoff Schedule (optional)""" - legend("text-sm/6 font-normal text-gray-900") { +"""Comma separated list of milliseconds to backoff subsequent failures.""" } - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.text - name = field - id = field - attributes["autocomplete"] = field - placeholder = "5000,15000,30000" - backfillToCloneStatus?.backoff_schedule?.let { value = it } - } + legend("text-sm/6 font-normal text-gray-900") { +"""Comma separated list of milliseconds to backoff subsequent failures.""" } + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.text + name = field + id = field + attributes["autocomplete"] = field + placeholder = "5000,15000,30000" + backfillToCloneStatus?.backoff_schedule?.let { value = it } } } } } } + } - // Custom Parameters - if (registeredBackfill?.parameterNames?.isNotEmpty() == true) { - div("pt-12 space-y-12") { - div("border-b border-gray-900/10 pb-12") { - h2("text-base/7 font-semibold text-gray-900") { +"""Immutable Custom Parameters""" } - p("mt-1 text-sm/6 text-gray-600") { +"""These custom parameters can't be changed once the backfill is created.""" } - - registeredBackfill.parameterNames.map { - div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { - div("sm:col-span-3") { - val field = "${BackfillCreateField.CUSTOM_PARAMETER_PREFIX.fieldId}$it" - label("block text-sm/6 font-medium text-gray-900") { - htmlFor = field - +it - } - div("mt-2") { - input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { - type = InputType.text - name = field - id = field - attributes["autocomplete"] = field - backfillToCloneStatus?.parameters?.get(it)?.let { value = it } - } + // Custom Parameters + if (registeredBackfill?.parameterNames?.isNotEmpty() == true) { + div("pt-12 space-y-12") { + div("border-b border-gray-900/10 pb-12") { + h2("text-base/7 font-semibold text-gray-900") { +"""Immutable Custom Parameters""" } + p("mt-1 text-sm/6 text-gray-600") { +"""These custom parameters can't be changed once the backfill is created.""" } + + registeredBackfill.parameterNames.map { + div("mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6") { + div("sm:col-span-3") { + val field = "${BackfillCreateField.CUSTOM_PARAMETER_PREFIX.fieldId}$it" + label("block text-sm/6 font-medium text-gray-900") { + htmlFor = field + +it + } + div("mt-2") { + input(classes = "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6") { + type = InputType.text + name = field + id = field + attributes["autocomplete"] = field + backfillToCloneStatus?.parameters?.get(it)?.let { value = it } } } } @@ -418,16 +384,12 @@ class BackfillCreateAction @Inject constructor( } } } + } - button(classes = "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600") { - type = ButtonType.submit + button(classes = "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600") { + type = ButtonType.submit - if (backfillToClone != null) { - +"Clone" - } else { - +"""Create""" - } - } + +cloneOrCreate } } } @@ -453,6 +415,6 @@ class BackfillCreateAction @Inject constructor( } companion object { - const val PATH = "/backfills/create/{service}/{variantOrBlank}" + const val PATH = "/backfills/create/{service}/{variantOrBackfillNameOrId}/{backfillNameOrId}" } } diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateIndexAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateIndexAction.kt index 9d1ce259e..5a78d94da 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateIndexAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateIndexAction.kt @@ -1,14 +1,12 @@ package app.cash.backfila.ui.pages -import app.cash.backfila.dashboard.GetBackfillStatusAction import app.cash.backfila.dashboard.GetServicesAction -import app.cash.backfila.ui.actions.ServiceAutocompleteAction +import app.cash.backfila.ui.actions.ServiceDataHelper import app.cash.backfila.ui.components.DashboardPageLayout import app.cash.backfila.ui.components.PageTitle import app.cash.backfila.ui.components.ServiceSelect import javax.inject.Inject import javax.inject.Singleton -import kotlinx.html.div import misk.security.authz.Authenticated import misk.web.Get import misk.web.Response @@ -19,8 +17,7 @@ import misk.web.mediatype.MediaTypes @Singleton class BackfillCreateIndexAction @Inject constructor( - private val serviceAutocompleteAction: ServiceAutocompleteAction, - private val getBackfillStatusAction: GetBackfillStatusAction, + private val servicesGetter: ServiceDataHelper, private val dashboardPageLayout: DashboardPageLayout, ) : WebAction { @Get(PATH) @@ -33,9 +30,9 @@ class BackfillCreateIndexAction @Inject constructor( PageTitle("Create Backfill") // If service + variant is blank, show service selection - val services: Map = serviceAutocompleteAction.getFlattenedServices() + val services: Map = servicesGetter.getFlattenedServices() ServiceSelect(services) { service, variant -> - BackfillCreateAction.PATH.replace("{service}", service).replace("{variantOrBlank}", variant ?: "") + BackfillCreateServiceIndexAction.PATH.replace("{service}", service).replace("{variantOrBlank}", variant ?: "") } } diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateServiceIndexAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateServiceIndexAction.kt new file mode 100644 index 000000000..5e4567661 --- /dev/null +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillCreateServiceIndexAction.kt @@ -0,0 +1,188 @@ +package app.cash.backfila.ui.pages + +import app.cash.backfila.dashboard.GetRegisteredBackfillsAction +import app.cash.backfila.ui.components.AlertError +import app.cash.backfila.ui.components.DashboardPageLayout +import app.cash.backfila.ui.components.PageTitle +import java.net.HttpURLConnection +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.html.a +import kotlinx.html.div +import kotlinx.html.h3 +import kotlinx.html.li +import kotlinx.html.role +import kotlinx.html.span +import kotlinx.html.ul +import misk.security.authz.Authenticated +import misk.tailwind.Link +import misk.web.Get +import misk.web.PathParam +import misk.web.Response +import misk.web.ResponseBody +import misk.web.ResponseContentType +import misk.web.actions.WebAction +import misk.web.mediatype.MediaTypes +import misk.web.toResponseBody +import okhttp3.Headers + +@Singleton +class BackfillCreateServiceIndexAction @Inject constructor( + private val backfillCreateAction: BackfillCreateAction, + private val getRegisteredBackfillsAction: GetRegisteredBackfillsAction, + private val dashboardPageLayout: DashboardPageLayout, +) : WebAction { + @Get(PATH) + @ResponseContentType(MediaTypes.TEXT_HTML) + @Authenticated(capabilities = ["users"]) + fun get( + @PathParam service: String, + @PathParam variantOrBlank: String? = "", + ): Response { + if (variantOrBlank.orEmpty().contains(".") || variantOrBlank.orEmpty().toIntOrNull() != null) { + // This means variant is default and the value provided is the backfill name or backfill ID to clone, redirect accordingly + val newPath = BackfillCreateAction.PATH + .replace("{service}", service) + .replace("{variantOrBackfillNameOrId}", variantOrBlank.orEmpty()) + .replace("{backfillNameOrId}", "") + return Response( + body = "go to $newPath".toResponseBody(), + statusCode = HttpURLConnection.HTTP_MOVED_TEMP, + headers = Headers.headersOf("Location", newPath), + ) + } + + val variant = variantOrBlank.orEmpty().ifBlank { "default" } + val label = if (variant == "default") service else "$service ($variant)" + val htmlResponseBody = dashboardPageLayout.newBuilder() + .title("Create Backfill | Backfila") + .breadcrumbLinks( + Link("Services", ServiceIndexAction.PATH), + Link( + label, + ServiceShowAction.PATH + .replace("{service}", service) + .replace("{variantOrBlank}", variantOrBlank ?: ""), + ), + Link( + "Create", + PATH + .replace("{service}", service) + .replace("{variantOrBlank}", variantOrBlank ?: ""), + ), + ) + .buildHtmlResponseBody { + val registeredBackfills = getRegisteredBackfillsAction.backfills(service, variant) + + PageTitle("Create Backfill") { + span("inline-flex shrink-0 items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-s font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20") { + val suffix = if (variantOrBlank.isNullOrBlank()) "" else "/$variantOrBlank" + +"$service$suffix" + } + } + + if (registeredBackfills.backfills.isEmpty()) { + // TODO add link to runbook? + AlertError("No backfills registered for this service. Check docs for how to register backfills.") + } else { + // If service + variant is set and valid, show registered backfills drop down + div("py-10") { + ul("grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3") { + role = "list" + + registeredBackfills.backfills.map { + a { + val variantOrBackfillNameOrId = variantOrBlank.orEmpty().ifBlank { it.name } + href = BackfillCreateAction.PATH + .replace("{service}", service) + .replace("{variantOrBackfillNameOrId}", variantOrBackfillNameOrId) + .replace("{backfillNameOrId}", if (variantOrBackfillNameOrId == it.name) "" else it.name) + + // TODO make full width + this@ul.li("registration col-span-1 divide-y divide-gray-200 rounded-lg bg-white shadow") { + div("flex w-full items-center justify-between space-x-6 p-6") { + div("flex-1 truncate") { + div("flex items-center space-x-3") { + // Don't include default variant in label, only for unique variants +// val label = if (variant == "default") service else "$service/$variant" + h3("truncate text-sm font-medium text-gray-900") { + +it.name + } +// variant?.let { span("inline-flex shrink-0 items-center rounded-full bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20") { +it } } + } + // p("mt-1 truncate text-sm text-gray-500") { +"""Regional Paradigm Technician""" } + } + } + } + // Buttons +// div { +// div("-mt-px flex divide-x divide-gray-200") { +// div("flex w-0 flex-1") { +// a(classes = "relative -mr-px inline-flex w-0 flex-1 items-center justify-center gap-x-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-gray-900") { +// href = "mailto:janecooper@example.com" +// // svg("size-5 text-gray-400") { +// // viewbox = "0 0 20 20" +// // fill = "currentColor" +// // attributes["aria-hidden"] = "true" +// // attributes["data-slot"] = "icon" +// // path { +// // d = +// // "M3 4a2 2 0 0 0-2 2v1.161l8.441 4.221a1.25 1.25 0 0 0 1.118 0L19 7.162V6a2 2 0 0 0-2-2H3Z" +// // } +// // path { +// // d = +// // "m19 8.839-7.77 3.885a2.75 2.75 0 0 1-2.46 0L1 8.839V14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.839Z" +// // } +// // } +// +"""Email""" +// } +// } +// div("-ml-px flex w-0 flex-1") { +// a(classes = "relative inline-flex w-0 flex-1 items-center justify-center gap-x-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-gray-900") { +// href = "tel:+1-202-555-0170" +// // svg("size-5 text-gray-400") { +// // viewbox = "0 0 20 20" +// // fill = "currentColor" +// // attributes["aria-hidden"] = "true" +// // attributes["data-slot"] = "icon" +// // path { +// // attributes["fill-rule"] = "evenodd" +// // d = +// // "M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" +// // attributes["clip-rule"] = "evenodd" +// // } +// // } +// +"""Call""" +// } +// } +// } +// } + } + } + } + } + } + } + + return Response(htmlResponseBody) + } + + enum class BackfillCreateField(val fieldId: String) { + SERVICE("service"), + VARIANT("variant"), + BACKFILL_NAME("backfillName"), + DRY_RUN("dryRun"), + RANGE_START("rangeStart"), + RANGE_END("rangeEnd"), + BATCH_SIZE("batchSize"), + SCAN_SIZE("scanSize"), + THREADS_PER_PARTITION("threadsPerPartition"), + EXTRA_SLEEP_MS("extraSleepMs"), + BACKOFF_SCHEDULE("backoffSchedule"), + CUSTOM_PARAMETER_PREFIX("customParameter_"), + } + + companion object { + const val PATH = "/backfills/create/{service}/{variantOrBlank}" + } +} 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 38f77d06e..1fdbacceb 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 @@ -51,21 +51,28 @@ class BackfillShowAction @Inject constructor( @PathParam id: String, ): Response { val backfill = getBackfillStatusAction.status(id.toLong()) - val label = if (backfill.variant == "default") backfill.service_name else "${backfill.service_name} (${backfill.variant})" + val label = + if (backfill.variant == "default") backfill.service_name else "${backfill.service_name} (${backfill.variant})" val htmlResponseBody = dashboardPageLayout.newBuilder() .title("Backfill $id | Backfila") .breadcrumbLinks( - listOf( - Link(label, ServiceShowAction.PATH.replace("{service}", backfill.service_name).replace("{variantOrBlank}", if (backfill.variant != "default") backfill.variant else "")), - Link("Backfill $id", PATH.replace("{id}", id)), + Link("Services", ServiceIndexAction.PATH), + Link( + label, + ServiceShowAction.PATH.replace("{service}", backfill.service_name) + .replace("{variantOrBlank}", if (backfill.variant != "default") backfill.variant else ""), ), + Link("Backfill #$id", PATH.replace("{id}", id)), ) .buildHtmlResponseBody { AutoReload { PageTitle("Backfill", id) { a { - href = BackfillCreateAction.PATH.replace("{service}", backfill.service_name).replace("{variantOrBlank}", backfill.variant) + "?backfillIdToClone=$id" + href = BackfillCreateAction.PATH + .replace("{service}", backfill.service_name) + .replace("{variantOrBackfillNameOrId}", if (backfill.variant != "default") backfill.variant else id) + .replace("{backfillNameOrId}", if (backfill.variant != "default") id else "") button(classes = "rounded-full bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600") { type = ButtonType.button diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/IndexAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/IndexAction.kt index 6c6b70d86..67f6cfd15 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/IndexAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/IndexAction.kt @@ -1,7 +1,7 @@ package app.cash.backfila.ui.pages import app.cash.backfila.service.BackfilaConfig -import app.cash.backfila.ui.actions.ServiceAutocompleteAction +import app.cash.backfila.ui.actions.ServiceDataHelper import app.cash.backfila.ui.components.DashboardPageLayout import javax.inject.Inject import kotlinx.html.dd @@ -22,7 +22,7 @@ import misk.web.mediatype.MediaTypes class IndexAction @Inject constructor( private val config: BackfilaConfig, - private val serviceAutocompleteAction: ServiceAutocompleteAction, + private val serviceDataHelper: ServiceDataHelper, private val dashboardPageLayout: DashboardPageLayout, private val callerProvider: ActionScoped, ) : WebAction { diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceIndexAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceIndexAction.kt index 44e91e512..4b4459047 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceIndexAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceIndexAction.kt @@ -1,7 +1,7 @@ package app.cash.backfila.ui.pages import app.cash.backfila.dashboard.GetServicesAction -import app.cash.backfila.ui.actions.ServiceAutocompleteAction +import app.cash.backfila.ui.actions.ServiceDataHelper import app.cash.backfila.ui.components.DashboardPageLayout import app.cash.backfila.ui.components.PageTitle import app.cash.backfila.ui.components.ServiceSelect @@ -13,7 +13,7 @@ import misk.web.actions.WebAction import misk.web.mediatype.MediaTypes class ServiceIndexAction @Inject constructor( - private val serviceAutocompleteAction: ServiceAutocompleteAction, + private val serviceDataHelper: ServiceDataHelper, private val dashboardPageLayout: DashboardPageLayout, ) : WebAction { @Get(PATH) @@ -26,7 +26,7 @@ class ServiceIndexAction @Inject constructor( PageTitle("Services") // Search and select from Services - val services: Map = serviceAutocompleteAction.getFlattenedServices() + val services: Map = serviceDataHelper.getFlattenedServices() ServiceSelect(services) { service, variant -> ServiceShowAction.PATH.replace("{service}", service).replace("{variantOrBlank}", variant ?: "") } diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceShowAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceShowAction.kt index 6bca186c1..79c0b7948 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceShowAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/ServiceShowAction.kt @@ -60,16 +60,14 @@ class ServiceShowAction @Inject constructor( val htmlResponseBody = dashboardPageLayout.newBuilder() .title("$label | Backfila") .breadcrumbLinks( - listOf( - Link("Services", ServiceIndexAction.PATH), - Link(label, path), - ), + Link("Services", ServiceIndexAction.PATH), + Link(label, path), ) .buildHtmlResponseBody { AutoReload { PageTitle("Service", label) { a { - href = BackfillCreateAction.PATH.replace("{service}", service) + href = BackfillCreateServiceIndexAction.PATH.replace("{service}", service) .replace("{variantOrBlank}", variantOrBlank ?: "") button(classes = "rounded-full bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600") {