Skip to content

Commit 31a2342

Browse files
nshoesjoshk
andauthored
Fix deleting and destroying devices in new UI (#2150)
This PR: - Allows devices to be deleted and destroyed in the new UI - Update device settings footer when device is soft-deleted - Rejects update attempts through `Device.changeset/2` when soft-deleted - New UI tests - Tests for device API: - Soft-deleted devices don't show up in `index` - Soft-deleted devices can be queried for by `identifier` Solves #2214. --------- Co-authored-by: Josh Kalderimis <[email protected]>
1 parent 098ac2a commit 31a2342

File tree

12 files changed

+270
-91
lines changed

12 files changed

+270
-91
lines changed

lib/nerves_hub/devices.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ defmodule NervesHub.Devices do
130130
def filter(product, user, opts) do
131131
base_query =
132132
Device
133-
|> Repo.exclude_deleted()
134133
|> join(:left, [d], dc in assoc(d, :latest_connection), as: :latest_connection)
135134
|> join(:left, [d, dc], dh in assoc(d, :latest_health), as: :latest_health)
136135
|> join(:left, [d, dc, dh], pd in PinnedDevice,
@@ -233,7 +232,6 @@ defmodule NervesHub.Devices do
233232
def get_device_by_identifier!(org, identifier, preload_assoc \\ nil)
234233
when is_binary(identifier) do
235234
get_device_by_identifier_query(org, identifier, preload_assoc)
236-
|> Repo.exclude_deleted()
237235
|> Repo.one!()
238236
end
239237

@@ -1809,6 +1807,15 @@ defmodule NervesHub.Devices do
18091807
Firmwares.get_firmware_url(target)
18101808
end
18111809

1810+
@spec soft_deleted_devices_exist_for_product?(non_neg_integer()) :: boolean()
1811+
def soft_deleted_devices_exist_for_product?(product_id) do
1812+
from(d in Device,
1813+
where: d.product_id == ^product_id,
1814+
where: not is_nil(d.deleted_at)
1815+
)
1816+
|> Repo.exists?()
1817+
end
1818+
18121819
defp update_tool() do
18131820
Application.get_env(
18141821
:nerves_hub,

lib/nerves_hub/devices/device.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,12 @@ defmodule NervesHub.Devices.Device do
9797
|> validate_required(@required_params)
9898
|> validate_length(:tags, min: 1)
9999
|> unique_constraint(:identifier)
100+
|> then(fn changeset ->
101+
if device.deleted_at && !Map.has_key?(changeset.changes, :deleted_at) do
102+
add_error(changeset, :deleted_at, "cannot update while marked as deleted")
103+
else
104+
changeset
105+
end
106+
end)
100107
end
101108
end

lib/nerves_hub/devices/device_filtering.ex

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,14 @@ defmodule NervesHub.Devices.DeviceFiltering do
115115
filter_on_metric(query, filters)
116116
end
117117

118-
def filter(query, _filters, :is_pinned, value) do
119-
if value do
120-
where(query, [pinned: pd], not is_nil(pd))
121-
else
122-
query
123-
end
124-
end
118+
def filter(query, _filters, :display_deleted, "include"),
119+
do: order_by(query, [d], asc_nulls_last: d.deleted_at)
120+
121+
def filter(query, _filters, :display_deleted, "exclude"),
122+
do: where(query, [d], is_nil(d.deleted_at))
123+
124+
def filter(query, _filters, :display_deleted, "only"),
125+
do: where(query, [d], not is_nil(d.deleted_at))
125126

126127
def filter(query, _filters, :search, value) when is_binary(value) and value != "" do
127128
search_term = "%#{value}%"

lib/nerves_hub_web/components/device_page/details_tab.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,8 +749,15 @@ defmodule NervesHubWeb.Components.DevicePage.DetailsTab do
749749
{:error, changeset} ->
750750
Logger.info("Couldn't update device.priority_updates: #{inspect(changeset)}")
751751

752+
error =
753+
if Keyword.has_key?(changeset.errors, :deleted_at) do
754+
"Device cannot be updated because it has been deleted. Please restore the device to make changes."
755+
else
756+
"There was an issue updating the device. Please contact support if this happens again."
757+
end
758+
752759
socket
753-
|> put_flash(:error, "There was an issue updating the device, please check the logs.")
760+
|> put_flash(:error, error)
754761
|> halt()
755762
end
756763
end

lib/nerves_hub_web/components/device_page/settings_tab.ex

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -210,30 +210,10 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
210210
</div>
211211
212212
<div :if={@device.deleted_at && authorized?(:"device:update", @org_user)} class="flex flex-col w-full bg-zinc-900 border border-zinc-700 rounded">
213-
<div class="flex items-center p-6 gap-6 border-t border-zinc-700">
214-
<div>
215-
<button class="flex px-3 py-1.5 gap-2 rounded bg-zinc-800 border border-zinc-600" type="button" phx-click="restore-device" data-confirm="Are you sure you want to restore this device?">
216-
<svg class="size-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
217-
<path
218-
d="M8.5 18.9999H5.39903C3.87406 18.9999 2.91012 17.3617 3.65071 16.0287L7 9.99994M7 9.99994L3 11.9999M7 9.99994L8 13.9999M18.9999 13.9999L20.1987 16.0122C20.9929 17.3454 20.0323 19.0358 18.4805 19.0358L12.768 19.0358M12.768 19.0358L16 21.9999M12.768 19.0358L16 15.9999M8.5 6.99994L10.5883 3.86749C11.4401 2.58975 13.3545 2.70894 14.0413 4.08246L17 9.99994M17 9.99994L18 5.99994M17 9.99994L13 8.99994"
219-
stroke="#A1A1AA"
220-
stroke-width="1.2"
221-
stroke-linecap="round"
222-
stroke-linejoin="round"
223-
/>
224-
</svg>
225-
226-
<span class="text-sm font-medium text-zinc-300">Restore device</span>
227-
</button>
228-
</div>
229-
<div class="text-zinc-300">
230-
The device has been disabled. Attempts to connect to NervesHub will be blocked.
231-
</div>
213+
<div class="text-zinc-300 p-6 pb-0">
214+
The device has been disabled. Attempts to connect to NervesHub will be blocked.
232215
</div>
233-
</div>
234-
235-
<div :if={@device.deleted_at && authorized?(:"device:update", @org_user)} class="flex flex-col w-full bg-zinc-900 border border-zinc-700 rounded">
236-
<div class="flex items-center p-6 gap-6 border-t border-zinc-700">
216+
<div class="flex items-center p-6 gap-6 border-zinc-700">
237217
<div>
238218
<button
239219
class="flex px-3 py-1.5 gap-2 rounded bg-zinc-800 border border-red-500"
@@ -253,6 +233,22 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
253233
<span class="text-sm font-medium text-red-500">Permanently delete device</span>
254234
</button>
255235
</div>
236+
<div class="text-zinc-300">or</div>
237+
<div>
238+
<button class="flex px-3 py-1.5 gap-2 rounded bg-zinc-800 border border-zinc-600" type="button" phx-click="restore-device" data-confirm="Are you sure you want to restore this device?">
239+
<svg class="size-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
240+
<path
241+
d="M8.5 18.9999H5.39903C3.87406 18.9999 2.91012 17.3617 3.65071 16.0287L7 9.99994M7 9.99994L3 11.9999M7 9.99994L8 13.9999M18.9999 13.9999L20.1987 16.0122C20.9929 17.3454 20.0323 19.0358 18.4805 19.0358L12.768 19.0358M12.768 19.0358L16 21.9999M12.768 19.0358L16 15.9999M8.5 6.99994L10.5883 3.86749C11.4401 2.58975 13.3545 2.70894 14.0413 4.08246L17 9.99994M17 9.99994L18 5.99994M17 9.99994L13 8.99994"
242+
stroke="#A1A1AA"
243+
stroke-width="1.2"
244+
stroke-linecap="round"
245+
stroke-linejoin="round"
246+
/>
247+
</svg>
248+
249+
<span class="text-sm font-medium text-zinc-300">Restore device</span>
250+
</button>
251+
</div>
256252
</div>
257253
</div>
258254
@@ -300,8 +296,15 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
300296
|> halt()
301297

302298
{:error, :update_with_audit, changeset, _} ->
299+
error =
300+
if Keyword.has_key?(changeset.errors, :deleted_at) do
301+
"Device cannot be updated because it has been deleted. Please restore the device to make changes."
302+
else
303+
"We couldn't save your changes. Please contact support if this happens again."
304+
end
305+
303306
socket
304-
|> put_flash(:error, "We couldn't save your changes.")
307+
|> put_flash(:error, error)
305308
|> assign(:settings_form, to_form(changeset))
306309
|> halt()
307310

lib/nerves_hub_web/components/filter_sidebar.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ defmodule NervesHubWeb.Components.FilterSidebar do
5656
<input class="sidebar-text-input" type="text" name={filter.attr} id={"input_#{filter.attr}"} value={@current_filters[filter.attr]} phx-debounce="500" />
5757
<% :select -> %>
5858
<select class="sidebar-select" name={filter.attr} id={"input_#{filter.attr}"}>
59-
<option :for={{label, value} <- filter.values} value={value} selected={@current_filters[filter.attr] == value}>
59+
<option :for={{label, value} <- filter.values} value={value} selected={to_string(@current_filters[filter.attr]) == value}>
6060
{label}
6161
</option>
6262
</select>

lib/nerves_hub_web/controllers/api/device_json.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ defmodule NervesHubWeb.API.DeviceJSON do
3737
product_name: device.product.name,
3838
# deprecated
3939
last_communication: connection_last_seen_at(device),
40-
priority_updates: device.priority_updates
40+
priority_updates: device.priority_updates,
41+
deleted: deleted(device)
4142
}
4243
end
4344

@@ -62,4 +63,7 @@ defmodule NervesHubWeb.API.DeviceJSON do
6263

6364
defp connection_status(%{latest_connection: %{status: status}}), do: status
6465
defp connection_status(_), do: :not_seen
66+
67+
defp deleted(%{deleted_at: nil}), do: false
68+
defp deleted(%{deleted_at: _}), do: true
6569
end

lib/nerves_hub_web/live/devices/index-new.html.heex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
<.button type="link" navigate={~p"/org/#{@org}/#{@product}/devices/new"} aria-label="Add new device">
5555
<.icon name="add" /> Add Device
5656
</.button>
57-
<.button :if={has_results?(@devices, @currently_filtering)} type="button" phx-click="toggle-filters" phx-value-toggle={to_string(@show_filters)}>
57+
<.button :if={has_results?(@devices, @currently_filtering) || @soft_deleted_devices_exist} type="button" phx-click="toggle-filters" phx-value-toggle={to_string(@show_filters)}>
5858
<.icon name="filter" /> Filters
5959
</.button>
6060
</div>
@@ -224,6 +224,13 @@
224224
</span>
225225
<.link navigate={~p"/org/#{@org}/#{@product}/devices/#{device}"} class="ff-m font-mono">
226226
{device.identifier}
227+
<div :if={device.deleted_at} class="relative inline-block align-middle z-50" id={"health-tooltip-#{device.identifier}"} phx-hook="ToolTip" data-placement="right">
228+
<.icon name="info" class="stroke-zinc-400" />
229+
<div class="tooltip-content hidden w-max absolute top-0 left-0 z-20 text-xs px-2 py-1.5 rounded border border-[#3F3F46] bg-base-900 flex">
230+
This device has been deleted. <br /> You can destroy or restore it in the device's settings.
231+
<div class="tooltip-arrow absolute w-2 h-2 border-[#3F3F46] bg-base-900 origin-center rotate-45"></div>
232+
</div>
233+
</div>
227234
<span class="clickable-table-row-mask" />
228235
</.link>
229236
<span class={["flex items-center gap-1 ml-2 pl-2.5 pr-2.5 py-0.5 border border-zinc-700 rounded-full bg-zinc-800", !@progress[device.id] && "invisible"]}>
@@ -391,6 +398,16 @@
391398
<:filter attr="metrics_operator" label="Metrics Operator" type={:select} values={[{"Greater Than", "gt"}, {"Less Than", "lt"}]} />
392399
<:filter attr="metrics_value" label="Metrics Value" type={:number} />
393400
<:filter attr="is_pinned" label="Pinned" type={:select} values={[{"All", "false"}, {"Only pinned devices", "true"}]} />
401+
<:filter
402+
attr="display_deleted"
403+
label="Include Deleted Devices?"
404+
type={:select}
405+
values={[
406+
{"Yes", "include"},
407+
{"No", "exclude"},
408+
{"Only deleted devices", "only"}
409+
]}
410+
/>
394411
</FilterSidebar.render>
395412

396413
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 mb-[119px] sm:pl-16 z-40">

lib/nerves_hub_web/live/devices/index.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ defmodule NervesHubWeb.Live.Devices.Index do
4646
metrics_value: "",
4747
deployment_id: "",
4848
is_pinned: false,
49-
search: ""
49+
search: "",
50+
display_deleted: "exclude"
5051
}
5152

5253
@filter_types %{
@@ -67,7 +68,8 @@ defmodule NervesHubWeb.Live.Devices.Index do
6768
metrics_value: :string,
6869
deployment_id: :string,
6970
is_pinned: :boolean,
70-
search: :string
71+
search: :string,
72+
display_deleted: :string
7173
}
7274

7375
@default_page 1
@@ -110,6 +112,10 @@ defmodule NervesHubWeb.Live.Devices.Index do
110112
|> assign(:deployment_groups, ManagedDeployments.get_deployment_groups_by_product(product))
111113
|> assign(:available_deployment_groups_for_filtered_platform, [])
112114
|> assign(:target_deployment_group, nil)
115+
|> assign(
116+
:soft_deleted_devices_exist,
117+
Devices.soft_deleted_devices_exist_for_product?(product.id)
118+
)
113119
|> subscribe_and_refresh_device_list_timer()
114120
|> ok()
115121
end

test/nerves_hub_web/controllers/api/device_controller_test.exs

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ defmodule NervesHubWeb.API.DeviceControllerTest do
5151
device.identifier == identifier
5252
end)
5353
end
54+
55+
test "does not return soft-deleted devices", %{conn: conn, user: user, org: org} do
56+
product = Fixtures.product_fixture(user, org)
57+
org_key = Fixtures.org_key_fixture(org, user)
58+
firmware = Fixtures.firmware_fixture(org_key, product)
59+
60+
{:ok, _device} =
61+
Fixtures.device_fixture(org, product, firmware)
62+
|> Devices.update_device(%{deleted_at: DateTime.utc_now()})
63+
64+
conn = get(conn, Routes.api_device_path(conn, :index, org.name, product.name))
65+
66+
assert json_response(conn, 200)["data"] == []
67+
end
5468
end
5569

