Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: unified seed view on subscription show page

Replace separate "Matching Seeds" and "Deployments" sections with a
single seed-centric table showing generation info, deployment status,
and active/pending indicators. Adds Flop pagination (10 per page).

SOW-1

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

+262 -61
+6
apps/sower/lib/sower/orchestration.ex
··· 6 6 alias Sower.Orchestration.Garden 7 7 alias Sower.Orchestration.GardenSeedGeneration 8 8 alias Sower.Orchestration.Deployment 9 + alias Sower.Orchestration.Seed 9 10 alias Sower.Orchestration.Subscription 10 11 11 12 # Garden delegates ··· 54 55 defdelegate list_deployments(), to: Deployment 55 56 defdelegate list_deployments(garden, opts \\ []), to: Deployment 56 57 defdelegate list_matching_seeds(subscription, limit \\ 10), to: Deployment 58 + 59 + defdelegate list_matching_seeds_enriched(subscription, garden_id, params), 60 + to: Seed, 61 + as: :list_matching_enriched 62 + 57 63 defdelegate list_unresolved_deployments_for_garden(garden, opts \\ []), to: Deployment 58 64 defdelegate match_seed(subscription), to: Deployment 59 65 defdelegate process_deployment(request_id, subscriptions, garden, opts \\ []), to: Deployment
+144 -1
apps/sower/lib/sower/orchestration/seed.ex
··· 5 5 import Ecto.Query, only: [from: 2] 6 6 7 7 alias Sower.Repo 8 - alias Sower.Orchestration.{Seed, SeedTag} 8 + 9 + alias Sower.Orchestration.{ 10 + Deployment, 11 + GardenSeedGeneration, 12 + Seed, 13 + SeedDeployment, 14 + SeedTag, 15 + Subscription, 16 + SubscriptionDeployment 17 + } 18 + 9 19 alias Ecto.Multi 10 20 11 21 @derive {Jason.Encoder, only: [:sid, :name, :seed_type, :artifact, :tags]} ··· 34 44 field :artifact, :string 35 45 36 46 has_many :tags, SeedTag 47 + 48 + field :generation_number, :integer, virtual: true 49 + field :is_current, :boolean, virtual: true, default: false 50 + 51 + field :latest_deployment_state, Ecto.Enum, 52 + values: [:created, :dispatched, :acknowledged, :completed, :stale, :canceled], 53 + virtual: true 54 + 55 + field :latest_deployment_result, Ecto.Enum, 56 + values: [:success, :failure, :partial], 57 + virtual: true 58 + 59 + field :latest_deployment_sid, :string, virtual: true 60 + field :latest_deployment_at, :utc_datetime, virtual: true 61 + field :is_pending, :boolean, virtual: true, default: false 37 62 38 63 timestamps() 39 64 end ··· 166 191 167 192 {:error, meta} -> 168 193 {:error, meta} 194 + end 195 + end 196 + 197 + @doc """ 198 + List matching seeds enriched with generation and deployment info for a subscription. 199 + 200 + Returns seeds matching the subscription's name/type/rules, enriched with: 201 + - generation_number and is_current from garden_seed_generations 202 + - latest deployment state/result from this subscription's deployments 203 + - is_pending flag (no successful deployment, or seed updated after last deployment) 204 + 205 + Paginated via Flop with a default limit of 10. 206 + """ 207 + def list_matching_enriched(%Subscription{} = subscription, garden_id, params) do 208 + tags = 209 + Enum.map(subscription.rules || [], fn rule -> 210 + %{key: rule.key, value: rule.value} 211 + end) 212 + 213 + base_query = 214 + from(s in Seed, 215 + as: :seed, 216 + where: s.name == ^subscription.seed_name and s.seed_type == ^subscription.seed_type 217 + ) 218 + 219 + base_query = 220 + Enum.reduce(tags, base_query, fn %{key: key, value: value}, query -> 221 + from(s in query, 222 + where: 223 + exists( 224 + from(st in SeedTag, 225 + where: st.seed_id == parent_as(:seed).id, 226 + where: st.key == ^key and st.value == ^value 227 + ) 228 + ) 229 + ) 230 + end) 231 + 232 + gen_query = 233 + from(g in GardenSeedGeneration, 234 + where: g.garden_id == ^garden_id and g.seed_id == parent_as(:seed).id, 235 + select: %{generation_number: g.generation_number, is_current: g.is_current}, 236 + limit: 1 237 + ) 238 + 239 + deploy_query = 240 + from(d in Deployment, 241 + join: sd in SeedDeployment, 242 + on: sd.deployment_id == d.id, 243 + join: sub_d in SubscriptionDeployment, 244 + on: sub_d.deployment_id == d.id, 245 + where: sd.seed_id == parent_as(:seed).id, 246 + where: sub_d.subscription_id == ^subscription.id, 247 + order_by: [desc: d.inserted_at], 248 + limit: 1, 249 + select: %{sid: d.sid, state: d.state, result: d.result, deployed_at: d.deployed_at} 250 + ) 251 + 252 + last_successful_deploy_query = 253 + from(d in Deployment, 254 + join: sub_d in SubscriptionDeployment, 255 + on: sub_d.deployment_id == d.id, 256 + where: sub_d.subscription_id == ^subscription.id, 257 + where: d.result == :success and not is_nil(d.deployed_at), 258 + order_by: [desc: d.deployed_at], 259 + limit: 1, 260 + select: d.deployed_at 261 + ) 262 + 263 + last_successful_at = Repo.one(last_successful_deploy_query, skip_org_id: true) 264 + 265 + query = 266 + from(s in base_query, 267 + left_lateral_join: g in subquery(gen_query), 268 + on: true, 269 + left_lateral_join: ld in subquery(deploy_query), 270 + on: true, 271 + select_merge: %{ 272 + generation_number: g.generation_number, 273 + is_current: coalesce(g.is_current, false), 274 + latest_deployment_sid: ld.sid, 275 + latest_deployment_state: ld.state, 276 + latest_deployment_result: ld.result, 277 + latest_deployment_at: ld.deployed_at, 278 + is_pending: false 279 + } 280 + ) 281 + 282 + case Flop.validate_and_run(query, params, for: Seed, default_limit: 10) do 283 + {:ok, {seeds, meta}} -> 284 + seeds = 285 + seeds 286 + |> Repo.preload([:tags]) 287 + |> mark_newest_pending(last_successful_at) 288 + 289 + {:ok, {seeds, meta}} 290 + 291 + {:error, meta} -> 292 + {:error, meta} 293 + end 294 + end 295 + 296 + defp mark_newest_pending(seeds, nil), do: seeds 297 + 298 + defp mark_newest_pending(seeds, last_successful_at) do 299 + newest = 300 + seeds 301 + |> Enum.filter(fn seed -> DateTime.compare(seed.updated_at, last_successful_at) == :gt end) 302 + |> Enum.max_by(& &1.updated_at, DateTime, fn -> nil end) 303 + 304 + case newest do 305 + nil -> 306 + seeds 307 + 308 + %Seed{id: pending_id} -> 309 + Enum.map(seeds, fn seed -> 310 + if seed.id == pending_id, do: %{seed | is_pending: true}, else: seed 311 + end) 169 312 end 170 313 end 171 314
+36
apps/sower/lib/sower_web/components/sower_components.ex
··· 337 337 """ 338 338 end 339 339 340 + attr :is_current, :boolean, required: true 341 + attr :is_pending, :boolean, required: true 342 + attr :latest_deployment_result, :atom, default: nil 343 + 344 + def seed_subscription_status(assigns) do 345 + ~H""" 346 + <%= cond do %> 347 + <% @is_current -> %> 348 + <span class="inline-flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"> 349 + <span class="relative flex h-2.5 w-2.5" role="img" aria-label="Active"> 350 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" /> 351 + </span> 352 + Active 353 + </span> 354 + <% @is_pending -> %> 355 + <span class="inline-flex items-center gap-1.5 text-sm text-amber-600 dark:text-amber-400"> 356 + <span class="relative flex h-2.5 w-2.5" role="img" aria-label="Pending"> 357 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500" /> 358 + </span> 359 + Pending 360 + </span> 361 + <% @latest_deployment_result == :success -> %> 362 + <span class="inline-flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400"> 363 + <span class="relative flex h-2.5 w-2.5" role="img" aria-label="Deployed"> 364 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-zinc-400" /> 365 + </span> 366 + Deployed 367 + </span> 368 + <% true -> %> 369 + <span class="inline-flex items-center gap-1.5 text-sm text-zinc-400 dark:text-zinc-500"> 370 + 371 + </span> 372 + <% end %> 373 + """ 374 + end 375 + 340 376 attr :state, :atom, required: true 341 377 attr :result, :atom, default: nil 342 378 attr :compact, :boolean, default: false
+33 -26
apps/sower/lib/sower_web/live/subscription_live/show.ex
··· 3 3 4 4 alias Sower.Orchestration 5 5 6 - @impl true 6 + @impl Phoenix.LiveView 7 7 def mount(_params, _session, socket) do 8 8 {:ok, socket} 9 9 end 10 10 11 - @impl true 12 - def handle_params(%{"garden_sid" => garden_sid, "sid" => sid}, _, socket) do 11 + @impl Phoenix.LiveView 12 + def handle_params(%{"garden_sid" => garden_sid, "sid" => sid} = params, _, socket) do 13 13 garden = Orchestration.get_garden_sid!(garden_sid) 14 14 15 - case Orchestration.get_subscription_sid_with_deployments(sid) do 15 + case Orchestration.get_subscription_sid(sid) do 16 16 nil -> 17 17 {:noreply, 18 18 socket ··· 20 20 |> redirect(to: ~p"/gardens/#{garden}/subscriptions")} 21 21 22 22 subscription -> 23 - matching_seeds = Orchestration.list_matching_seeds(subscription, 5) 23 + subscription = Sower.Repo.preload(subscription, :garden) 24 + flop_params = Map.take(params, ["page", "page_size", "order_by", "order_directions"]) 24 25 25 - # TODO find the generations in the current visible seed list 26 - # matching_generations = matching_seeds |> Enum.map( 26 + {seeds, meta} = load_seeds(subscription, garden, flop_params) 27 27 28 28 if connected?(socket) do 29 29 Phoenix.PubSub.subscribe(Sower.PubSub, "deployments:subscription:#{sid}") ··· 34 34 |> assign(:garden, garden) 35 35 |> assign(:page_title, page_title(socket.assigns.live_action)) 36 36 |> assign(:subscription, subscription) 37 - |> assign(:matching_seeds, matching_seeds) 38 - |> assign(:deployable, matching_seeds != []) 37 + |> assign(:seeds, seeds) 38 + |> assign(:meta, meta) 39 + |> assign(:flop_params, flop_params) 40 + |> assign(:deployable, seeds != []) 39 41 |> assign(:deploying, false) 40 42 |> assign(:deploy_error, nil)} 41 43 end 42 44 end 43 45 44 - @impl true 46 + @impl Phoenix.LiveView 45 47 def handle_event("deploy_subscription", %{"subscription_sid" => _sub_sid}, socket) do 46 48 socket = assign(socket, deploying: true, deploy_error: nil) 47 49 ··· 62 64 |> assign(:deploying, false) 63 65 |> redirect(to: ~p"/deployments/#{deployment.sid}")} 64 66 else 65 - subscription = 66 - Orchestration.get_subscription_sid_with_deployments!(socket.assigns.subscription.sid) 67 - 68 - matching_seeds = Orchestration.list_matching_seeds(subscription, 5) 69 - 70 - {:noreply, 71 - socket 72 - |> assign(:subscription, subscription) 73 - |> assign(:matching_seeds, matching_seeds)} 67 + {:noreply, refresh_seeds(socket)} 74 68 end 75 69 end 76 70 77 71 @impl Phoenix.LiveView 78 72 def handle_info({:deployment, _event, _deployment}, socket) do 79 - subscription = 80 - Orchestration.get_subscription_sid_with_deployments!(socket.assigns.subscription.sid) 73 + {:noreply, refresh_seeds(socket)} 74 + end 75 + 76 + defp load_seeds(%Orchestration.Subscription{} = subscription, garden, flop_params) do 77 + case Orchestration.list_matching_seeds_enriched(subscription, garden.id, flop_params) do 78 + {:ok, {seeds, meta}} -> {seeds, meta} 79 + {:error, meta} -> {[], meta} 80 + end 81 + end 81 82 82 - matching_seeds = Orchestration.list_matching_seeds(subscription, 5) 83 + defp refresh_seeds(socket) do 84 + {seeds, meta} = 85 + load_seeds( 86 + socket.assigns.subscription, 87 + socket.assigns.garden, 88 + socket.assigns.flop_params 89 + ) 83 90 84 - {:noreply, 85 - socket 86 - |> assign(:subscription, subscription) 87 - |> assign(:matching_seeds, matching_seeds)} 91 + socket 92 + |> assign(:seeds, seeds) 93 + |> assign(:meta, meta) 94 + |> assign(:deployable, seeds != []) 88 95 end 89 96 90 97 defp page_title(:show), do: "Show Subscription"
+43 -34
apps/sower/lib/sower_web/live/subscription_live/show.html.heex
··· 76 76 </p> 77 77 </section> 78 78 79 - <section> 80 - <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4">Matching Seeds</h2> 79 + <section class="-mx-4 sm:mx-0"> 80 + <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4 px-4 sm:px-0"> 81 + Seeds 82 + </h2> 81 83 <.table 82 - id="matching-seeds" 83 - rows={@matching_seeds} 84 + id="subscription-seeds" 85 + rows={@seeds} 86 + meta={@meta} 87 + path={~p"/gardens/#{@garden}/subscriptions/#{@subscription}"} 84 88 row_click={fn seed -> JS.navigate(~p"/seeds/#{seed}") end} 85 89 > 86 - <:col :let={seed} label="Name">{seed.name}</:col> 87 - <:col :let={seed} label="Type">{seed.seed_type}</:col> 88 - <:col :let={seed} label="Tags"> 89 - {Enum.sort(seed.tags) |> Enum.map_join(", ", fn tag -> "#{tag.key}=#{tag.value}" end)} 90 + <:col :let={seed} label="Artifact"> 91 + <span 92 + class="font-mono text-xs truncate inline-block max-w-[250px] sm:max-w-lg align-middle" 93 + title={seed.artifact} 94 + > 95 + {seed.artifact} 96 + </span> 97 + </:col> 98 + <:col :let={seed} label="Status"> 99 + <.seed_subscription_status 100 + is_current={seed.is_current} 101 + is_pending={seed.is_pending} 102 + latest_deployment_result={seed.latest_deployment_result} 103 + /> 90 104 </:col> 91 - <:col :let={seed} label="Created"> 92 - <.local_datetime datetime={seed.inserted_at} user_timezone={@user_timezone} /> 105 + <:col :let={seed} label="Gen" hide_on={:sm}> 106 + {seed.generation_number && "##{seed.generation_number}"} 93 107 </:col> 94 - </.table> 95 - <p 96 - :if={@matching_seeds == []} 97 - class="text-sm text-zinc-500 dark:text-zinc-400 italic" 98 - > 99 - No matching seeds found. 100 - </p> 101 - </section> 102 - 103 - <section> 104 - <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4">Deployments</h2> 105 - <.table 106 - id="deployments" 107 - rows={@subscription.deployments} 108 - row_click={fn deployment -> JS.navigate(~p"/deployments/#{deployment.sid}") end} 109 - > 110 - <:col :let={deployment} label="Result"> 111 - <.result result={deployment.result} /> 108 + <:col :let={seed} label="Deployment" hide_on={:sm}> 109 + <.link 110 + :if={seed.latest_deployment_sid} 111 + navigate={~p"/deployments/#{seed.latest_deployment_sid}"} 112 + class="inline-flex items-center gap-1.5 align-middle hover:text-orange-500 dark:hover:text-orange-400" 113 + > 114 + <.result result={seed.latest_deployment_result} /> 115 + <span class="font-mono text-xs">{seed.latest_deployment_sid}</span> 116 + </.link> 112 117 </:col> 113 - <:col :let={deployment} label="SID">{deployment.sid}</:col> 114 - <:col :let={deployment} label="Created"> 115 - <.local_datetime datetime={deployment.inserted_at} user_timezone={@user_timezone} /> 118 + <:col :let={seed} label="Updated" field={:updated_at} hide_on={:md}> 119 + <.local_datetime datetime={seed.updated_at} user_timezone={@user_timezone} /> 116 120 </:col> 117 121 </.table> 118 122 <p 119 - :if={@subscription.deployments == []} 120 - class="text-sm text-zinc-500 dark:text-zinc-400 italic" 123 + :if={@seeds == []} 124 + class="text-sm text-zinc-500 dark:text-zinc-400 italic px-4 sm:px-0" 121 125 > 122 - No deployments. 126 + No matching seeds found. 123 127 </p> 128 + <.pagination 129 + :if={@seeds != []} 130 + meta={@meta} 131 + path={~p"/gardens/#{@garden}/subscriptions/#{@subscription}"} 132 + /> 124 133 </section> 125 134 </div> 126 135