Add detailed graph view with date range selection
- Create GraphLive.Show for detailed sensor graph visualization - Add route for /equipment/:id/graph/:sensor_type endpoint - Make all chart headers clickable with navigation to detail view - Implement date range selector (1h, 6h, 12h, 24h, 7d, 30d) - Fix chart rendering by destroying and recreating on data updates - Fix duplicate data loading in LiveView event handlers - Fix MikroTik profile typing warning for entity sensor discovery
This commit is contained in:
parent
05f1aec227
commit
ddabb3f030
6 changed files with 295 additions and 44 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -181,11 +181,19 @@
|
|||
<!-- CPU Usage Chart -->
|
||||
<%= if @cpu_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Processors (24 Hours)
|
||||
</h3>
|
||||
</div>
|
||||
<.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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Processors (24 Hours)
|
||||
</h3>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
</.link>
|
||||
<div class="p-4">
|
||||
<div
|
||||
id="cpu-chart"
|
||||
|
|
@ -201,11 +209,19 @@
|
|||
<!-- Memory Usage Chart -->
|
||||
<%= if @memory_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Memory Usage (24 Hours)
|
||||
</h3>
|
||||
</div>
|
||||
<.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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Memory Usage (24 Hours)
|
||||
</h3>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
</.link>
|
||||
<div class="p-4">
|
||||
<div
|
||||
id="memory-chart"
|
||||
|
|
@ -221,11 +237,19 @@
|
|||
<!-- Storage Usage Chart -->
|
||||
<%= if @storage_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Storage Usage (24 Hours)
|
||||
</h3>
|
||||
</div>
|
||||
<.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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Storage Usage (24 Hours)
|
||||
</h3>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
</.link>
|
||||
<div class="p-4">
|
||||
<div
|
||||
id="storage-chart"
|
||||
|
|
@ -241,11 +265,19 @@
|
|||
<!-- Temperature Chart -->
|
||||
<%= if @temperature_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Temperature (24 Hours)
|
||||
</h3>
|
||||
</div>
|
||||
<.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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Temperature (24 Hours)
|
||||
</h3>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
</.link>
|
||||
<div class="p-4">
|
||||
<div
|
||||
id="temperature-chart"
|
||||
|
|
@ -263,11 +295,19 @@
|
|||
<!-- Voltage Chart -->
|
||||
<%= if @voltage_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Voltage (24 Hours)
|
||||
</h3>
|
||||
</div>
|
||||
<.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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Voltage (24 Hours)
|
||||
</h3>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
</.link>
|
||||
<div class="p-4">
|
||||
<div
|
||||
id="voltage-chart"
|
||||
|
|
|
|||
136
lib/towerops_web/live/graph_live/show.ex
Normal file
136
lib/towerops_web/live/graph_live/show.ex
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
defmodule ToweropsWeb.GraphLive.Show do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Equipment
|
||||
alias Towerops.Snmp
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => 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
|
||||
74
lib/towerops_web/live/graph_live/show.html.heex
Normal file
74
lib/towerops_web/live/graph_live/show.html.heex
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_organization={@current_organization}
|
||||
active_page="equipment"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<!-- Header with back button -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<.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" />
|
||||
</.link>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@chart_title}</h1>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">{@equipment.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Date Range Selector -->
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Time Range:</span>
|
||||
<div class="flex gap-1">
|
||||
<%= for {label, value} <- [{"1 Hour", "1h"}, {"6 Hours", "6h"}, {"12 Hours", "12h"}, {"24 Hours", "24h"}, {"7 Days", "7d"}, {"30 Days", "30d"}] do %>
|
||||
<button
|
||||
phx-click="change_range"
|
||||
phx-value-range={value}
|
||||
class={[
|
||||
"px-3 py-1.5 text-sm font-medium rounded border transition-colors",
|
||||
if @range == value do
|
||||
"bg-blue-500 text-white border-blue-500"
|
||||
else
|
||||
"bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border-zinc-300 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-700"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Large Chart -->
|
||||
<%= if @chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="p-6">
|
||||
<div
|
||||
id="detail-chart"
|
||||
phx-hook="SensorChart"
|
||||
data-chart={@chart_data}
|
||||
data-unit={@unit}
|
||||
data-auto-scale={@auto_scale}
|
||||
style="height: 600px;"
|
||||
>
|
||||
<canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-12">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-chart-bar" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
No sensor data available
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
This device doesn't have any sensors of this type.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue