Skip to content

Commit

Permalink
Pinned devices (#1897)
Browse files Browse the repository at this point in the history
Implements Pinned Devices feature.

- Migration, schema and context for pinned devices
- Listing of pinned devices in "dashboard" page (orgs)
- Pin/unpin event in show device liveview

Pinned devices are cleared when:
- device is moved to other organization which user don't have access to
- device is soft deleted
- user is removed from organisation
- user account is soft deleted
  • Loading branch information
elinol authored Mar 5, 2025
1 parent 601f629 commit 272a5da
Show file tree
Hide file tree
Showing 16 changed files with 551 additions and 12 deletions.
10 changes: 8 additions & 2 deletions lib/nerves_hub/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule NervesHub.Accounts do
alias NervesHub.Accounts.RemoveAccount
alias NervesHub.Accounts.User
alias NervesHub.Accounts.UserToken
alias NervesHub.Devices
alias NervesHub.Devices.Device
alias NervesHub.Products.Product

Expand Down Expand Up @@ -124,8 +125,13 @@ defmodule NervesHub.Accounts do
defp maybe_soft_delete_org_user(org_user), do: soft_delete_org_user(org_user)

def soft_delete_org_user(org_user) do
{:ok, _result} = Repo.soft_delete(org_user)
:ok
with {:ok, %{org_id: org_id, user_id: user_id}} <-
Repo.soft_delete(org_user),
{_, nil} <- Devices.unpin_org_devices(user_id, org_id) do
:ok
else
err -> err
end
end

def change_org_user_role(%OrgUser{} = ou, role) do
Expand Down
6 changes: 6 additions & 0 deletions lib/nerves_hub/accounts/remove_account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule NervesHub.Accounts.RemoveAccount do
import Ecto.Query
import Ecto.Changeset

alias NervesHub.Devices.PinnedDevice
alias Ecto.Multi

alias NervesHub.Accounts.Invite
Expand Down Expand Up @@ -34,6 +35,7 @@ defmodule NervesHub.Accounts.RemoveAccount do
|> Multi.delete_all(:deployments, &query_by_org_id(Deployment, &1))
|> Multi.delete_all(:firmware_deltas, &query_firmware_deltas/1)
|> Multi.delete_all(:firmware_transfers, &query_by_org_id(FirmwareTransfer, &1))
|> Multi.delete_all(:pinned_devices, &query_by_user_id(PinnedDevice, &1))
|> Multi.merge(&delete_firmwares/1)
|> Multi.delete_all(:org_keys, &query_by_org_id(OrgKey, &1))
|> Multi.delete_all(:org_metrics, &query_by_org_id(OrgMetric, &1))
Expand Down Expand Up @@ -132,6 +134,10 @@ defmodule NervesHub.Accounts.RemoveAccount do
where(queryable, [d], d.org_id in ^ids)
end

defp query_by_user_id(queryable, %{user_id: user_id}) do
where(queryable, [d], d.user_id == ^user_id)
end

defp query_firmware_deltas(%{org_ids: ids}) do
join(
FirmwareDelta,
Expand Down
82 changes: 80 additions & 2 deletions lib/nerves_hub/devices.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule NervesHub.Devices do
alias NervesHub.Accounts
alias NervesHub.Accounts.Org
alias NervesHub.Accounts.OrgKey
alias NervesHub.Accounts.OrgUser
alias NervesHub.Accounts.User
alias NervesHub.AuditLogs
alias NervesHub.AuditLogs.DeviceTemplates
Expand All @@ -22,6 +23,7 @@ defmodule NervesHub.Devices do
alias NervesHub.Devices.DeviceHealth
alias NervesHub.Devices.Filtering
alias NervesHub.Devices.InflightUpdate
alias NervesHub.Devices.PinnedDevice
alias NervesHub.Devices.SharedSecretAuth
alias NervesHub.Devices.UpdatePayload
alias NervesHub.Extensions
Expand Down Expand Up @@ -106,14 +108,14 @@ defmodule NervesHub.Devices do
|> Repo.one!()
end

@spec filter(Product.t(), map()) :: %{
@spec filter(Product.t(), User.t(), map()) :: %{
entries: list(Device.t()),
current_page: non_neg_integer(),
page_size: non_neg_integer(),
total_pages: non_neg_integer(),
total_count: non_neg_integer()
}
def filter(product, opts) do
def filter(product, user, opts) do
pagination = Map.get(opts, :pagination, %{})
sorting = Map.get(opts, :sort, {:asc, :identifier})
filters = Map.get(opts, :filters, %{})
Expand All @@ -125,6 +127,10 @@ defmodule NervesHub.Devices do
|> Repo.exclude_deleted()
|> join(:left, [d], dc in assoc(d, :latest_connection), as: :latest_connection)
|> join(:left, [d, dc], dh in assoc(d, :latest_health), as: :latest_health)
|> join(:left, [d, dc, dh], pd in PinnedDevice,
on: pd.device_id == d.id and pd.user_id == ^user.id,
as: :pinned
)
|> preload([latest_connection: lc], latest_connection: lc)
|> preload([latest_health: lh], latest_health: lh)
|> Filtering.build_filters(filters)
Expand Down Expand Up @@ -392,10 +398,12 @@ defmodule NervesHub.Devices do

def delete_device(%Device{} = device) do
device_certificates_query = from(dc in DeviceCertificate, where: dc.device_id == ^device.id)
pinned_devices_query = from(p in PinnedDevice, where: p.device_id == ^device.id)
changeset = Repo.soft_delete_changeset(device)

Multi.new()
|> Multi.delete_all(:device_certificates, device_certificates_query)
|> Multi.delete_all(:pinned_devices, pinned_devices_query)
|> Multi.update(:device, changeset)
|> Repo.transaction()
|> case do
Expand Down Expand Up @@ -1069,6 +1077,7 @@ defmodule NervesHub.Devices do

Multi.new()
|> Multi.run(:move, fn _, _ -> update_device(device, attrs) end)
|> Multi.delete_all(:pinned_devices, &unpin_unauthorized_users_query(&1))
|> Multi.run(:audit_device, fn _, _ ->
AuditLogs.audit(user, device, description)
end)
Expand All @@ -1089,6 +1098,18 @@ defmodule NervesHub.Devices do
end
end

# Queries pinned devices where user is unauthorized to device's org.
defp unpin_unauthorized_users_query(%{move: device}) do
users_in_org =
OrgUser
|> where(org_id: ^device.org_id)
|> select([:user_id])

PinnedDevice
|> where([p], p.device_id == ^device.id)
|> where([p], p.user_id not in subquery(users_in_org))
end

@spec tag_device(Device.t() | [Device.t()], User.t(), list(String.t())) ::
{:ok, Device.t()} | {:error, any(), any(), any()}
def tag_device(%Device{} = device, user, tags) do
Expand Down Expand Up @@ -1557,4 +1578,61 @@ defmodule NervesHub.Devices do

:ok
end

@spec get_pinned_devices(non_neg_integer()) :: [Device.t()]
def get_pinned_devices(user_id) do
query =
PinnedDevice
|> where(user_id: ^user_id)
|> select([:device_id])

Device
|> where([d], d.id in subquery(query))
|> join(:left, [d], o in assoc(d, :org))
|> join(:left, [d, o], p in assoc(d, :product))
|> join(:left, [d, o, lc], lc in assoc(d, :latest_connection))
|> join(:left, [d, o, lc, lh], lh in assoc(d, :latest_health))
|> preload([d, o, p, lc, lh], org: o, product: p, latest_connection: lc, latest_health: lh)
|> Repo.all()
end

@spec pin_device(non_neg_integer(), non_neg_integer()) ::
{:ok, PinnedDevice.t()} | {:error, Ecto.Changeset.t()}
def pin_device(user_id, device_id) do
%{user_id: user_id, device_id: device_id}
|> PinnedDevice.create()
|> Repo.insert()
end

@spec unpin_device(neg_integer(), non_neg_integer()) ::
{:ok, PinnedDevice.t()} | {:error, Ecto.Changeset.t()}
def unpin_device(user_id, device_id) do
PinnedDevice
|> Repo.get_by!(user_id: user_id, device_id: device_id)
|> Repo.delete()
end

def device_pinned?(user_id, device_id) do
PinnedDevice
|> where([p], p.user_id == ^user_id)
|> where([p], p.device_id == ^device_id)
|> Repo.exists?()
end

@doc """
Unpins all devices belonging to user and org.
"""
@spec unpin_org_devices(non_neg_integer(), non_neg_integer()) ::
{non_neg_integer(), nil | [term()]}
def unpin_org_devices(user_id, org_id) do
sub =
Device
|> where(org_id: ^org_id)
|> select([:id])

PinnedDevice
|> where([p], p.user_id == ^user_id)
|> where([p], p.device_id in subquery(sub))
|> Repo.delete_all()
end
end
8 changes: 8 additions & 0 deletions lib/nerves_hub/devices/filtering.ex
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ defmodule NervesHub.Devices.Filtering do
filter_on_metric(query, filters)
end

def filter(query, _filters, :is_pinned, value) do
if value do
where(query, [pinned: pd], not is_nil(pd))
else
query
end
end

# Ignore any undefined filter.
# This will prevent error 500 responses on deprecated saved bookmarks etc.
def filter(query, _filters, _key, _value) do
Expand Down
25 changes: 25 additions & 0 deletions lib/nerves_hub/devices/pinned_device.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule NervesHub.Devices.PinnedDevice do
use Ecto.Schema

import Ecto.Changeset

alias NervesHub.Accounts.User
alias NervesHub.Devices.Device
alias NervesHub.Devices.PinnedDevice

@required [:user_id, :device_id]
schema "pinned_devices" do
belongs_to(:user, User)
belongs_to(:device, Device)

timestamps(updated_at: false)
end

def create(params \\ %{}) do
%PinnedDevice{}
|> cast(params, @required)
|> validate_required(@required)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:device_id)
end
end
26 changes: 26 additions & 0 deletions lib/nerves_hub_web/components/icons.ex
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,30 @@ defmodule NervesHubWeb.Components.Icons do
</svg>
"""
end

def icon(%{name: "pinned"} = assigns) do
~H"""
<svg class={["size-5", @class]} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 20.0001L9 15.0001M9 15.0001L12.9556 18.9557C13.4558 19.456 14.3031 19.2928 14.5818 18.6425L16.8368 13.3809C16.9413 13.1371 17.1383 12.9448 17.3846 12.8463L20.5919 11.5634C21.2585 11.2967 21.4353 10.4354 20.9276 9.92778L14.0724 3.07249C13.5647 2.56485 12.7034 2.74164 12.4368 3.40821L11.1538 6.61555C11.0553 6.8618 10.863 7.05883 10.6193 7.16331L5.35761 9.41831C4.70735 9.69699 4.54417 10.5443 5.04442 11.0446L9 15.0001Z"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
"""
end

def icon(%{name: "unpinned"} = assigns) do
~H"""
<svg class={["size-5", @class]} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 20L9 15M9 15L14 20M9 15L4 10M8.5 8L10.6021 7.15917C10.8562 7.05753 11.0575 6.85618 11.1592 6.60208L12.4368 3.40807C12.7034 2.7415 13.5647 2.56471 14.0724 3.07235L20.9276 9.92764C21.4353 10.4353 21.2585 11.2966 20.5919 11.5632L17.3846 12.8462C17.1383 12.9447 16.9413 13.137 16.8368 13.3807L15.9286 15.5M2 2L22 22"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
"""
end
end
127 changes: 127 additions & 0 deletions lib/nerves_hub_web/components/pinned_devices.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
defmodule NervesHubWeb.Components.PinnedDevices do
use NervesHubWeb, :component

alias NervesHubWeb.Components.HealthStatus

attr(:devices, :list, required: true)
attr(:statuses, :map, required: true)
attr(:device_limit, :integer, required: true)
attr(:total_count, :integer, required: true)
attr(:show_all?, :boolean, default: false)

def render(assigns) do
~H"""
<div>
<div class="mt-12 h-[88px] py-6 flex items-center justify-between">
<h1 class="text-xl leading-[30px] font-semibold text-neutral-50">My Pinned Devices</h1>
</div>
<div class="bg-zinc-900 border border-zinc-700 rounded">
<div class="flex flex-col">
<div class="listing">
<table class="">
<thead>
<tr>
<th>Identifier</th>
<th>Health</th>
<th>Firmware</th>
<th>Platform</th>
<th>Tags</th>
<th>Project</th>
</tr>
</thead>
<tbody>
<tr :for={device <- @devices} class="border-b border-zinc-800 relative">
<td>
<div class="flex gap-[8px] items-center">
<span title="status">
<%= if @statuses[device.identifier] == "online" do %>
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="6" viewBox="0 0 6 6" fill="none">
<circle cx="3" cy="3" r="3" fill="#10B981" />
</svg>
<% else %>
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="6" viewBox="0 0 6 6" fill="none">
<circle cx="3" cy="3" r="3" fill="#71717A" />
</svg>
<% end %>
</span>
<.link navigate={~p"/org/#{device.org.name}/#{device.product.name}/devices/#{device.identifier}"} class="ff-m ">
{device.identifier}
</.link>
</div>
</td>
<td>
<div class="flex gap-[8px] items-center justify-center">
<HealthStatus.render device_id={device.id} health={device.latest_health} />
</div>
</td>
<td>
<div class="flex gap-[8px] items-center">
<span>
<%= if is_nil(device.firmware_metadata) do %>
-
<% else %>
{device.firmware_metadata.version}
<% end %>
</span>
<svg :if={device.firmware_metadata && device.updates_enabled} title="Updates enabled" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M6.00016 8L7.3335 9.33333L10.0002 6M8.00016 14C8.00016 14 12.6668 12 12.6668 9.33333V3.33333C11.6668 3.44444 9.3335 3.33333 8.00016 2C6.66683 3.33333 4.3335 3.44444 3.3335 3.33333V9.33333C3.3335 12 8.00016 14 8.00016 14Z"
stroke="#10B981"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg :if={device.firmware_metadata && not device.updates_enabled} title="Updates disabled" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12.6667 9.33333V3.33333C11.6667 3.44444 9.33333 3.33333 8 2C7.61905 2.38095 7.15646 2.66213 6.66667 2.86686M3.33333 3.33333V9.33333C3.33333 12 8 14 8 14C8 14 10.1359 13.0846 11.5177 11.6667M2 2L14 14"
stroke="#A1A1AA"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</td>
<td>
<span>
<%= if is_nil(device.firmware_metadata) do %>
-
<% else %>
{device.firmware_metadata.platform}
<% end %>
</span>
</td>
<td>
<div class="flex items-center gap-[4px]">
<%= if !is_nil(device.tags) do %>
<%= for tag <- device.tags do %>
<span class="tag">{tag}</span>
<% end %>
<% end %>
</div>
</td>
<td>
<div class="org-selector-title">{device.org.name}</div>
<div class="product-selector-title flex gap-2 items-center">
<span>{device.product.name}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div :if={@total_count > @device_limit} phx-click="toggle-expand-devices" class="px-6 py-2 text-center text-xs font-normal text-zinc-400 hover:text-neutral-50 hover:cursor-pointer">
{if @show_all?, do: "Show less", else: "Show all #{@total_count} devices"}
</div>
</div>
</div>
</div>
"""
end
end
Loading

0 comments on commit 272a5da

Please sign in to comment.