diff --git a/config/runtime.exs b/config/runtime.exs index 430f8280d..5cf3463c8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -25,9 +25,6 @@ config :nerves_hub, username: System.get_env("ADMIN_AUTH_USERNAME"), password: System.get_env("ADMIN_AUTH_PASSWORD") ], - device_health_check_enabled: System.get_env("DEVICE_HEALTH_CHECK_ENABLED", "true") == "true", - device_health_check_interval_minutes: - String.to_integer(System.get_env("DEVICE_HEALTH_CHECK_INTERVAL_MINUTES", "60")), device_health_days_to_retain: String.to_integer(System.get_env("HEALTH_CHECK_DAYS_TO_RETAIN", "7")), device_deployment_change_jitter_seconds: @@ -37,7 +34,18 @@ config :nerves_hub, deployment_calculator_interval_seconds: String.to_integer(System.get_env("DEPLOYMENT_CALCULATOR_INTERVAL_SECONDS", "3600")), mapbox_access_token: System.get_env("MAPBOX_ACCESS_TOKEN"), - dashboard_enabled: System.get_env("DASHBOARD_ENABLED", "false") == "true" + dashboard_enabled: System.get_env("DASHBOARD_ENABLED", "false") == "true", + extension_config: [ + geo: [ + # No interval, fetch geo on device connection by default + interval_minutes: + System.get_env("FEATURES_GEO_INTERVAL_MINUTES", "0") |> String.to_integer() + ], + health: [ + interval_minutes: + System.get_env("FEATURES_HEALTH_INTERVAL_MINUTES", "60") |> String.to_integer() + ] + ] config :nerves_hub, :device_socket_drainer, batch_size: String.to_integer(System.get_env("DEVICE_SOCKET_DRAINER_BATCH_SIZE", "1000")), diff --git a/config/test.exs b/config/test.exs index fe1bac8de..89012346f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -87,6 +87,8 @@ config :nerves_hub, NervesHub.SwooshMailer, adapter: Swoosh.Adapters.Test config :nerves_hub, NervesHub.RateLimit, limit: 100 +config :sentry, environment_name: :test + config :phoenix_test, :endpoint, NervesHubWeb.Endpoint # Initialize plugs at runtime for faster test compilation diff --git a/lib/nerves_hub/devices.ex b/lib/nerves_hub/devices.ex index dafc826b6..fab2dee6b 100644 --- a/lib/nerves_hub/devices.ex +++ b/lib/nerves_hub/devices.ex @@ -1383,4 +1383,33 @@ defmodule NervesHub.Devices do "pending" end end + + def enable_extension_setting(%Device{} = device, extension_string) do + device = Repo.get(Device, device.id) + + Device.changeset(device, %{"extensions" => %{extension_string => true}}) + |> Repo.update() + |> tap(fn _ -> + topic = "device:#{device.id}:extensions" + + NervesHubWeb.DeviceEndpoint.broadcast(topic, "attach", %{"extensions" => [extension_string]}) + end) + end + + def disable_extension_setting(%Device{} = device, extension_string) do + device = Repo.get(Device, device.id) + + Device.changeset(device, %{"extensions" => %{extension_string => false}}) + |> Repo.update() + |> tap(fn _ -> + topic = "device:#{device.id}:extensions" + + NervesHubWeb.DeviceEndpoint.broadcast(topic, "detach", %{"extensions" => [extension_string]}) + end) + end + + def preload_product(%Device{} = device) do + device + |> Repo.preload(:product) + end end diff --git a/lib/nerves_hub/devices/device.ex b/lib/nerves_hub/devices/device.ex index a12db4474..c202e4ae8 100644 --- a/lib/nerves_hub/devices/device.ex +++ b/lib/nerves_hub/devices/device.ex @@ -7,6 +7,7 @@ defmodule NervesHub.Devices.Device do alias NervesHub.Devices.DeviceCertificate alias NervesHub.Devices.DeviceConnection alias NervesHub.Deployments.Deployment + alias NervesHub.Extensions.ExtensionsSetting alias NervesHub.Firmwares.FirmwareMetadata alias NervesHub.Products.Product @@ -71,12 +72,14 @@ defmodule NervesHub.Devices.Device do field(:connection_established_at, :utc_datetime) field(:connection_disconnected_at, :utc_datetime) field(:connection_last_seen_at, :utc_datetime) + embeds_one(:extensions, ExtensionsSetting) end def changeset(%Device{} = device, params) do device |> cast(params, @required_params ++ @optional_params) |> cast_embed(:firmware_metadata) + |> cast_embed(:extensions) |> validate_required(@required_params) |> validate_length(:tags, min: 1) |> unique_constraint(:identifier) diff --git a/lib/nerves_hub/devices/metrics.ex b/lib/nerves_hub/devices/metrics.ex index 35a72640c..4fa907178 100644 --- a/lib/nerves_hub/devices/metrics.ex +++ b/lib/nerves_hub/devices/metrics.ex @@ -6,6 +6,7 @@ defmodule NervesHub.Devices.Metrics do @default_metric_types [ :cpu_temp, + :cpu_usage_percent, :load_15min, :load_1min, :load_5min, diff --git a/lib/nerves_hub/extensions.ex b/lib/nerves_hub/extensions.ex new file mode 100644 index 000000000..a7239d925 --- /dev/null +++ b/lib/nerves_hub/extensions.ex @@ -0,0 +1,52 @@ +defmodule NervesHub.Extensions do + @moduledoc """ + An "extension" is an additional piece of functionality that we add onto the + existing connection between the device and the NervesHub service. They are + designed to be less important than firmware updates and requires both client + to report support and the server to enable support. + + This is intended to ensure that: + + - The service decides when activity should be taken by the device meaning + the fleet of devices will not inadvertently swarm the service with data. + - The service can turn off extensions in various ways to ensure that disruptive + extensions stop being enabled on subsequent connections. + - Use of extensions should have very little chance to disrupt the flow of a + critical firmware update. + """ + + @callback handle_in(event :: String.t(), Phoenix.Channel.payload(), Phoenix.Socket.t()) :: + {:noreply, Phoenix.Socket.t()} + | {:noreply, Phoenix.Socket.t(), timeout() | :hibernate} + | {:reply, Phoenix.Channel.reply(), Phoenix.Socket.t()} + | {:stop, reason :: term(), Phoenix.Socket.t()} + | {:stop, reason :: term(), Phoenix.Channel.reply(), Phoenix.Socket.t()} + + @callback handle_info(msg :: term(), Phoenix.Socket.t()) :: + {:noreply, Phoenix.Socket.t()} | {:stop, reason :: term(), Phoenix.Socket.t()} + + @callback attach(Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} + @callback detach(Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} + + require Logger + + @supported_extensions %{ + health: """ + Reporting of fundamental device metrics, metadata, alarms and more. + Also supports custom metrics. Alarms require an alarm handler to be set. + """, + geo: """ + Reporting of GeoIP information or custom geo-location information sources + you've set up for your device. + """ + } + @type extension() :: :health | :geo + + @doc """ + Get list of supported extensions as atoms with descriptive text. + """ + @spec list() :: %{extension() => String.t()} + def list do + @supported_extensions + end +end diff --git a/lib/nerves_hub/extensions/extensions_setting.ex b/lib/nerves_hub/extensions/extensions_setting.ex new file mode 100644 index 000000000..d21e354b3 --- /dev/null +++ b/lib/nerves_hub/extensions/extensions_setting.ex @@ -0,0 +1,15 @@ +defmodule NervesHub.Extensions.ExtensionsSetting do + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:health, :boolean, default: nil) + field(:geo, :boolean, default: nil) + end + + def changeset(setting, params) do + setting + |> cast(params, [:health, :geo]) + end +end diff --git a/lib/nerves_hub/extensions/geo.ex b/lib/nerves_hub/extensions/geo.ex new file mode 100644 index 000000000..d274c6e89 --- /dev/null +++ b/lib/nerves_hub/extensions/geo.ex @@ -0,0 +1,57 @@ +defmodule NervesHub.Extensions.Geo do + @behaviour NervesHub.Extensions + + alias NervesHub.Devices + + @impl NervesHub.Extensions + def attach(socket) do + extension_config = Application.get_env(:nerves_hub, :extension_config) + geo_interval = get_in(extension_config, [:geo, :interval_minutes]) || 0 + + send(self(), {__MODULE__, :location_request}) + + socket = + if geo_interval > 0 do + timer = + geo_interval + |> :timer.minutes() + |> :timer.send_interval({__MODULE__, :location_request}) + + socket + |> Phoenix.Socket.assign(:geo_timer, timer) + |> Phoenix.Socket.assign(:geo_interval, geo_interval) + else + socket + end + + {:noreply, socket} + end + + @impl NervesHub.Extensions + def detach(socket) do + _ = if socket.assigns[:geo_timer], do: :timer.cancel(socket.assigns.geo_timer) + {:noreply, Phoenix.Socket.assign(socket, :geo_timer, nil)} + end + + @impl NervesHub.Extensions + def handle_in("location:update", location, %{assigns: %{device: device}} = socket) do + metadata = Map.put(device.connection_metadata, "location", location) + + {:ok, device} = Devices.update_device(device, %{connection_metadata: metadata}) + + _ = + NervesHubWeb.DeviceEndpoint.broadcast( + "device:#{device.identifier}:internal", + "location:updated", + location + ) + + {:noreply, Phoenix.Socket.assign(socket, :device, device)} + end + + @impl NervesHub.Extensions + def handle_info(:location_request, socket) do + Phoenix.Channel.push(socket, "geo:location:request", %{}) + {:noreply, socket} + end +end diff --git a/lib/nerves_hub/extensions/health.ex b/lib/nerves_hub/extensions/health.ex new file mode 100644 index 000000000..bccabac23 --- /dev/null +++ b/lib/nerves_hub/extensions/health.ex @@ -0,0 +1,113 @@ +defmodule NervesHub.Extensions.Health do + @behaviour NervesHub.Extensions + + alias NervesHub.Devices + alias NervesHub.Devices.Metrics + + require Logger + + # @impl NervesHub.Extensions + def init(socket) do + # Allow DB settings? + socket + end + + @impl NervesHub.Extensions + def attach(socket) do + extension_config = Application.get_env(:nerves_hub, :extension_config, []) + + health_interval = + case get_in(extension_config, [:health, :interval_minutes]) do + i when is_integer(i) -> i + _ -> 60 + end + + send(self(), {__MODULE__, :check}) + + socket = + if health_interval > 0 do + timer = + health_interval + |> :timer.minutes() + |> :timer.send_interval({__MODULE__, :check}) + + socket + |> Phoenix.Socket.assign(:health_interval, health_interval) + |> Phoenix.Socket.assign(:health_timer, timer) + else + socket + end + + {:noreply, socket} + end + + @impl NervesHub.Extensions + def detach(socket) do + _ = if socket.assigns[:health_timer], do: :timer.cancel(socket.assigns.health_timer) + {:noreply, Phoenix.Socket.assign(socket, :health_timer, nil)} + end + + @impl NervesHub.Extensions + def handle_in("report", %{"value" => device_status}, socket) do + device_meta = + for {key, val} <- Map.from_struct(socket.assigns.device.firmware_metadata), + into: %{}, + do: {to_string(key), to_string(val)} + + # Separate metrics from health report to store in metrics table + metrics = device_status["metrics"] + + health_report = + device_status + |> Map.delete("metrics") + |> Map.put("metadata", Map.merge(device_status["metadata"], device_meta)) + + device_health = %{"device_id" => socket.assigns.device.id, "data" => health_report} + + with {:health_report, {:ok, _}} <- + {:health_report, Devices.save_device_health(device_health)}, + {:metrics_report, {count, _}} when count >= 0 <- + {:metrics_report, Metrics.save_metrics(socket.assigns.device.id, metrics)} do + device_internal_broadcast!(socket.assigns.device, "health_check_report", %{}) + else + {:health_report, {:error, err}} -> + Logger.warning("Failed to save health check data: #{inspect(err)}") + log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save health check data.") + + {:metrics_report, {:error, err}} -> + Logger.warning("Failed to save metrics: #{inspect(err)}") + log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save metrics.") + end + + {:noreply, socket} + end + + @impl NervesHub.Extensions + def handle_info(:check, socket) do + Phoenix.Channel.push(socket, "health:check", %{}) + {:noreply, socket} + end + + defp device_internal_broadcast!(device, event, payload) do + topic = "device:#{device.identifier}:extensions" + NervesHubWeb.DeviceEndpoint.broadcast_from!(self(), topic, event, payload) + end + + defp log_to_sentry(device, msg_or_ex, extra \\ %{}) do + Sentry.Context.set_tags_context(%{ + device_identifier: device.identifier, + device_id: device.id, + product_id: device.product_id, + org_id: device.org_id + }) + + _ = + if is_exception(msg_or_ex) do + Sentry.capture_exception(msg_or_ex, extra: extra, result: :none) + else + Sentry.capture_message(msg_or_ex, extra: extra, result: :none) + end + + :ok + end +end diff --git a/lib/nerves_hub/products.ex b/lib/nerves_hub/products.ex index 87bba04b0..4292aa927 100644 --- a/lib/nerves_hub/products.ex +++ b/lib/nerves_hub/products.ex @@ -269,4 +269,28 @@ defmodule NervesHub.Products do end end end + + def enable_extension_setting(%Product{} = product, extension_string) do + product = Repo.get(Product, product.id) + + Product.changeset(product, %{"extensions" => %{extension_string => true}}) + |> Repo.update() + |> tap(fn _ -> + topic = "product:#{product.id}:extensions" + + NervesHubWeb.DeviceEndpoint.broadcast(topic, "attach", %{"extensions" => [extension_string]}) + end) + end + + def disable_extension_setting(%Product{} = product, extension_string) do + product = Repo.get(Product, product.id) + + Product.changeset(product, %{"extensions" => %{extension_string => false}}) + |> Repo.update() + |> tap(fn _ -> + topic = "product:#{product.id}:extensions" + + NervesHubWeb.DeviceEndpoint.broadcast(topic, "detach", %{"extensions" => [extension_string]}) + end) + end end diff --git a/lib/nerves_hub/products/product.ex b/lib/nerves_hub/products/product.ex index 92692d21a..58adccae4 100644 --- a/lib/nerves_hub/products/product.ex +++ b/lib/nerves_hub/products/product.ex @@ -7,6 +7,7 @@ defmodule NervesHub.Products.Product do alias NervesHub.Scripts.Script alias NervesHub.Devices.CACertificate alias NervesHub.Devices.Device + alias NervesHub.Extensions.ExtensionsSetting alias NervesHub.Firmwares.Firmware alias NervesHub.Products.SharedSecretAuth @@ -31,6 +32,7 @@ defmodule NervesHub.Products.Product do field(:name, :string) field(:deleted_at, :utc_datetime) field(:delta_updatable, :boolean, default: false) + embeds_one(:extensions, ExtensionsSetting, on_replace: :update) timestamps() end @@ -44,6 +46,7 @@ defmodule NervesHub.Products.Product do def changeset(product, params) do product |> cast(params, @required_params ++ @optional_params) + |> cast_embed(:extensions) |> update_change(:name, &trim/1) |> validate_required(@required_params) |> unique_constraint(:name, name: :products_org_id_name_index) diff --git a/lib/nerves_hub_web/channels/device_channel.ex b/lib/nerves_hub_web/channels/device_channel.ex index ccefdbf7d..6954c4759 100644 --- a/lib/nerves_hub_web/channels/device_channel.ex +++ b/lib/nerves_hub_web/channels/device_channel.ex @@ -15,7 +15,6 @@ defmodule NervesHubWeb.DeviceChannel do alias NervesHub.Deployments alias NervesHub.Devices alias NervesHub.Devices.Device - alias NervesHub.Devices.Metrics alias NervesHub.Firmwares alias NervesHub.Repo alias Phoenix.Socket.Broadcast @@ -48,13 +47,11 @@ defmodule NervesHubWeb.DeviceChannel do subscribe("device:#{device.id}") subscribe(deployment_channel) - if device_health_check_enabled?() do - send(self(), :health_check) - schedule_health_check() - end - send(self(), :device_registation) + # Get device extension capabilities + push(socket, "extensions:get", %{}) + socket = socket |> assign(:device, device) @@ -316,12 +313,6 @@ defmodule NervesHubWeb.DeviceChannel do {:noreply, socket} end - def handle_info(:health_check, socket) do - push(socket, "check_health", %{}) - schedule_health_check() - {:noreply, socket} - end - def handle_info(%Broadcast{event: "connection:heartbeat"}, socket) do # Expected message that is not used here :) {:noreply, socket} @@ -364,19 +355,6 @@ defmodule NervesHubWeb.DeviceChannel do end end - @decorate with_span("Channels.DeviceChannel.handle_in:location:update") - def handle_in("location:update", location, %{assigns: %{device: device}} = socket) do - metadata = Map.put(device.connection_metadata, "location", location) - - {:ok, device} = Devices.update_device(device, %{connection_metadata: metadata}) - - socket = assign(socket, :device, device) - - device_internal_broadcast!(socket, device, "location:updated", location) - - {:reply, :ok, socket} - end - def handle_in("connection_types", %{"values" => types}, %{assigns: %{device: device}} = socket) do {:ok, device} = Devices.update_device(device, %{"connection_types" => types}) {:noreply, assign(socket, :device, device)} @@ -415,41 +393,6 @@ defmodule NervesHubWeb.DeviceChannel do {:noreply, socket} end - @decorate with_span("Channels.DeviceChannel.handle_in:health_check_report") - def handle_in("health_check_report", %{"value" => device_status}, socket) do - device_meta = - for {key, val} <- Map.from_struct(socket.assigns.device.firmware_metadata), - into: %{}, - do: {to_string(key), to_string(val)} - - # Separate metrics from health report to store in metrics table - metrics = device_status["metrics"] - - health_report = - device_status - |> Map.delete("metrics") - |> Map.put("metadata", Map.merge(device_status["metadata"], device_meta)) - - device_health = %{"device_id" => socket.assigns.device.id, "data" => health_report} - - with {:health_report, {:ok, _}} <- - {:health_report, Devices.save_device_health(device_health)}, - {:metrics_report, {:ok, _}} <- - {:metrics_report, Metrics.save_metrics(socket.assigns.device.id, metrics)} do - device_internal_broadcast!(socket, socket.assigns.device, "health_check_report", %{}) - else - {:health_report, {:error, err}} -> - Logger.warning("Failed to save health check data: #{inspect(err)}") - log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save health check data.") - - {:metrics_report, :error} -> - Logger.warning("Failed to save metrics") - log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save metrics.") - end - - {:noreply, socket} - end - def handle_in(msg, params, socket) do # Ignore unhandled messages so that it doesn't crash the link process # preventing cascading problems. @@ -493,7 +436,7 @@ defmodule NervesHubWeb.DeviceChannel do |> Deployments.set_deployment() end - defp log_to_sentry(device, message, extra \\ %{}) do + defp log_to_sentry(device, message, extra) do Sentry.Context.set_tags_context(%{ device_identifier: device.identifier, device_id: device.id, @@ -660,24 +603,6 @@ defmodule NervesHubWeb.DeviceChannel do socket end - defp schedule_health_check() do - if device_health_check_enabled?() do - Process.send_after(self(), :health_check, device_health_check_interval()) - :ok - else - :ok - end - end - - defp device_health_check_enabled?() do - Application.get_env(:nerves_hub, :device_health_check_enabled) - end - - defp device_health_check_interval() do - Application.get_env(:nerves_hub, :device_health_check_interval_minutes) - |> :timer.minutes() - end - defp device_deployment_change_jitter_ms() do jitter = Application.get_env(:nerves_hub, :device_deployment_change_jitter_seconds) diff --git a/lib/nerves_hub_web/channels/device_socket.ex b/lib/nerves_hub_web/channels/device_socket.ex index 3293c042d..d11c1a2d5 100644 --- a/lib/nerves_hub_web/channels/device_socket.ex +++ b/lib/nerves_hub_web/channels/device_socket.ex @@ -15,6 +15,7 @@ defmodule NervesHubWeb.DeviceSocket do channel("console", NervesHubWeb.ConsoleChannel) channel("device", NervesHubWeb.DeviceChannel) + channel("extensions", NervesHubWeb.ExtensionsChannel) # Default 90 seconds max age for the signature @default_max_hmac_age 90 @@ -94,7 +95,7 @@ defmodule NervesHubWeb.DeviceSocket do |> Devices.get_device_certificate_by_x509() |> case do {:ok, %{device: %Device{} = device}} -> - socket_and_assigns(socket, device) + socket_and_assigns(socket, Devices.preload_product(device)) error -> :telemetry.execute([:nerves_hub, :devices, :invalid_auth], %{count: 1}, %{ @@ -118,7 +119,7 @@ defmodule NervesHubWeb.DeviceSocket do {:ok, signature} <- Map.fetch(headers, "x-nh-signature"), {:ok, identifier} <- Crypto.verify(auth.secret, salt, signature, verification_opts), {:ok, device} <- get_or_maybe_create_device(auth, identifier) do - socket_and_assigns(socket, device) + socket_and_assigns(socket, Devices.preload_product(device)) else error -> :telemetry.execute([:nerves_hub, :devices, :invalid_auth], %{count: 1}, %{ diff --git a/lib/nerves_hub_web/channels/extensions_channel.ex b/lib/nerves_hub_web/channels/extensions_channel.ex new file mode 100644 index 000000000..88694898d --- /dev/null +++ b/lib/nerves_hub_web/channels/extensions_channel.ex @@ -0,0 +1,159 @@ +defmodule NervesHubWeb.ExtensionsChannel do + use Phoenix.Channel + + alias Phoenix.Socket.Broadcast + + require Logger + + @impl Phoenix.Channel + def join("extensions", extension_versions, socket) do + extensions = parse_extensions(socket.assigns.device, extension_versions) + socket = assign(socket, :extensions, extensions) + + attach_list = for {key, %{attach?: true}} <- extensions, do: key + + if length(attach_list) > 0 do + send(self(), :init_extensions) + end + + topic = "device:#{socket.assigns.device.id}:extensions" + NervesHubWeb.DeviceEndpoint.subscribe(topic) + + {:ok, attach_list, socket} + end + + defp parse_extensions( + %{extensions: device_extensions, product: %{extensions: product_extensions}}, + extension_versions + ) do + allowed_extensions = + product_extensions + |> Map.from_struct() + |> Enum.filter(fn {extension, enabled?} -> + enabled? == true and Map.get(device_extensions, extension) != false + end) + |> Enum.map(&elem(&1, 0)) + + for {key_str, version} <- extension_versions, into: %{} do + meta = + case Version.parse(version) do + {:ok, ver} -> + extension = Enum.find(allowed_extensions, &(to_string(&1) == key_str)) + + if extension do + mod = extension_module(extension, ver) + %{attach?: Code.ensure_loaded?(mod), version: ver, module: mod, status: :detached} + else + %{attach?: false, version: version, module: nil, status: :detached} + end + + _ -> + %{attach?: false, version: version, module: nil, status: :detached} + end + + {key_str, meta} + end + end + + defp extension_module(:health, ver) do + cond do + Version.match?(ver, "~> 0.0.1") -> NervesHub.Extensions.Health + true -> :unsupported + end + end + + defp extension_module(:geo, ver) do + cond do + Version.match?(ver, "~> 0.0.1") -> NervesHub.Extensions.Geo + true -> :unsupported + end + end + + defp extension_module(_key, _ver) do + :unsupported + end + + @impl Phoenix.Channel + def handle_in(scoped_event, payload, socket) do + with [key, event] <- String.split(scoped_event, ":", parts: 2), + %{attach?: true, module: mod} <- socket.assigns.extensions[key] do + case event do + "attached" -> + update_in(socket.assigns.extensions[key], &%{&1 | status: :attached}) + |> mod.attach() + + "detached" -> + update_in(socket.assigns.extensions[key], &%{&1 | status: :detached}) + |> mod.detach() + + "error" -> + socket = update_in(socket.assigns.extensions[key], &%{&1 | status: :detached}) + safe_handle_in(mod, event, payload, socket) + + event -> + safe_handle_in(mod, event, payload, socket) + end + else + _ -> + # Unknown extension, tell device to detach it + {:reply, {:error, "detach"}, socket} + end + end + + defp safe_handle_in(mod, event, payload, socket) do + mod.handle_in(event, payload, socket) + rescue + error -> + Logger.warning("#{inspect(mod)} failed to handle extension message - #{inspect(error)}") + log_to_sentry(socket.assigns.device, error) + {:noreply, socket} + end + + @impl Phoenix.Channel + def handle_info(:init_extensions, socket) do + topic = "product:#{socket.assigns.device.product.id}:extensions" + NervesHubWeb.DeviceEndpoint.subscribe(topic) + + socket = + for {_extension, %{attach?: true, mod: mod}} <- socket.assigns.extensions, reduce: socket do + acc -> + mod.init(acc) + end + + {:noreply, socket} + end + + def handle_info(%Broadcast{event: event, payload: payload}, socket) do + push(socket, event, payload) + {:noreply, socket} + end + + def handle_info({mod, msg}, socket) do + mod.handle_info(msg, socket) + rescue + error -> + Logger.warning("#{inspect(mod)} failed handle_info - #{inspect(error)}") + log_to_sentry(socket.assigns.device, error) + {:noreply, socket} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + defp log_to_sentry(device, msg_or_ex, extra \\ %{}) do + Sentry.Context.set_tags_context(%{ + device_identifier: device.identifier, + device_id: device.id, + product_id: device.product_id, + org_id: device.org_id + }) + + _ = + if is_exception(msg_or_ex) do + Sentry.capture_exception(msg_or_ex, extra: extra, result: :none) + else + Sentry.capture_message(msg_or_ex, extra: extra, result: :none) + end + + :ok + end +end diff --git a/lib/nerves_hub_web/components/utils.ex b/lib/nerves_hub_web/components/utils.ex index 532790880..e89ea7f38 100644 --- a/lib/nerves_hub_web/components/utils.ex +++ b/lib/nerves_hub_web/components/utils.ex @@ -30,6 +30,14 @@ defmodule NervesHubWeb.Components.Utils do end end + def cpu_usage_percent_to_status(usage) do + case usage do + usage when usage < 80 -> "" + usage when usage < 90 -> "warn" + _ -> "danger" + end + end + def memory_to_status(percent) do case percent do _ when percent > 80 -> "warn" diff --git a/lib/nerves_hub_web/live/devices/device_health.ex b/lib/nerves_hub_web/live/devices/device_health.ex index f108d487d..577d50c73 100644 --- a/lib/nerves_hub_web/live/devices/device_health.ex +++ b/lib/nerves_hub_web/live/devices/device_health.ex @@ -34,6 +34,7 @@ defmodule NervesHubWeb.Live.Devices.DeviceHealth do if connected?(socket) do socket.endpoint.subscribe("device:#{device.identifier}:internal") + socket.endpoint.subscribe("device:#{device.identifier}:extensions") end socket diff --git a/lib/nerves_hub_web/live/devices/settings.ex b/lib/nerves_hub_web/live/devices/settings.ex index 4cef510d7..b0c8ae736 100644 --- a/lib/nerves_hub_web/live/devices/settings.ex +++ b/lib/nerves_hub_web/live/devices/settings.ex @@ -6,6 +6,7 @@ defmodule NervesHubWeb.Live.Devices.Settings do alias NervesHub.Certificate alias NervesHub.Devices + alias NervesHub.Extensions alias NervesHub.Repo def mount(%{"device_identifier" => device_identifier}, _session, socket) do @@ -15,6 +16,7 @@ defmodule NervesHubWeb.Live.Devices.Settings do device_identifier, :device_certificates ) + |> Devices.preload_product() changeset = Ecto.Changeset.change(device) @@ -22,6 +24,7 @@ defmodule NervesHubWeb.Live.Devices.Settings do |> page_title("Device Settings #{device.identifier} - #{socket.assigns.product.name}") |> assign(:toggle_upload, false) |> assign(:device, device) + |> assign(:available_extensions, Extensions.list()) |> assign(:form, to_form(changeset)) |> assign(:tab_hint, :devices) |> allow_upload(:certificate, @@ -83,6 +86,32 @@ defmodule NervesHubWeb.Live.Devices.Settings do end end + def handle_event("update-extension", %{"extension" => extension} = params, socket) do + value = params["value"] + available = Extensions.list() |> Map.keys() |> Enum.map(&to_string/1) + + result = + case {extension in available, value} do + {true, "on"} -> + Devices.enable_extension_setting(socket.assigns.device, extension) + + {true, _} -> + Devices.disable_extension_setting(socket.assigns.device, extension) + end + + socket = + case result do + {:ok, _pf} -> + # reload extensions + assign(socket, :extensions, Extensions.list()) + + {:error, _changeset} -> + put_flash(socket, :error, "Failed to set extension") + end + + {:noreply, socket} + end + def handle_progress(:certificate, %{done?: true} = entry, socket) do socket = socket diff --git a/lib/nerves_hub_web/live/devices/settings.html.heex b/lib/nerves_hub_web/live/devices/settings.html.heex index 9790a734f..9a0a67e99 100644 --- a/lib/nerves_hub_web/live/devices/settings.html.heex +++ b/lib/nerves_hub_web/live/devices/settings.html.heex @@ -65,6 +65,48 @@
+
+

Extensions

+
+ + + + + + + + + + + + + + +
NameDescription
+ + +
Name
+ <%= key %> +
+
Description
+

+ Extension is disabled at the product level. +

+

<%= description %>

+
+ +
+

Certificates

<%= if @toggle_upload do %> @@ -102,7 +144,7 @@
<% end %> - +
diff --git a/lib/nerves_hub_web/live/devices/show.ex b/lib/nerves_hub_web/live/devices/show.ex index 67075a6b4..1b5e9c829 100644 --- a/lib/nerves_hub_web/live/devices/show.ex +++ b/lib/nerves_hub_web/live/devices/show.ex @@ -25,6 +25,7 @@ defmodule NervesHubWeb.Live.Devices.Show do if connected?(socket) do socket.endpoint.subscribe("device:#{device.identifier}:internal") socket.endpoint.subscribe("device:console:#{device.id}:internal") + socket.endpoint.subscribe("device:#{device.identifier}:extensions") socket.endpoint.subscribe("firmware") end diff --git a/lib/nerves_hub_web/live/devices/show.html.heex b/lib/nerves_hub_web/live/devices/show.html.heex index d1a3e655b..b21ac8985 100644 --- a/lib/nerves_hub_web/live/devices/show.html.heex +++ b/lib/nerves_hub_web/live/devices/show.html.heex @@ -109,7 +109,7 @@ - <%= if (!Enum.any?(Map.values(@latest_metrics)) or !Enum.any?(Map.values(@latest_custom_metrics))) do %> + <%= if (!Enum.any?(Map.values(@latest_metrics)) and !Enum.any?(Map.values(@latest_custom_metrics))) do %>
No health information have been received for this device.
@@ -134,22 +134,44 @@ <% end %> + <%= if @latest_metrics.cpu_usage_percent do %> +
+
CPU use
+ <%= round(@latest_metrics.cpu_usage_percent) %>% +
+ <% else %> +
+
CPU use
+ Not reported +
+ <% end %> + <%= if @latest_metrics.cpu_temp do %>
-
CPU
+
CPU temp
<%= round(@latest_metrics.cpu_temp) %>°
<% else %>
-
CPU
+
CPU temp
Not reported
<% end %> <%= for {key, val} <- @latest_custom_metrics do %>
-
<%= String.capitalize(key) %>
- <%= val %> +
+ <%= key + |> String.replace("_", " ") + |> String.capitalize() %> +
+ <%= cond do + is_float(val) -> + Float.round(val, 3) + + true -> + val + end %>
<% end %> diff --git a/lib/nerves_hub_web/live/product/settings.ex b/lib/nerves_hub_web/live/product/settings.ex index e77820772..6270e656e 100644 --- a/lib/nerves_hub_web/live/product/settings.ex +++ b/lib/nerves_hub_web/live/product/settings.ex @@ -1,6 +1,7 @@ defmodule NervesHubWeb.Live.Product.Settings do use NervesHubWeb, :updated_live_view + alias NervesHub.Extensions alias NervesHub.Products alias NervesHubWeb.DeviceSocket @@ -14,6 +15,7 @@ defmodule NervesHubWeb.Live.Product.Settings do |> assign(:shared_secrets, product.shared_secret_auths) |> assign(:shared_auth_enabled, DeviceSocket.shared_secrets_enabled?()) |> assign(:form, to_form(Ecto.Changeset.change(product))) + |> assign(:available_extensions, Extensions.list()) {:ok, socket} end @@ -71,4 +73,30 @@ defmodule NervesHubWeb.Live.Product.Settings do )} end end + + def handle_event("update-extension", %{"extension" => extension} = params, socket) do + value = params["value"] + available = Extensions.list() |> Map.keys() |> Enum.map(&to_string/1) + + result = + case {extension in available, value} do + {true, "on"} -> + Products.enable_extension_setting(socket.assigns.product, extension) + + {true, _} -> + Products.disable_extension_setting(socket.assigns.product, extension) + end + + socket = + case result do + {:ok, _pf} -> + # reload extensions + assign(socket, :extensions, Extensions.list()) + + {:error, _changeset} -> + put_flash(socket, :error, "Failed to set extension") + end + + {:noreply, socket} + end end diff --git a/lib/nerves_hub_web/live/product/settings.html.heex b/lib/nerves_hub_web/live/product/settings.html.heex index f82ae7230..e2743a23b 100644 --- a/lib/nerves_hub_web/live/product/settings.html.heex +++ b/lib/nerves_hub_web/live/product/settings.html.heex @@ -129,12 +129,70 @@ <% else %> -

This feature hasn't been enabled for this server.

+

This extension hasn't been enabled for this server.

Please contact your system admin.

<% end %>
+
+
+
+

Device Extensions

+
+
+ Experimental +
+
+
+ +

+ Isolated channels for various device behaviours and extension messaging not crucial to firmware updates
but are useful for managing, monitoring, and introspecting on wide fleets of devices. +

+

+ When enabled, NervesHub will request the extensions a device currently supports and then
check against product and device settings to see if the extension should be attached to the connection. +

+

+ Extensions most be allowed at the product level. They can also be configured at the device level for more
granular control when needed. +

+ +
Serial
+ + + + + + + + + + + + +
NameDescription
+ + +
Name
+ + +
+
Description
+ <%= description %> +
+ +
+