Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Flop filtering, sorting, and pagination to deployments index

Derive Flop.Schema on Deployment with filterable state/result/garden_name
(via join_fields), sortable state/result/deployed_at/inserted_at. Add
list_flop/1 with garden join. Switch DeploymentLive.Index from mount-based
streaming to handle_params with Flop. Add filter form (garden name search,
state select, result select), sortable column headers, and styled pagination.
Handle Ecto.Enum atom-to-string conversion for filter value retention.

SOW-111

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+185 -61
+28
apps/sower/lib/sower/orchestration/deployment.ex
··· 16 16 @derive {Jason.Encoder, only: [:sid]} 17 17 @derive {Phoenix.Param, key: :sid} 18 18 19 + @derive { 20 + Flop.Schema, 21 + filterable: [:state, :result, :garden_name], 22 + sortable: [:state, :result, :deployed_at, :inserted_at], 23 + default_limit: 20, 24 + default_order: %{ 25 + order_by: [:inserted_at], 26 + order_directions: [:desc] 27 + }, 28 + adapter_opts: [ 29 + join_fields: [ 30 + garden_name: [binding: :garden, field: :name, ecto_type: :string] 31 + ] 32 + ] 33 + } 34 + 19 35 schema "deployments" do 20 36 field :sid, SowerClient.Sid, autogenerate: true 21 37 field :org_id, Ecto.UUID ··· 79 95 ) 80 96 81 97 Repo.all(query) 98 + end 99 + 100 + def list_flop(params \\ %{}) do 101 + query = from(d in __MODULE__, join: g in assoc(d, :garden), as: :garden) 102 + 103 + case Flop.validate_and_run(query, params, for: __MODULE__) do 104 + {:ok, {deployments, meta}} -> 105 + {:ok, {Repo.preload(deployments, [:garden]), meta}} 106 + 107 + {:error, meta} -> 108 + {:error, meta} 109 + end 82 110 end 83 111 84 112 def list_deployments(%Garden{} = garden, opts \\ []) do
+66 -61
apps/sower/lib/sower_web/live/deployment_live/index.ex
··· 2 2 use SowerWeb, :live_view 3 3 4 4 alias Sower.Orchestration 5 - alias SowerWeb.Layouts 6 - 7 - @impl true 8 - def render(assigns) do 9 - ~H""" 10 - <Layouts.app flash={@flash} current_user={@current_user}> 11 - <.header> 12 - Listing Deployments 13 - </.header> 14 - 15 - <.table 16 - id="deployments" 17 - rows={@streams.deployments} 18 - row_click={fn {_id, deployment} -> JS.navigate(~p"/deployments/#{deployment}") end} 19 - > 20 - <:col :let={{_id, deployment}} label=""> 21 - <.deployment_status state={deployment.state} result={deployment.result} /> 22 - </:col> 23 - <:col :let={{_id, deployment}} label="sid"> 24 - <span 25 - class="sm:hidden truncate max-w-[8rem] inline-block align-bottom" 26 - title={deployment.sid} 27 - > 28 - {deployment.sid} 29 - </span> 30 - <span class="hidden sm:inline">{deployment.sid}</span> 31 - </:col> 32 - <:col :let={{_id, deployment}} label="garden"> 33 - {get_in(deployment.garden.name) || "-"} 34 - </:col> 35 - <:col :let={{_id, deployment}} label="completed" hide_on={:sm}> 36 - <.local_datetime datetime={deployment.deployed_at} user_timezone={@user_timezone} /> 37 - </:col> 38 - <:action :let={{_id, deployment}}> 39 - <.button 40 - :if={retryable?(deployment)} 41 - variant={:secondary} 42 - type="button" 43 - phx-click="retry" 44 - phx-value-sid={deployment.sid} 45 - phx-stop-propagation 46 - phx-disable-with="Retrying..." 47 - class="hidden sm:inline" 48 - disabled={@retrying_deployment_sid == deployment.sid} 49 - > 50 - Retry 51 - </.button> 52 - </:action> 53 - </.table> 54 - </Layouts.app> 55 - """ 56 - end 5 + alias Sower.Orchestration.Deployment 57 6 58 - @impl true 7 + @impl Phoenix.LiveView 59 8 def mount(_params, _session, socket) do 60 9 if connected?(socket) do 61 10 Phoenix.PubSub.subscribe(Sower.PubSub, "deployments") ··· 64 13 {:ok, 65 14 socket 66 15 |> assign(:page_title, "Listing Deployments") 67 - |> assign(:retrying_deployment_sid, nil) 68 - |> stream(:deployments, Orchestration.list_deployments() |> Sower.Repo.preload([:garden]))} 16 + |> assign(:retrying_deployment_sid, nil)} 17 + end 18 + 19 + @impl Phoenix.LiveView 20 + def handle_params(params, _uri, socket) do 21 + case Deployment.list_flop(params) do 22 + {:ok, {deployments, meta}} -> 23 + {:noreply, assign(socket, deployments: deployments, meta: meta)} 24 + 25 + {:error, meta} -> 26 + {:noreply, assign(socket, deployments: [], meta: meta)} 27 + end 69 28 end 70 29 71 30 @impl Phoenix.LiveView 72 31 def handle_info({:deployment, :created, deployment}, socket) do 73 32 deployment = Sower.Repo.preload(deployment, [:garden]) 74 - 75 - # Insert new deployment at the top of the stream 76 - {:noreply, stream_insert(socket, :deployments, deployment, at: 0)} 33 + deployments = [deployment | socket.assigns.deployments] 34 + {:noreply, assign(socket, :deployments, deployments)} 77 35 end 78 36 79 37 def handle_info({:deployment, :updated, deployment}, socket) do 80 38 deployment = Sower.Repo.preload(deployment, [:garden]) 81 39 82 - # Update existing deployment in the stream 83 - {:noreply, stream_insert(socket, :deployments, deployment)} 40 + deployments = 41 + Enum.map(socket.assigns.deployments, fn d -> 42 + if d.id == deployment.id, do: deployment, else: d 43 + end) 44 + 45 + {:noreply, assign(socket, :deployments, deployments)} 46 + end 47 + 48 + @impl Phoenix.LiveView 49 + def handle_event("filter", params, socket) do 50 + filters = 51 + [] 52 + |> maybe_add_filter(:garden_name, :ilike_and, params["garden_name"]) 53 + |> maybe_add_filter(:state, :==, params["state"]) 54 + |> maybe_add_filter(:result, :==, params["result"]) 55 + 56 + flop = %Flop{filters: filters} 57 + path = Flop.Phoenix.build_path(~p"/deployments", flop) 58 + 59 + {:noreply, push_patch(socket, to: path)} 84 60 end 85 61 86 - @impl true 87 62 def handle_event("retry", %{"sid" => sid}, socket) do 88 63 deployment = Orchestration.get_deployment_sid(sid) 89 64 ··· 124 99 125 100 defp retryable?(deployment) do 126 101 deployment.state in [:completed, :stale] 102 + end 103 + 104 + defp maybe_add_filter(filters, _field, _op, nil), do: filters 105 + defp maybe_add_filter(filters, _field, _op, ""), do: filters 106 + 107 + defp maybe_add_filter(filters, field, op, value) do 108 + filters ++ [%Flop.Filter{field: field, op: op, value: value}] 109 + end 110 + 111 + defp filter_value(%Flop.Meta{flop: %Flop{filters: filters}}, field) do 112 + case Enum.find(filters, &(&1.field == field)) do 113 + %Flop.Filter{value: value} when is_atom(value) and not is_nil(value) -> 114 + Atom.to_string(value) 115 + 116 + %Flop.Filter{value: value} -> 117 + value 118 + 119 + nil -> 120 + nil 121 + end 122 + end 123 + 124 + defp filter_value(_meta, _field), do: nil 125 + 126 + defp state_options do 127 + ["created", "dispatched", "acknowledged", "completed", "stale"] 128 + end 129 + 130 + defp result_options do 131 + ["success", "failure", "partial"] 127 132 end 128 133 end
+91
apps/sower/lib/sower_web/live/deployment_live/index.html.heex
··· 1 + <Layouts.app flash={@flash} current_user={@current_user}> 2 + <.header> 3 + Listing Deployments 4 + </.header> 5 + 6 + <form phx-change="filter" class="mt-6 flex gap-4 items-end"> 7 + <div class="flex-1"> 8 + <label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">Garden</label> 9 + <input 10 + type="text" 11 + name="garden_name" 12 + value={filter_value(@meta, :garden_name)} 13 + placeholder="Search by garden..." 14 + phx-debounce="300" 15 + class="mt-1 block w-full rounded-md border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" 16 + /> 17 + </div> 18 + <div> 19 + <label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">State</label> 20 + <select 21 + name="state" 22 + class="mt-1 block w-full rounded-md border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" 23 + > 24 + <option value="">All states</option> 25 + <option :for={s <- state_options()} value={s} selected={filter_value(@meta, :state) == s}> 26 + {s} 27 + </option> 28 + </select> 29 + </div> 30 + <div> 31 + <label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">Result</label> 32 + <select 33 + name="result" 34 + class="mt-1 block w-full rounded-md border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" 35 + > 36 + <option value="">All results</option> 37 + <option 38 + :for={r <- result_options()} 39 + value={r} 40 + selected={filter_value(@meta, :result) == r} 41 + > 42 + {r} 43 + </option> 44 + </select> 45 + </div> 46 + </form> 47 + 48 + <.table 49 + id="deployments" 50 + rows={@deployments} 51 + meta={@meta} 52 + path={~p"/deployments"} 53 + row_click={fn deployment -> JS.navigate(~p"/deployments/#{deployment}") end} 54 + > 55 + <:col :let={deployment} label=""> 56 + <.deployment_status state={deployment.state} result={deployment.result} /> 57 + </:col> 58 + <:col :let={deployment} label="sid"> 59 + <span 60 + class="sm:hidden truncate max-w-[8rem] inline-block align-bottom" 61 + title={deployment.sid} 62 + > 63 + {deployment.sid} 64 + </span> 65 + <span class="hidden sm:inline">{deployment.sid}</span> 66 + </:col> 67 + <:col :let={deployment} label="garden" field={:garden_name}> 68 + {get_in(deployment.garden.name) || "-"} 69 + </:col> 70 + <:col :let={deployment} label="completed" field={:deployed_at} hide_on={:sm}> 71 + <.local_datetime datetime={deployment.deployed_at} user_timezone={@user_timezone} /> 72 + </:col> 73 + <:action :let={deployment}> 74 + <.button 75 + :if={retryable?(deployment)} 76 + variant={:secondary} 77 + type="button" 78 + phx-click="retry" 79 + phx-value-sid={deployment.sid} 80 + phx-stop-propagation 81 + phx-disable-with="Retrying..." 82 + class="hidden sm:inline" 83 + disabled={@retrying_deployment_sid == deployment.sid} 84 + > 85 + Retry 86 + </.button> 87 + </:action> 88 + </.table> 89 + 90 + <.pagination meta={@meta} path={~p"/deployments"} /> 91 + </Layouts.app>