Skip to content

Commit bc334d3

Browse files
kybishopnshoes
andauthored
Track release history (#2404)
<img width="1728" height="684" alt="Screenshot 2025-12-05 at 1 17 44 PM" src="https://github.com/user-attachments/assets/d873765b-9259-468f-bc85-ffe88fa57e71" /> --------- Co-authored-by: Nate Shoemaker <[email protected]> Co-authored-by: Nate Shoemaker <[email protected]>
1 parent 65ee8aa commit bc334d3

File tree

19 files changed

+480
-141
lines changed

19 files changed

+480
-141
lines changed

lib/nerves_hub/managed_deployments.ex

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule NervesHub.ManagedDeployments do
33

44
require Logger
55

6+
alias NervesHub.Accounts.User
67
alias NervesHub.AuditLogs.DeploymentGroupTemplates
78
alias NervesHub.AuditLogs.DeviceTemplates
89
alias NervesHub.Devices
@@ -11,6 +12,7 @@ defmodule NervesHub.ManagedDeployments do
1112
alias NervesHub.Firmwares
1213
alias NervesHub.Firmwares.FirmwareDelta
1314
alias NervesHub.ManagedDeployments.DeploymentGroup
15+
alias NervesHub.ManagedDeployments.DeploymentRelease
1416
alias NervesHub.ManagedDeployments.Distributed.Orchestrator, as: DistributedOrchestrator
1517
alias NervesHub.Products.Product
1618
alias Phoenix.Channel.Server, as: PhoenixChannelServer
@@ -193,27 +195,31 @@ defmodule NervesHub.ManagedDeployments do
193195
Update a deployment
194196
195197
- Records audit logs depending on changes
198+
- Creates deployment release record if firmware_id or archive_id changed
196199
"""
197-
@spec update_deployment_group(DeploymentGroup.t(), map) ::
200+
@spec update_deployment_group(DeploymentGroup.t(), map, User.t()) ::
198201
{:ok, DeploymentGroup.t()} | {:error, Changeset.t()}
199-
def update_deployment_group(deployment_group, params) do
202+
def update_deployment_group(deployment_group, params, user) do
203+
deployment_group = Repo.preload(deployment_group, :firmware)
204+
200205
result =
201-
Repo.transaction(fn ->
206+
Repo.transact(fn ->
202207
changeset =
203208
deployment_group
204-
|> Repo.preload(:firmware)
205209
|> DeploymentGroup.update_changeset(params)
206210

207-
case Repo.update(changeset) do
208-
{:ok, deployment_group} ->
209-
deployment_group = Repo.preload(deployment_group, [:firmware], force: true)
210-
211-
audit_changes!(deployment_group, changeset)
212-
213-
{deployment_group, changeset}
214-
215-
{:error, changeset} ->
216-
Repo.rollback(changeset)
211+
create_deployment_release? =
212+
Map.has_key?(changeset.changes, :firmware_id) or
213+
Map.has_key?(changeset.changes, :archive_id)
214+
215+
with {:ok, deployment_group} <- Repo.update(changeset),
216+
:ok <- create_audit_logs!(deployment_group, changeset),
217+
{:ok, _deployment_group} <-
218+
if(create_deployment_release?,
219+
do: create_deployment_release(deployment_group, user.id),
220+
else: {:ok, nil}
221+
) do
222+
{:ok, {deployment_group, changeset}}
217223
end
218224
end)
219225

@@ -237,7 +243,18 @@ defmodule NervesHub.ManagedDeployments do
237243
end
238244
end
239245

240-
defp audit_changes!(deployment_group, changeset) do
246+
defp create_deployment_release(deployment_group, user_id) do
247+
%DeploymentRelease{}
248+
|> DeploymentRelease.changeset(%{
249+
deployment_group_id: deployment_group.id,
250+
firmware_id: deployment_group.firmware_id,
251+
archive_id: deployment_group.archive_id,
252+
created_by_id: user_id
253+
})
254+
|> Repo.insert()
255+
end
256+
257+
defp create_audit_logs!(deployment_group, changeset) do
241258
Enum.each(changeset.changes, fn
242259
{:archive_id, archive_id} ->
243260
# Trigger the new archive to get downloaded by devices
@@ -330,11 +347,17 @@ defmodule NervesHub.ManagedDeployments do
330347
Ecto.Changeset.change(%DeploymentGroup{})
331348
end
332349

333-
@spec create_deployment_group(map(), Product.t()) ::
350+
@spec create_deployment_group(map(), Product.t(), User.t()) ::
334351
{:ok, DeploymentGroup.t()} | {:error, Changeset.t()}
335-
def create_deployment_group(params, %Product{} = product) do
336-
DeploymentGroup.create_changeset(params, product)
337-
|> Repo.insert()
352+
def create_deployment_group(params, %Product{} = product, user) do
353+
Repo.transact(fn ->
354+
changeset = DeploymentGroup.create_changeset(params, product)
355+
356+
with {:ok, deployment_group} <- Repo.insert(changeset),
357+
{:ok, _release} <- create_deployment_release(deployment_group, user.id) do
358+
{:ok, deployment_group}
359+
end
360+
end)
338361
|> case do
339362
{:ok, deployment_group} ->
340363
deployment_created_event(deployment_group)
@@ -346,6 +369,21 @@ defmodule NervesHub.ManagedDeployments do
346369
end
347370
end
348371

372+
@doc """
373+
List all deployment releases for a deployment group, ordered by most recent first.
374+
"""
375+
@spec list_deployment_releases(DeploymentGroup.t()) :: [DeploymentRelease.t()]
376+
def list_deployment_releases(%DeploymentGroup{id: deployment_group_id}) do
377+
DeploymentRelease
378+
|> where([r], r.deployment_group_id == ^deployment_group_id)
379+
|> join(:inner, [r], f in assoc(r, :firmware))
380+
|> join(:inner, [r], u in assoc(r, :user))
381+
|> join(:left, [r], a in assoc(r, :archive))
382+
|> preload([r, f, u, a], firmware: f, user: u, archive: a)
383+
|> order_by([r], desc: r.inserted_at, desc: r.id)
384+
|> Repo.all()
385+
end
386+
349387
@spec broadcast(DeploymentGroup.t() | atom(), String.t(), map()) :: :ok | {:error, term()}
350388
def broadcast(deployment_group, event, payload \\ %{})
351389

lib/nerves_hub/managed_deployments/deployment_group.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ defmodule NervesHub.ManagedDeployments.DeploymentGroup do
3838

3939
has_many(:inflight_updates, InflightUpdate, foreign_key: :deployment_id)
4040
has_many(:devices, Device, foreign_key: :deployment_id, on_delete: :nilify_all)
41-
has_many(:deployment_releases, DeploymentRelease)
41+
has_many(:deployment_releases, DeploymentRelease, on_delete: :delete_all)
4242
has_many(:update_stats, UpdateStat, on_delete: :nilify_all, foreign_key: :deployment_id)
4343

4444
embeds_one :conditions, __MODULE__.Conditions, primary_key: false, on_replace: :update do

lib/nerves_hub/managed_deployments/deployment_release.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ defmodule NervesHub.ManagedDeployments.DeploymentRelease do
1515
@required_fields [
1616
:deployment_group_id,
1717
:firmware_id,
18-
:archive_id,
19-
:created_by_id,
20-
:status
18+
:created_by_id
2119
]
2220

2321
@optional_fields [:archive_id]

lib/nerves_hub_web/components/deployment_group_page/release_history.ex

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,61 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.ReleaseHistory do
99

1010
def render(assigns) do
1111
~H"""
12-
<div class="h-full flex flex-col items-center justify-center gap-4">
13-
<div class="font-semibold text-xl">
14-
Coming Soon...
12+
<div class="flex flex-col p-6 gap-6">
13+
<div class="w-full">
14+
<div class="flex flex-col bg-zinc-900 border border-zinc-700 rounded">
15+
<div class="flex justify-between items-center h-14 px-4 border-b border-zinc-700">
16+
<div class="text-base text-neutral-50 font-medium">Release History</div>
17+
</div>
18+
19+
<div :if={@releases == []} class="flex flex-col items-center justify-center p-12 gap-4">
20+
<div class="text-zinc-400">No releases yet</div>
21+
<div class="text-sm text-zinc-500">
22+
Release history will appear here when you change the firmware version in settings.
23+
</div>
24+
</div>
25+
26+
<div :if={@releases != []} class="overflow-x-auto">
27+
<table class="w-full">
28+
<thead class="border-b border-zinc-700">
29+
<tr>
30+
<th class="text-left px-4 py-3 text-sm font-medium text-zinc-400">Released</th>
31+
<th class="text-left px-4 py-3 text-sm font-medium text-zinc-400">Firmware Version</th>
32+
<th class="text-left px-4 py-3 text-sm font-medium text-zinc-400">UUID</th>
33+
<th class="text-left px-4 py-3 text-sm font-medium text-zinc-400">Archive</th>
34+
<th class="text-left px-4 py-3 text-sm font-medium text-zinc-400">Released By</th>
35+
</tr>
36+
</thead>
37+
<tbody>
38+
<tr :for={release <- @releases} class="border-b border-zinc-800 hover:bg-zinc-800/50">
39+
<td class="px-4 py-3 text-sm text-zinc-300">
40+
<div class="flex flex-col">
41+
<span>{Calendar.strftime(release.inserted_at, "%B %d, %Y")}</span>
42+
<span class="text-xs text-zinc-500">{Calendar.strftime(release.inserted_at, "%I:%M %p")} UTC</span>
43+
</div>
44+
</td>
45+
<td class="px-4 py-3 text-sm text-zinc-300 font-medium">
46+
{release.firmware.version}
47+
</td>
48+
<td class="px-4 py-3 text-sm text-zinc-400 font-mono">
49+
{release.firmware.uuid}
50+
</td>
51+
<td class="px-4 py-3 text-sm text-zinc-400">
52+
<span :if={release.archive}>
53+
{release.archive.version} ({String.slice(release.archive.uuid, 0..7)})
54+
</span>
55+
<span :if={!release.archive} class="text-zinc-500 italic">
56+
None
57+
</span>
58+
</td>
59+
<td class="px-4 py-3 text-sm text-zinc-400">
60+
{release.user.name}
61+
</td>
62+
</tr>
63+
</tbody>
64+
</table>
65+
</div>
66+
</div>
1567
</div>
1668
</div>
1769
"""

lib/nerves_hub_web/components/deployment_group_page/settings.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.Settings do
286286

287287
authorized!(:"deployment_group:update", org_user)
288288

289-
case ManagedDeployments.update_deployment_group(deployment_group, params) do
289+
case ManagedDeployments.update_deployment_group(deployment_group, params, user) do
290290
{:ok, updated} ->
291291
# Use original deployment so changes will get
292292
# marked in audit log

lib/nerves_hub_web/controllers/api/deployment_group_controller.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ defmodule NervesHubWeb.API.DeploymentGroupController do
2929
uuid ->
3030
with {:ok, firmware} <- Firmwares.get_firmware_by_product_and_uuid(product, uuid),
3131
params = Map.put(params, "firmware_id", firmware.id),
32-
{:ok, deployment_group} <- ManagedDeployments.create_deployment_group(params, product) do
32+
{:ok, deployment_group} <- ManagedDeployments.create_deployment_group(params, product, user) do
3333
DeploymentGroupTemplates.audit_deployment_created(user, deployment_group)
3434

3535
conn
@@ -67,7 +67,7 @@ defmodule NervesHubWeb.API.DeploymentGroupController do
6767
ManagedDeployments.get_deployment_group_by_name(product, name),
6868
params = update_params(product, deployment_group_params),
6969
{:ok, updated_deployment_group} <-
70-
ManagedDeployments.update_deployment_group(deployment_group, params) do
70+
ManagedDeployments.update_deployment_group(deployment_group, params, user) do
7171
DeploymentGroupTemplates.audit_deployment_updated(user, deployment_group)
7272

7373
render(conn, :show, deployment_group: updated_deployment_group)

lib/nerves_hub_web/live/deployment_groups/new.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ defmodule NervesHubWeb.Live.DeploymentGroups.New do
143143

144144
%{user: user, org: org, product: product} = socket.assigns
145145

146-
ManagedDeployments.create_deployment_group(params, product)
146+
ManagedDeployments.create_deployment_group(params, product, user)
147147
|> case do
148148
{:ok, deployment_group} ->
149149
_ = DeploymentGroupTemplates.audit_deployment_created(user, deployment_group)

lib/nerves_hub_web/live/deployment_groups/show.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Show do
3838

3939
inflight_updates = Devices.inflight_updates_for(deployment_group)
4040
updating_count = Devices.updating_count(deployment_group)
41+
releases = ManagedDeployments.list_deployment_releases(deployment_group)
4142

4243
:ok = socket.endpoint.subscribe("deployment:#{deployment_group.id}:internal")
4344

@@ -55,6 +56,7 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Show do
5556
|> assign(:firmware, deployment_group.firmware)
5657
|> assign(:deltas, Firmwares.get_deltas_by_target_firmware(deployment_group.firmware))
5758
|> assign(:update_stats, UpdateStats.stats_by_deployment(deployment_group))
59+
|> assign(:releases, releases)
5860
|> assign_matched_devices_count()
5961
|> schedule_inflight_updates_updater()
6062
|> ok()
@@ -85,7 +87,7 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Show do
8587
value = !deployment_group.is_active
8688

8789
{:ok, deployment_group} =
88-
ManagedDeployments.update_deployment_group(deployment_group, %{is_active: value})
90+
ManagedDeployments.update_deployment_group(deployment_group, %{is_active: value}, user)
8991

9092
active_str = if value, do: "active", else: "inactive"
9193
DeploymentGroupTemplates.audit_deployment_toggle_active(user, deployment_group, active_str)

lib/nerves_hub_web/live/deployment_groups/show.html.heex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
update_stats={@update_stats}
102102
/>
103103

104-
<.live_component :if={@tab == :release_history} module={ReleaseHistoryTab} id="deployment_group_release_history" />
104+
<.live_component :if={@tab == :release_history} module={ReleaseHistoryTab} id="deployment_group_release_history" releases={@releases} />
105105

106106
<.live_component :if={@tab == :activity} module={ActivityTab} id="deployment_group_activity" deployment_group={@deployment_group} org={@org} product={@product} user={@user} />
107107

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule NervesHub.Repo.Migrations.AddCascadeDeleteToDeploymentReleases do
2+
use Ecto.Migration
3+
4+
def up do
5+
# Drop the existing foreign key constraint
6+
drop constraint(:deployment_releases, "deployment_releases_deployment_group_id_fkey")
7+
8+
# Add it back with cascade delete
9+
alter table(:deployment_releases) do
10+
modify :deployment_group_id, references(:deployments, on_delete: :delete_all), null: false
11+
end
12+
end
13+
14+
def down do
15+
# Drop the cascade delete foreign key constraint
16+
drop constraint(:deployment_releases, "deployment_releases_deployment_group_id_fkey")
17+
18+
# Add it back without cascade delete (original state)
19+
alter table(:deployment_releases) do
20+
modify :deployment_group_id, references(:deployments), null: false
21+
end
22+
end
23+
end

0 commit comments

Comments
 (0)