diff --git a/assets/js/app.js b/assets/js/app.js index 7e2a0f54..47f01913 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -29,6 +29,24 @@ import Chart from "../vendor/chart" // Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.) const SensorChart = { mounted() { + this.createChart() + }, + + updated() { + // Destroy and recreate chart to properly handle scale changes + if (this.chart) { + this.chart.destroy() + } + this.createChart() + }, + + destroyed() { + if (this.chart) { + this.chart.destroy() + } + }, + + createChart() { const data = JSON.parse(this.el.dataset.chart) const canvas = this.el.querySelector('canvas') const ctx = canvas.getContext('2d') @@ -137,20 +155,6 @@ const SensorChart = { } } }) - }, - - updated() { - const data = JSON.parse(this.el.dataset.chart) - if (this.chart) { - this.chart.data.datasets = data.datasets - this.chart.update() - } - }, - - destroyed() { - if (this.chart) { - this.chart.destroy() - } } } diff --git a/lib/towerops/snmp/profiles/mikrotik.ex b/lib/towerops/snmp/profiles/mikrotik.ex index cb13e697..4c6bb339 100644 --- a/lib/towerops/snmp/profiles/mikrotik.ex +++ b/lib/towerops/snmp/profiles/mikrotik.ex @@ -76,11 +76,7 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do resource_sensors = discover_resource_sensors(client_opts) # Also try to discover entity sensors (for additional temperature, voltage sensors) - entity_sensors = - case Base.discover_sensors(client_opts) do - {:ok, sensors} -> sensors - {:error, _} -> [] - end + {:ok, entity_sensors} = Base.discover_sensors(client_opts) all_sensors = health_sensors ++ resource_sensors ++ entity_sensors diff --git a/lib/towerops_web/live/equipment_live/show.html.heex b/lib/towerops_web/live/equipment_live/show.html.heex index 6f8d8678..632f1be0 100644 --- a/lib/towerops_web/live/equipment_live/show.html.heex +++ b/lib/towerops_web/live/equipment_live/show.html.heex @@ -181,11 +181,19 @@ <%= if @cpu_chart_data do %>
-
-

- Processors (24 Hours) -

