Skip to content

Commit 8d2fa03

Browse files
committed
A first spike at Product Insights
1 parent 6abed2d commit 8d2fa03

File tree

7 files changed

+557
-0
lines changed

7 files changed

+557
-0
lines changed

assets/ui-rework/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import en from "javascript-time-ago/locale/en"
1111
import Console from "./hooks/console.js"
1212
import SharedSecretClipboardClick from "./hooks/sharedSecretClipboardClick.js"
1313
import Chart from "./hooks/chart.js"
14+
import ConnectedDevicesAnalytics from "./hooks/connected_devices_analytics.js"
1415
import HighlightCode from "./hooks/highlightCode.js"
1516
import UpdatingTimeAgo from "./hooks/updatingTimeAgo.js"
1617
import LocalTime from "./hooks/localTime.js"
@@ -34,6 +35,7 @@ let liveSocket = new LiveSocket("/live", Socket, {
3435
Console,
3536
SharedSecretClipboardClick,
3637
Chart,
38+
ConnectedDevicesAnalytics,
3739
HighlightCode,
3840
UpdatingTimeAgo,
3941
LocalTime,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import Chart from "chart.js/auto"
2+
3+
export default {
4+
mounted() {
5+
let unit = this.el.dataset.unit
6+
7+
let label_singual = this.el.dataset.labelSingual
8+
let label_plural = this.el.dataset.labelPlural
9+
10+
let dataset = JSON.parse(this.el.dataset.metrics)
11+
12+
const ctx = this.el
13+
14+
var data = []
15+
for (let i = 0; i < dataset.length; i++) {
16+
data.push({ x: dataset[i].timestamp, y: dataset[i].count })
17+
}
18+
19+
const areaChartDataset = {
20+
type: "bar",
21+
data: {
22+
datasets: [
23+
{
24+
backgroundColor: "#6366f1",
25+
hoverBackgroundColor: "#7f9cf5",
26+
// fill: {
27+
// target: "start",
28+
// above: "rgba(99, 102, 241, 0.29)",
29+
// below: "rgba(99, 102, 241, 0.29)"
30+
// },
31+
// radius: 2
32+
barPercentage: 0.5,
33+
barThickness: 6,
34+
maxBarThickness: 8,
35+
minBarLength: 2,
36+
data: data
37+
}
38+
]
39+
},
40+
options: {
41+
plugins: {
42+
title: {
43+
display: false
44+
},
45+
legend: {
46+
display: false,
47+
labels: {
48+
display: false
49+
}
50+
},
51+
tooltip: {
52+
callbacks: {
53+
title: function(context) {
54+
date = new Date(context[0].parsed.x)
55+
return date.toLocaleTimeString("en-NZ")
56+
},
57+
label: function(context) {
58+
if (context.raw.y === 1) {
59+
unit = label_singual
60+
} else {
61+
unit = label_plural
62+
}
63+
return " " + context.formattedValue + " " + unit
64+
}
65+
}
66+
}
67+
},
68+
scales: {
69+
x: {
70+
display: false,
71+
// grid: {
72+
// display: false,
73+
// drawOnChartArea: false,
74+
// drawTicks: false
75+
// },
76+
type: "time",
77+
time: {
78+
unit: unit,
79+
displayFormats: {
80+
millisecond: "HH:mm:ss.SSS",
81+
second: "HH:mm:ss",
82+
minute: "HH:mm",
83+
hour: "HH:mm"
84+
}
85+
},
86+
ticks: {
87+
display: false
88+
}
89+
},
90+
y: {
91+
offset: false,
92+
grid: {
93+
display: false
94+
},
95+
type: "linear",
96+
min: 0,
97+
// max: max
98+
ticks: {
99+
display: false
100+
}
101+
}
102+
},
103+
responsive: true,
104+
maintainAspectRatio: false,
105+
layout: {
106+
autoPadding: false,
107+
padding: {
108+
top: 0,
109+
right: 10,
110+
bottom: 0,
111+
left: 5
112+
}
113+
}
114+
}
115+
}
116+
117+
const chart = new Chart(ctx, areaChartDataset)
118+
this.el.chart = chart
119+
120+
this.handleEvent("update-charts", function(payload) {
121+
if (payload.type == type) {
122+
chart.data.datasets[0].data = payload.data
123+
chart.update()
124+
}
125+
})
126+
127+
this.handleEvent("update-time-unit", function(payload) {
128+
chart.options.scales.x.time.unit = payload.unit
129+
chart.update()
130+
})
131+
}
132+
}

lib/nerves_hub/firmwares.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ defmodule NervesHub.Firmwares do
6868
|> Repo.all()
6969
end
7070

71+
@spec get_installed_firmwares(Product.t(), [String.t()]) :: [Firmware.t()]
72+
def get_installed_firmwares(product, uuids) do
73+
subquery =
74+
Device
75+
|> select([d], %{
76+
firmware_uuid: fragment("? ->> 'uuid'", d.firmware_metadata),
77+
install_count: count(fragment("? ->> 'uuid'", d.firmware_metadata), :distinct)
78+
})
79+
|> where([d], not is_nil(d.firmware_metadata))
80+
|> where([d], not is_nil(fragment("? ->> 'uuid'", d.firmware_metadata)))
81+
|> Repo.exclude_deleted()
82+
|> group_by([d], fragment("? ->> 'uuid'", d.firmware_metadata))
83+
84+
Firmware
85+
|> join(:left, [f], d in subquery(subquery), on: d.firmware_uuid == f.uuid)
86+
|> where([f], f.product_id == ^product.id)
87+
|> where([f], f.uuid in ^uuids)
88+
|> select_merge([_f, d], %{install_count: d.install_count})
89+
|> order_by([_f, d], d.install_count)
90+
|> Repo.all()
91+
end
92+
7193
@spec filter(Product.t(), map()) :: {[Product.t()], Flop.Meta.t()}
7294
def filter(product, opts \\ %{}) do
7395
opts = Map.reject(opts, fn {_key, val} -> is_nil(val) end)

lib/nerves_hub/insights.ex

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
defmodule NervesHub.Insights do
2+
@moduledoc """
3+
Queries for Product Insights.
4+
5+
Some useful reads, which may inspire future improvements:
6+
- https://danschultzer.com/posts/normalize-chart-data-using-ecto-postgresql
7+
- https://danschultzer.com/posts/echarts-phoenix-liveview
8+
"""
9+
import Ecto.Query
10+
11+
alias NervesHub.Devices.Device
12+
alias NervesHub.Devices.DeviceConnection
13+
alias NervesHub.Products.Product
14+
15+
alias NervesHub.Repo
16+
17+
@doc """
18+
Count of currently connected Devices, scoped to an individual Product.
19+
"""
20+
@spec connected_count(product :: NervesHub.Products.Product.t()) :: integer()
21+
def connected_count(product) do
22+
Device
23+
|> join(:left, [d], lc in assoc(d, :latest_connection), as: :latest_connection)
24+
|> where([d], d.product_id == ^product.id)
25+
|> where([_, latest_connection: lc], lc.status == :connected)
26+
|> Repo.aggregate(:count)
27+
end
28+
29+
@doc """
30+
Count of connected Devices over the last 24 hours, in 1 minute intervals, scoped to an individual Product.
31+
"""
32+
@spec connected_device_counts_last_24_hours(product :: Product.t()) :: list()
33+
def connected_device_counts_last_24_hours(product) do
34+
device_counts_query =
35+
DeviceConnection
36+
|> select([dc], %{
37+
device_count: count(dc.id, :distinct)
38+
})
39+
|> where([dc], dc.product_id == ^product.id)
40+
|> where(
41+
[dc],
42+
fragment(
43+
"? BETWEEN DATE_BIN('1 minute'::interval, ?, 'epoch') AND DATE_BIN('1 minute'::interval, ?, 'epoch') + '1 minute'::interval",
44+
dc.established_at,
45+
parent_as(:series).timestamp,
46+
parent_as(:series).timestamp
47+
) or
48+
fragment(
49+
"? BETWEEN DATE_BIN('1 minute'::interval, ?, 'epoch') AND DATE_BIN('1 minute'::interval, ?, 'epoch') + '1 minute'::interval",
50+
dc.disconnected_at,
51+
parent_as(:series).timestamp,
52+
parent_as(:series).timestamp
53+
) or
54+
(fragment(
55+
"? < DATE_BIN('1 minute'::interval, ?, 'epoch')",
56+
dc.established_at,
57+
parent_as(:series).timestamp
58+
) and
59+
fragment(
60+
"? > DATE_BIN('1 minute'::interval, ?, 'epoch') + '1 minute'::interval",
61+
dc.disconnected_at,
62+
parent_as(:series).timestamp
63+
)) or
64+
(fragment(
65+
"? < DATE_BIN('1 minute'::interval, ?, 'epoch')",
66+
dc.established_at,
67+
parent_as(:series).timestamp
68+
) and is_nil(dc.disconnected_at))
69+
)
70+
|> group_by([dc], dc.product_id)
71+
72+
from(fragment("GENERATE_SERIES(NOW() - '1 hour'::interval, NOW(), '1 minute'::interval)"),
73+
as: :series
74+
)
75+
|> join(:left_lateral, [], subquery(device_counts_query), on: true, as: :counts)
76+
|> select([gs, counts: dc], %{
77+
timestamp:
78+
fragment("DATE_BIN('1 minute'::interval, ?, 'epoch')", gs) |> selected_as(:timestamp),
79+
count: coalesce(dc.device_count, 0)
80+
})
81+
|> order_by([series: gs], asc: gs.timestamp)
82+
|> Repo.all()
83+
end
84+
85+
@doc """
86+
Count of devices where the health status isn't 'healthy' or 'unknown', scoped to an individual Product.
87+
"""
88+
@spec not_healthy_devices_count(product :: NervesHub.Products.Product.t()) :: integer()
89+
def not_healthy_devices_count(product) do
90+
Device
91+
|> join(:left, [d], lc in assoc(d, :latest_health), as: :latest_health)
92+
|> where([d], d.product_id == ^product.id)
93+
|> where([_, latest_health: lh], lh.status in [:warning, :unhealthy])
94+
|> Repo.aggregate(:count)
95+
end
96+
97+
@doc """
98+
Count of devices which haven't connected in the last 24 hours, scoped to an individual Product.
99+
"""
100+
@spec not_recently_seen_count(product :: NervesHub.Products.Product.t()) :: integer()
101+
def not_recently_seen_count(product) do
102+
Device
103+
|> join(:left, [d], lc in assoc(d, :latest_connection), as: :latest_connection)
104+
|> where([d], d.product_id == ^product.id)
105+
|> where([_, latest_connection: lc], lc.status == :disconnected)
106+
|> where([_, latest_connection: lc], lc.disconnected_at < ago(24, "hour"))
107+
|> Repo.aggregate(:count)
108+
end
109+
110+
@doc """
111+
Count of different firmware versions run on connected devices, scoped to an individual Product.
112+
"""
113+
@spec active_firmware_versions_count(product :: NervesHub.Products.Product.t()) :: integer()
114+
def active_firmware_versions_count(product) do
115+
Device
116+
|> select([d], %{version: d.firmware_metadata["uuid"]})
117+
|> distinct(true)
118+
|> where([d], d.product_id == ^product.id)
119+
|> Repo.aggregate(:count)
120+
end
121+
122+
@doc """
123+
List of unique firmware versions run on connected devices, scoped to an individual Product.
124+
"""
125+
@spec active_firmware_versions(product :: NervesHub.Products.Product.t()) :: integer()
126+
def active_firmware_versions(product) do
127+
Device
128+
|> select([d], %{version: d.firmware_metadata["uuid"]})
129+
|> distinct(true)
130+
|> where([d], d.product_id == ^product.id)
131+
|> Repo.all()
132+
end
133+
134+
@doc """
135+
Count of devices which have reconnected more than 5 times in the last 24 hours, scoped to an individual Product.
136+
"""
137+
@spec high_reconnect_count(product :: NervesHub.Products.Product.t()) :: integer()
138+
def high_reconnect_count(product) do
139+
Device
140+
|> join(:left, [d], lc in assoc(d, :device_connections), as: :device_connections)
141+
|> where([d], d.product_id == ^product.id)
142+
|> where([_, device_connections: dc], dc.established_at > ago(24, "hour"))
143+
|> group_by([d], d.id)
144+
|> having([d, device_connections: dc], count(dc.id) > 5)
145+
|> subquery()
146+
|> Repo.aggregate(:count)
147+
end
148+
end

0 commit comments

Comments
 (0)