Skip to content

Commit 80dd9e9

Browse files
authored
Add support for filtering devices on alarms (#1628)
1 parent 3c167fb commit 80dd9e9

File tree

9 files changed

+241
-11
lines changed

9 files changed

+241
-11
lines changed

assets/css/_custom.scss

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,18 @@
1313
right: 0;
1414
margin: auto;
1515
z-index: 999;
16-
// text-align: center;
16+
}
17+
18+
.alarms-banner {
19+
display: flex;
20+
flex-direction: row;
21+
justify-content: right;
22+
padding-top: 2px;
23+
padding-bottom: 2px;
24+
}
25+
26+
.alarms-banner a {
27+
color: #f78080;
1728
}
1829

1930
.opacity-1 {
@@ -244,19 +255,28 @@
244255
}
245256
}
246257

247-
.device-limit-indicator {
248-
background-image: url("/images/icons/device.svg");
249-
background-position: right center;
258+
.navbar-indicator {
259+
background-position: left center;
250260
background-repeat: no-repeat;
251261
background-size: 1.5rem;
252-
padding-right: 2rem;
253-
letter-spacing: 4px;
262+
padding-left: 1.8rem;
263+
letter-spacing: 2px;
254264

255265
@media(max-width: 860px) {
256266
display: none;
257267
}
258268
}
259269

270+
.device-limit-indicator {
271+
background-image: url("/images/icons/device.svg");
272+
}
273+
274+
.alarms-indicator {
275+
background-image: url("/images/icons/bell.svg");
276+
margin-left: 1.3rem;
277+
font-weight: 400;
278+
}
279+
260280
html {
261281
.btn-group>form:not(:first-child) .btn {
262282
margin-left: -1px;
@@ -345,6 +365,7 @@ html {
345365
cursor: pointer;
346366
display: flex;
347367
gap: 4px;
368+
348369
label {
349370
font-size: 14px;
350371
border-radius: 3.2px;
@@ -360,6 +381,7 @@ html {
360381
padding-right: 8px;
361382
justify-content: center;
362383
flex-grow: 1;
384+
363385
input[type=radio] {
364386
appearance: none;
365387
}

assets/css/_form.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ html {
267267
.filter-form {
268268
&.device-filters {
269269
display: grid;
270-
grid-template-columns: 3fr 3fr 3fr 2fr 2fr;
270+
grid-template-columns: 2fr 2fr 2fr 2fr 3fr;
271271
grid-column-gap: 1rem;
272272

273273
@media (max-width: 860px) {
Lines changed: 5 additions & 0 deletions
Loading

lib/nerves_hub/devices.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule NervesHub.Devices do
1313
alias NervesHub.Deployments.Deployment
1414
alias NervesHub.Deployments.Orchestrator
1515
alias NervesHub.Devices.CACertificate
16+
alias NervesHub.Devices.Alarms
1617
alias NervesHub.Devices.Device
1718
alias NervesHub.Devices.DeviceCertificate
1819
alias NervesHub.Devices.DeviceHealth
@@ -163,6 +164,15 @@ defmodule NervesHub.Devices do
163164
{_, ""} ->
164165
query
165166

167+
{:alarm_status, "with"} ->
168+
where(query, [d], d.id in subquery(Alarms.query_devices_with_alarms()))
169+
170+
{:alarm_status, "without"} ->
171+
where(query, [d], d.id not in subquery(Alarms.query_devices_with_alarms()))
172+
173+
{:alarm, value} ->
174+
where(query, [d], d.id in subquery(Alarms.query_devices_with_alarm(value)))
175+
166176
{:connection, _value} ->
167177
where(query, [d], d.connection_status == ^String.to_atom(value))
168178

lib/nerves_hub/devices/alarms.ex

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
defmodule NervesHub.Devices.Alarms do
2+
import Ecto.Query
3+
alias NervesHub.Repo
4+
alias NervesHub.Devices.Device
5+
alias NervesHub.Devices.DeviceHealth
6+
7+
@doc """
8+
Selects device id:s for devices that has alarm(s) in it's latest health record.
9+
Used when filtering devices.
10+
"""
11+
def query_devices_with_alarms() do
12+
(lr in subquery(latest_row_query()))
13+
|> from()
14+
|> where([lr], lr.rn == 1)
15+
|> where([lr], fragment("?->'alarms' != '{}'", lr.data))
16+
|> join(:inner, [lr], d in Device, on: lr.device_id == d.id)
17+
|> select([lr, o], o.id)
18+
end
19+
20+
@doc """
21+
Selects device id:s for devices that has provided alarm in it's latest health record.
22+
Used when filtering devices.
23+
"""
24+
def query_devices_with_alarm(alarm) do
25+
(lr in subquery(latest_row_query()))
26+
|> from()
27+
|> where([lr], lr.rn == 1)
28+
|> where(
29+
[lr],
30+
fragment(
31+
"EXISTS (SELECT 1 FROM jsonb_each_text(?) WHERE value ILIKE ?)",
32+
lr.data,
33+
^"%#{alarm}%"
34+
)
35+
)
36+
|> join(:inner, [lr], d in Device, on: lr.device_id == d.id)
37+
|> select([lr, o], o.id)
38+
end
39+
40+
@doc """
41+
Creates a list with all current alarm types for a product.
42+
"""
43+
def get_current_alarm_types(product_id) do
44+
query_current_alarms(product_id)
45+
|> Repo.all()
46+
|> Enum.map(fn %{data: data} ->
47+
Map.keys(data["alarms"])
48+
end)
49+
|> List.flatten()
50+
|> Enum.uniq()
51+
|> Enum.map(&String.trim_leading(&1, "Elixir."))
52+
end
53+
54+
@doc """
55+
Counts number of devices currently alarming, within a product.
56+
"""
57+
def current_alarms_count(product_id) do
58+
product_id
59+
|> query_current_alarms()
60+
|> select([a], count(a))
61+
|> Repo.one!()
62+
end
63+
64+
@doc """
65+
Selects latest health per device if alarms is populated and device belongs to product.
66+
"""
67+
def query_current_alarms(product_id) do
68+
(lr in subquery(latest_row_query()))
69+
|> from()
70+
|> where([lr], lr.rn == 1)
71+
|> where([lr], fragment("?->'alarms' != '{}'", lr.data))
72+
|> where([lr], lr.device_id in subquery(device_product_query(product_id)))
73+
end
74+
75+
defp latest_row_query() do
76+
DeviceHealth
77+
|> select([dh], %{
78+
device_id: dh.device_id,
79+
data: dh.data,
80+
inserted_at: dh.inserted_at,
81+
rn: row_number() |> over(partition_by: dh.device_id, order_by: [desc: dh.inserted_at])
82+
})
83+
end
84+
85+
defp device_product_query(product_id) do
86+
Device
87+
|> select([:id])
88+
|> where(product_id: ^product_id)
89+
end
90+
end

lib/nerves_hub_web/components/navigation.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule NervesHubWeb.Components.Navigation do
22
use NervesHubWeb, :component
33

44
alias NervesHub.Devices
5+
alias NervesHub.Devices.Alarms
56
alias NervesHub.Products.Product
67

78
import NervesHubWeb.Components.SimpleActiveLink
@@ -115,6 +116,7 @@ defmodule NervesHubWeb.Components.Navigation do
115116

116117
if Enum.any?(links) do
117118
assigns = %{
119+
org: assigns.org,
118120
product: assigns.product,
119121
links: links
120122
}
@@ -129,8 +131,19 @@ defmodule NervesHubWeb.Components.Navigation do
129131
</.link>
130132
</li>
131133
</ul>
132-
<div :if={device_count = device_count(@product)} class="device-limit-indicator" title="Device total" aria-label="Device total">
133-
<%= device_count %>
134+
<div class="flex-row align-items-center justify-content-between">
135+
<div :if={device_count = device_count(@product)} class="navbar-indicator device-limit-indicator" title="Device total" aria-label="Device total">
136+
<%= device_count %>
137+
</div>
138+
<.link
139+
:if={alarms_count = alarms_count(@product)}
140+
navigate={~p"/org/#{@org.name}/#{@product.name}/devices?alarm_status=with"}
141+
class="navbar-indicator alarms-indicator"
142+
title="Devices alarming"
143+
aria-label="Devices alarming"
144+
>
145+
<%= alarms_count %>
146+
</.link>
134147
</div>
135148
</nav>
136149
</div>
@@ -308,4 +321,7 @@ defmodule NervesHubWeb.Components.Navigation do
308321
def device_count(_conn) do
309322
nil
310323
end
324+
325+
def alarms_count(%Product{} = product), do: Alarms.current_alarms_count(product.id)
326+
def alarms_count(_conn), do: nil
311327
end

lib/nerves_hub_web/live/devices/index.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule NervesHubWeb.Live.Devices.Index do
55

66
alias NervesHub.AuditLogs
77
alias NervesHub.Devices
8+
alias NervesHub.Devices.Alarms
89
alias NervesHub.Firmwares
910
alias NervesHub.Products.Product
1011
alias NervesHub.Tracker
@@ -27,7 +28,9 @@ defmodule NervesHubWeb.Live.Devices.Index do
2728
device_id: "",
2829
tag: "",
2930
updates: "",
30-
has_no_tags: false
31+
has_no_tags: false,
32+
alarm_status: "",
33+
alarm: ""
3134
}
3235

3336
@filter_types %{
@@ -39,7 +42,9 @@ defmodule NervesHubWeb.Live.Devices.Index do
3942
device_id: :string,
4043
tag: :string,
4144
updates: :string,
42-
has_no_tags: :boolean
45+
has_no_tags: :boolean,
46+
alarm_status: :string,
47+
alarm: :string
4348
}
4449

4550
@default_page 1
@@ -77,6 +82,7 @@ defmodule NervesHubWeb.Live.Devices.Index do
7782
|> assign(:valid_tags, true)
7883
|> assign(:device_tags, "")
7984
|> assign(:total_entries, 0)
85+
|> assign(:current_alarms, Alarms.get_current_alarm_types(product.id))
8086
|> subscribe_and_refresh_device_list_timer()
8187
|> ok()
8288
end

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@
132132
<div class="select-icon"></div>
133133
</div>
134134
</div>
135+
<div class="form-group">
136+
<label for="alarm_status">Alarm Status</label>
137+
<div class="pos-rel">
138+
<select name="alarm_status" id="alarm_status" class="form-control">
139+
<option {selected?(@current_filters, :alarm_status, "")} value="">All</option>
140+
<option {selected?(@current_filters, :alarm_status, "with")} value="with">Has Alarms</option>
141+
<option {selected?(@current_filters, :alarm_status, "without")} value="without">No alarms</option>
142+
</select>
143+
<div class="select-icon"></div>
144+
</div>
145+
</div>
146+
<div class="form-group">
147+
<label for="alarm">Alarm</label>
148+
<div class="pos-rel">
149+
<select name="alarm" id="alarm" class="form-control">
150+
<option {selected?(@current_filters, :alarm, "")} value="">All</option>
151+
<%= for alarm <- @current_alarms do %>
152+
<option {selected?(@current_filters, :alarm, alarm)}><%= alarm %></option>
153+
<% end %>
154+
</select>
155+
<div class="select-icon"></div>
156+
</div>
157+
</div>
135158
</form>
136159
<button class="btn btn-secondary" type="button" phx-click="reset-filters">Reset Filters</button>
137160
</div>

test/nerves_hub_web/live/devices/index_test.exs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,64 @@ defmodule NervesHubWeb.Live.Devices.IndexTest do
131131
refute change =~ device3.identifier
132132
end
133133

134+
test "filters devices with alarms", %{conn: conn, fixture: fixture} do
135+
%{device: device, firmware: firmware, org: org, product: product} = fixture
136+
137+
device2 = Fixtures.device_fixture(org, product, firmware)
138+
139+
{:ok, view, html} = live(conn, device_index_path(fixture))
140+
assert html =~ device.identifier
141+
assert html =~ device2.identifier
142+
assert html =~ "2 devices found"
143+
144+
device_health = %{"device_id" => device.id, "data" => %{"alarms" => %{"SomeAlarm" => []}}}
145+
assert {:ok, _} = NervesHub.Devices.save_device_health(device_health)
146+
147+
change = render_change(view, "update-filters", %{"alarm_status" => "with"})
148+
assert change =~ device.identifier
149+
refute change =~ device2.identifier
150+
assert change =~ "1 devices found"
151+
end
152+
153+
test "filters devices without alarms", %{conn: conn, fixture: fixture} do
154+
%{device: device, firmware: firmware, org: org, product: product} = fixture
155+
156+
device2 = Fixtures.device_fixture(org, product, firmware)
157+
158+
{:ok, view, html} = live(conn, device_index_path(fixture))
159+
assert html =~ device.identifier
160+
assert html =~ device2.identifier
161+
assert html =~ "2 devices found"
162+
163+
device_health = %{"device_id" => device.id, "data" => %{"alarms" => %{"SomeAlarm" => []}}}
164+
assert {:ok, _} = NervesHub.Devices.save_device_health(device_health)
165+
166+
change = render_change(view, "update-filters", %{"alarm_status" => "without"})
167+
refute change =~ device.identifier
168+
assert change =~ device2.identifier
169+
assert change =~ "1 devices found"
170+
end
171+
172+
test "filters devices with specific alarm", %{conn: conn, fixture: fixture} do
173+
%{device: device, firmware: firmware, org: org, product: product} = fixture
174+
175+
device2 = Fixtures.device_fixture(org, product, firmware)
176+
177+
{:ok, view, html} = live(conn, device_index_path(fixture))
178+
assert html =~ device.identifier
179+
assert html =~ device2.identifier
180+
assert html =~ "2 devices found"
181+
182+
alarm = "SomeAlarm"
183+
device_health = %{"device_id" => device.id, "data" => %{"alarms" => %{alarm => []}}}
184+
assert {:ok, _} = NervesHub.Devices.save_device_health(device_health)
185+
186+
change = render_change(view, "update-filters", %{"alarm" => alarm})
187+
assert change =~ device.identifier
188+
refute change =~ device2.identifier
189+
assert change =~ "1 devices found"
190+
end
191+
134192
test "select device", %{conn: conn, fixture: fixture} do
135193
%{device: _device, firmware: firmware, org: org, product: product} = fixture
136194

0 commit comments

Comments
 (0)