5670
describe "show" do
@@ -111,6 +125,55 @@ defmodule NervesHubWeb.API.DeviceControllerTest do
111125
end)
112126
|> assert_authorization_error(404)
113127
end
128+
129+
test "deleted device can be queried", %{
130+
conn: conn,
131+
user: user,
132+
org: org
133+
} do
134+
product = Fixtures.product_fixture(user, org)
135+
org_key = Fixtures.org_key_fixture(org, user)
136+
firmware = Fixtures.firmware_fixture(org_key, product)
137+
138+
{:ok, device} =
139+
Fixtures.device_fixture(org, product, firmware)
140+
|> Devices.update_device(%{deleted_at: DateTime.utc_now()})
141+
142+
conn =
143+
get(conn, Routes.api_device_path(conn, :show, device.identifier))
144+
145+
assert json_response(conn, 200)["data"]
146+
147+
assert json_response(conn, 200)["data"]["identifier"] == device.identifier
148+
end
149+
150+
test "device indicates if it has been deleted", %{
151+
conn: conn,
152+
user: user,
153+
org: org
154+
} do
155+
product = Fixtures.product_fixture(user, org)
156+
org_key = Fixtures.org_key_fixture(org, user)
157+
firmware = Fixtures.firmware_fixture(org_key, product)
158+
159+
{:ok, device} =
160+
Fixtures.device_fixture(org, product, firmware)
161+
|> Devices.update_device(%{deleted_at: DateTime.utc_now()})
162+
163+
conn =
164+
get(conn, Routes.api_device_path(conn, :show, device.identifier))
165+
166+
assert json_response(conn, 200)["data"]
167+
168+
assert json_response(conn, 200)["data"]["deleted"] == true
169+
170+
{:ok, device} = Devices.update_device(device, %{deleted_at: nil})
171+
172+
conn =
173+
get(conn, Routes.api_device_path(conn, :show, device.identifier))
174+
175+
assert json_response(conn, 200)["data"]["deleted"] == false
176+
end
114177
end
115178

116179
describe "delete devices" do
@@ -131,14 +194,6 @@ defmodule NervesHubWeb.API.DeviceControllerTest do
131194
)
132195

133196
assert response(conn, 204)
134-
135-
assert_error_sent(404, fn ->
136-
get(
137-
conn,
138-
Routes.api_device_path(conn, :show, org.name, product.name, to_delete.identifier)
139-
)
140-
end)
141-
|> assert_authorization_error(404)
142197
end
143198

144199
test "does not have required role to delete chosen device", %{

0 commit comments

Comments
 (0)