Skip to content

Commit

Permalink
Bulk move devices to deployment (#1718)
Browse files Browse the repository at this point in the history
  • Loading branch information
nshoes authored Jan 6, 2025
1 parent 6a5ff1b commit 0aab022
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 6 deletions.
16 changes: 16 additions & 0 deletions lib/nerves_hub/devices.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,22 @@ defmodule NervesHub.Devices do
update_device_with_audit(device, params, user, description)
end

@spec move_many_to_deployment([integer()], integer()) ::
{:ok, %{updated: non_neg_integer(), ignored: non_neg_integer()}}
def move_many_to_deployment(device_ids, deployment_id) do
%{firmware: firmware} =
Deployment |> where(id: ^deployment_id) |> preload(:firmware) |> Repo.one()

{devices_updated_count, _} =
Device
|> where([d], d.id in ^device_ids)
|> where([d], d.firmware_metadata["platform"] == ^firmware.platform)
|> where([d], d.firmware_metadata["architecture"] == ^firmware.architecture)
|> Repo.update_all(set: [deployment_id: deployment_id])

{:ok, %{updated: devices_updated_count, ignored: length(device_ids) - devices_updated_count}}
end

@spec move_many([Device.t()], Product.t(), User.t()) :: %{
ok: [Device.t()],
error: [{Ecto.Multi.name(), any()}]
Expand Down
2 changes: 2 additions & 0 deletions lib/nerves_hub/products/product.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule NervesHub.Products.Product do

alias NervesHub.Accounts.Org
alias NervesHub.Archives.Archive
alias NervesHub.Deployments.Deployment
alias NervesHub.Scripts.Script
alias NervesHub.Devices.CACertificate
alias NervesHub.Devices.Device
Expand All @@ -22,6 +23,7 @@ defmodule NervesHub.Products.Product do
has_many(:jitp, CACertificate.JITP)
has_many(:archives, Archive)
has_many(:scripts, Script)
has_many(:deployments, Deployment)

has_many(:shared_secret_auths, SharedSecretAuth,
preload_order: [desc: :deactivated_at, asc: :id]
Expand Down
81 changes: 78 additions & 3 deletions lib/nerves_hub_web/live/devices/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule NervesHubWeb.Live.Devices.Index do
alias NervesHub.Firmwares
alias NervesHub.Products.Product
alias NervesHub.Tracker
alias NervesHub.Repo

alias Phoenix.Socket.Broadcast
alias Phoenix.LiveView.JS
Expand Down Expand Up @@ -73,8 +74,8 @@ defmodule NervesHubWeb.Live.Devices.Index do
total_pages: :integer
}

def mount(_params, _session, socket) do
%{product: product} = socket.assigns
def mount(_params, _session, %{assigns: %{product: product}} = socket) do
product = Repo.preload(product, :deployments)

socket
|> page_title("Devices - #{product.name}")
Expand All @@ -93,6 +94,8 @@ defmodule NervesHubWeb.Live.Devices.Index do
|> assign(:total_entries, 0)
|> assign(:current_alarms, Alarms.get_current_alarm_types(product.id))
|> assign(:metrics_keys, Metrics.default_metrics())
|> assign(:deployments, product.deployments)
|> assign(:target_deployment, nil)
|> subscribe_and_refresh_device_list_timer()
|> ok()
end
Expand Down Expand Up @@ -272,7 +275,18 @@ defmodule NervesHubWeb.Live.Devices.Index do
{:noreply, assign(socket, target_product: target)}
end

def handle_event("move-devices", _, socket) do
def handle_event("target-deployment", %{"deployment" => ""}, socket) do
{:noreply, assign(socket, target_deployment: nil)}
end

def handle_event("target-deployment", %{"deployment" => deployment_id}, socket) do
deployment =
Enum.find(socket.assigns.deployments, &(&1.id == String.to_integer(deployment_id)))

{:noreply, assign(socket, target_deployment: deployment)}
end

def handle_event("move-devices-product", _, socket) do
%{ok: successfuls} =
Devices.get_devices_by_id(socket.assigns.selected_devices)
|> Devices.move_many(socket.assigns.target_product, socket.assigns.user)
Expand All @@ -289,6 +303,30 @@ defmodule NervesHubWeb.Live.Devices.Index do
{:noreply, socket}
end

def handle_event(
"move-devices-deployment",
_,
%{
assigns: %{
selected_devices: selected_devices,
target_deployment: target_deployment
}
} = socket
) do
{:ok, %{updated: updated, ignored: ignored}} =
Devices.move_many_to_deployment(selected_devices, target_deployment.id)

socket =
socket
|> assign(:target_deployment, nil)
|> assign_display_devices()
|> then(
&update_flash_moving_devices_deployment(&1, updated, ignored, target_deployment.name)
)

{:noreply, socket}
end

def handle_event("disable-updates-for-devices", _, socket) do
%{ok: _successfuls} =
Devices.get_devices_by_id(socket.assigns.selected_devices)
Expand Down Expand Up @@ -573,4 +611,41 @@ defmodule NervesHubWeb.Live.Devices.Index do
js
|> JS.hide(transition: "fade-out", to: "##{id}")
end

defp update_flash_moving_devices_deployment(
socket,
updated_count,
ignored_count,
deployment_name
) do
maybe_pluralize =
&if &1 == 1 do
&2
else
&2 <> "s"
end

case [updated_count, ignored_count] do
[updated_count, 0] ->
put_flash(
socket,
:info,
"#{updated_count} #{maybe_pluralize.(updated_count, "device")} added to deployment #{deployment_name}"
)

[0, _not_updated_count] ->
put_flash(
socket,
:info,
"No devices selected could be added to deployment #{deployment_name} because of mismatched firmware"
)

[updated_count, not_updated_count] ->
put_flash(
socket,
:info,
"#{updated_count} #{maybe_pluralize.(updated_count, "device")} added to deployment #{deployment_name}. #{not_updated_count} #{maybe_pluralize.(not_updated_count, "device")} could not be added to deployment because of mismatched firmware"
)
end
end
end
31 changes: 28 additions & 3 deletions lib/nerves_hub_web/live/devices/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@
</div>

<div class="row mt-2">
<form id="move" class="col-lg-6" phx-change="target-product" phx-submit="move-devices">
<label for="move_to">Move device(s) to:</label>
<form id="move-product" class="col-lg-4" phx-change="target-product" phx-submit="move-devices-product">
<label for="move_to">Move device(s) to product:</label>
<div class="flex-row align-items-center">
<div class="flex-grow pos-rel">
<select name="product" id="move_to" class="form-control">
Expand All @@ -216,7 +216,7 @@
</div>
</form>

<form id="bulk-tag-input" class="col-lg-6" phx-submit="tag-devices" phx-change="validate-tags">
<form id="bulk-tag-input" class="col-lg-4" phx-submit="tag-devices" phx-change="validate-tags">
<label for="input_set_tags">Set tag(s) to:</label>
<div class="flex-row align-items-center">
<div class="flex-grow">
Expand All @@ -233,6 +233,31 @@
</div>
<div class={if @valid_tags, do: "hidden"}><span class="has-error"> Tags Cannot Contain Spaces </span></div>
</form>

<form id="move-deployment" class="col-lg-4" phx-change="target-deployment" phx-submit="move-devices-deployment">
<label for="move_to">Move device(s) to deployment:</label>
<div class="flex-row align-items-center">
<div class="flex-grow pos-rel">
<select name="deployment" id="move_to" class="form-control">
<option value=""></option>
<%= for deployment <- @deployments do %>
<option value={deployment.id} {if @target_deployment && @target_deployment.id == deployment.id, do: [selected: true], else: []}><%= deployment.name %></option>
<% end %>
</select>
<div class="select-icon"></div>
</div>

<button
class="btn btn-outline-light btn-action btn-primary ml-1"
type="submit"
data-confirm={"This will move all selected devices to #{@target_deployment && @target_deployment.name}. Continue?"}
id="move-deployment-submit"
{unless @target_deployment, do: [disabled: true], else: []}
>
Move
</button>
</div>
</form>
</div>

<div class="row mt-2">
Expand Down
107 changes: 107 additions & 0 deletions test/nerves_hub_web/live/devices/index_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ defmodule NervesHubWeb.Live.Devices.IndexTest do
use NervesHubWeb.ConnCase.Browser, async: false

alias NervesHub.Devices
alias NervesHub.Firmwares.FirmwareMetadata
alias NervesHub.Fixtures
alias NervesHubWeb.Endpoint
alias NervesHub.Repo

setup %{fixture: %{device: device}} do
Endpoint.subscribe("device:#{device.id}")
Expand Down Expand Up @@ -278,6 +280,111 @@ defmodule NervesHubWeb.Live.Devices.IndexTest do
|> click_button("Set")
|> assert_has("span", text: "moussaka")
end

test "add multiple devices to deployment",
%{conn: conn, fixture: fixture} do
%{
device: device,
org: org,
product: product,
firmware: firmware,
deployment: deployment
} = fixture

device2 = Fixtures.device_fixture(org, product, firmware)

refute device.deployment_id
refute device2.deployment_id

conn
|> visit("/org/#{org.name}/#{product.name}/devices")
|> unwrap(fn view ->
render_change(view, "select-all", %{"id" => device.id})
end)
|> assert_has("span", text: "2 selected")
|> unwrap(fn view ->
render_change(view, "target-deployment", %{"deployment" => to_string(deployment.id)})
end)
|> click_button("#move-deployment-submit", "Move")
|> assert_has("div", text: "2 devices added to deployment")

assert Repo.reload(device) |> Map.get(:deployment_id)
assert Repo.reload(device2) |> Map.get(:deployment_id)
end

test "selecting multiple devices to add to deployment but some don't match firmware requirements",
%{conn: conn, fixture: fixture} do
%{
device: device,
org: org,
product: product,
firmware: firmware,
deployment: deployment
} = fixture

device2 = Fixtures.device_fixture(org, product, firmware)

different_firmware_params =
%FirmwareMetadata{device2.firmware_metadata | platform: "foo"} |> Map.from_struct()

{:ok, device2} = Devices.update_firmware_metadata(device2, different_firmware_params)

refute device.deployment_id
refute device2.deployment_id

conn
|> visit("/org/#{org.name}/#{product.name}/devices")
|> unwrap(fn view ->
render_change(view, "select-all", %{"id" => device.id})
end)
|> assert_has("span", text: "2 selected")
|> unwrap(fn view ->
render_change(view, "target-deployment", %{"deployment" => to_string(deployment.id)})
end)
|> click_button("#move-deployment-submit", "Move")
|> assert_has("div", text: "1 device added to deployment")
|> assert_has("div", text: "1 device could not be added")

assert Repo.reload(device) |> Map.get(:deployment_id)
refute Repo.reload(device2) |> Map.get(:deployment_id)
end

test "selecting multiple devices to add to deployment but none match firmware requirements",
%{conn: conn, fixture: fixture} do
%{
device: device,
org: org,
product: product,
firmware: firmware,
deployment: deployment
} = fixture

device2 = Fixtures.device_fixture(org, product, firmware)

different_firmware_params =
%FirmwareMetadata{device2.firmware_metadata | platform: "foo"} |> Map.from_struct()

{:ok, device} = Devices.update_firmware_metadata(device, different_firmware_params)
{:ok, device2} = Devices.update_firmware_metadata(device2, different_firmware_params)

refute device.deployment_id
refute device2.deployment_id

conn
|> visit("/org/#{org.name}/#{product.name}/devices")
|> unwrap(fn view ->
render_change(view, "select-all", %{"id" => device.id})
end)
|> assert_has("span", text: "2 selected")
|> unwrap(fn view ->
render_change(view, "target-deployment", %{"deployment" => to_string(deployment.id)})
end)
|> click_button("#move-deployment-submit", "Move")
|> assert_has("div", text: "No devices selected could be added to deployment")

refute Repo.reload(device) |> Map.get(:deployment_id)
refute Repo.reload(device2) |> Map.get(:deployment_id)
end
end

def device_index_path(%{org: org, product: product}) do
Expand Down

0 comments on commit 0aab022

Please sign in to comment.