-
+ <.link + navigate={ + ~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/processors" + } + class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors" + > +
+

+ Processors (24 Hours) +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" /> +
+
<%= if @memory_chart_data do %>
-
-

- Memory Usage (24 Hours) -

-
+ <.link + navigate={ + ~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/memory" + } + class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors" + > +
+

+ Memory Usage (24 Hours) +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" /> +
+
<%= if @storage_chart_data do %>
-
-

- Storage Usage (24 Hours) -

-
+ <.link + navigate={ + ~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/storage" + } + class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors" + > +
+

+ Storage Usage (24 Hours) +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" /> +
+
<%= if @temperature_chart_data do %>
-
-

- Temperature (24 Hours) -

-
+ <.link + navigate={ + ~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/temperature" + } + class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors" + > +
+

+ Temperature (24 Hours) +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" /> +
+
<%= if @voltage_chart_data do %>
-
-

- Voltage (24 Hours) -

-
+ <.link + navigate={ + ~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/voltage" + } + class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors" + > +
+

+ Voltage (24 Hours) +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" /> +
+
equipment_id, "sensor_type" => sensor_type} = params, _, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}") + end + + range = Map.get(params, "range", "24h") + + socket = + socket + |> assign(:equipment_id, equipment_id) + |> assign(:sensor_type, sensor_type) + |> assign(:range, range) + |> load_graph_data() + + {:noreply, socket} + end + + @impl true + def handle_event("change_range", %{"range" => range}, socket) do + {:noreply, + push_patch(socket, + to: + ~p"/orgs/#{socket.assigns.current_organization.slug}/equipment/#{socket.assigns.equipment_id}/graph/#{socket.assigns.sensor_type}?range=#{range}" + )} + end + + # Private functions + + defp load_graph_data(socket) do + equipment_id = socket.assigns.equipment_id + sensor_type = socket.assigns.sensor_type + range = socket.assigns.range + + equipment = Equipment.get_equipment!(equipment_id) + sensor_types = get_sensor_types_for_chart(sensor_type) + chart_data = load_sensor_chart_data(equipment_id, sensor_types, range) + + {title, unit, auto_scale} = get_chart_config(sensor_type) + + socket + |> assign(:equipment, equipment) + |> assign(:page_title, "#{equipment.name} - #{title}") + |> assign(:chart_title, title) + |> assign(:chart_data, chart_data) + |> assign(:unit, unit) + |> assign(:auto_scale, auto_scale) + end + + defp get_sensor_types_for_chart("processors"), do: ["cpu_load"] + defp get_sensor_types_for_chart("memory"), do: ["memory_usage"] + defp get_sensor_types_for_chart("storage"), do: ["disk_usage"] + defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"] + defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"] + defp get_sensor_types_for_chart(_), do: [] + + defp get_chart_config("processors"), do: {"Processor Usage", "%", false} + defp get_chart_config("memory"), do: {"Memory Usage", "%", false} + defp get_chart_config("storage"), do: {"Storage Usage", "%", false} + defp get_chart_config("temperature"), do: {"Temperature", "°C", true} + defp get_chart_config("voltage"), do: {"Voltage", "V", true} + defp get_chart_config(_), do: {"Sensor Data", "", false} + + defp load_sensor_chart_data(equipment_id, sensor_types, range) do + case Snmp.get_device_with_associations(equipment_id) do + nil -> + nil + + device -> + # Find all sensors of the specified types + sensors = + device.sensors + |> Enum.filter(&(&1.sensor_type in sensor_types)) + |> Enum.sort_by(& &1.sensor_index) + + if Enum.empty?(sensors) do + nil + else + # Get readings based on range + since = get_datetime_from_range(range) + + datasets = + Enum.map(sensors, fn sensor -> + readings = + sensor.id + |> Snmp.get_sensor_readings( + since: since, + limit: get_limit_for_range(range) + ) + |> Enum.reverse() + + %{ + label: sensor.sensor_descr, + data: + Enum.map(readings, fn reading -> + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.value, do: Float.round(reading.value, 1)) + } + end) + } + end) + + Jason.encode!(%{datasets: datasets}) + end + end + end + + defp get_datetime_from_range("1h"), do: DateTime.add(DateTime.utc_now(), -1, :hour) + defp get_datetime_from_range("6h"), do: DateTime.add(DateTime.utc_now(), -6, :hour) + defp get_datetime_from_range("12h"), do: DateTime.add(DateTime.utc_now(), -12, :hour) + defp get_datetime_from_range("24h"), do: DateTime.add(DateTime.utc_now(), -24, :hour) + defp get_datetime_from_range("7d"), do: DateTime.add(DateTime.utc_now(), -7, :day) + defp get_datetime_from_range("30d"), do: DateTime.add(DateTime.utc_now(), -30, :day) + defp get_datetime_from_range(_), do: DateTime.add(DateTime.utc_now(), -24, :hour) + + defp get_limit_for_range("1h"), do: 360 + defp get_limit_for_range("6h"), do: 720 + defp get_limit_for_range("12h"), do: 1440 + defp get_limit_for_range("24h"), do: 2880 + defp get_limit_for_range("7d"), do: 10_080 + defp get_limit_for_range("30d"), do: 43_200 + defp get_limit_for_range(_), do: 2880 +end diff --git a/lib/towerops_web/live/graph_live/show.html.heex b/lib/towerops_web/live/graph_live/show.html.heex new file mode 100644 index 00000000..4e715477 --- /dev/null +++ b/lib/towerops_web/live/graph_live/show.html.heex @@ -0,0 +1,74 @@ + +
+ +
+
+ <.link + navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment_id}"} + class="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100" + > + <.icon name="hero-arrow-left" class="h-5 w-5" /> + +
+

{@chart_title}

+

{@equipment.name}

+
+
+
+ +
+ Time Range: +
+ <%= for {label, value} <- [{"1 Hour", "1h"}, {"6 Hours", "6h"}, {"12 Hours", "12h"}, {"24 Hours", "24h"}, {"7 Days", "7d"}, {"30 Days", "30d"}] do %> + + <% end %> +
+
+ + <%= if @chart_data do %> +
+
+
+ +
+
+
+ <% else %> +
+
+ <.icon name="hero-chart-bar" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No sensor data available +

+

+ This device doesn't have any sensors of this type. +

+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 737902ee..a2654696 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -109,6 +109,7 @@ defmodule ToweropsWeb.Router do live "/equipment/new", EquipmentLive.Form, :new live "/equipment/:id", EquipmentLive.Show, :show live "/equipment/:id/edit", EquipmentLive.Form, :edit + live "/equipment/:id/graph/:sensor_type", GraphLive.Show, :show # Alert routes live "/alerts", AlertLive.Index, :index