Add sensor charts and improve equipment detail page

- Add Chart.js integration for interactive sensor graphs
- Add processor, memory, storage, temperature, and voltage charts showing 24 hours of data
- Fix device information box to use natural height instead of stretching
- Update MikroTik profile to discover ENTITY-SENSOR-MIB sensors
- Support multiple sensor types per chart (celsius/temperature, volts/voltage)
- Use 24-hour time format in chart tooltips and x-axis labels
- Support auto-scaling y-axis for non-percentage metrics
This commit is contained in:
Graham McIntire 2026-01-05 11:01:21 -06:00
parent 4ad4e3a673
commit 05f1aec227
No known key found for this signature in database
6 changed files with 320 additions and 3 deletions

View file

@ -24,12 +24,141 @@ import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/towerops"
import topbar from "../vendor/topbar"
import Chart from "../vendor/chart"
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
const SensorChart = {
mounted() {
const data = JSON.parse(this.el.dataset.chart)
const canvas = this.el.querySelector('canvas')
const ctx = canvas.getContext('2d')
const unit = this.el.dataset.unit || '%'
const autoScale = this.el.dataset.autoScale === 'true'
// Generate colors for each dataset
const colors = [
'rgb(59, 130, 246)', // blue
'rgb(239, 68, 68)', // red
'rgb(34, 197, 94)', // green
'rgb(234, 179, 8)', // yellow
'rgb(168, 85, 247)', // purple
'rgb(236, 72, 153)', // pink
'rgb(14, 165, 233)', // sky
'rgb(249, 115, 22)', // orange
]
const datasets = data.datasets.map((dataset, index) => ({
...dataset,
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length].replace('rgb', 'rgba').replace(')', ', 0.1)'),
borderWidth: 2,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
}))
// Y-axis configuration
const yAxisConfig = autoScale ? {
ticks: {
callback: function(value) {
return value + unit
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
} : {
min: 0,
max: 100,
ticks: {
callback: function(value) {
return value + unit
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
}
this.chart = new Chart(ctx, {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 15,
}
},
tooltip: {
callbacks: {
title: function(context) {
const timestamp = context[0].parsed.x
const date = new Date(timestamp)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
},
label: function(context) {
return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + unit
}
}
}
},
scales: {
x: {
type: 'linear',
ticks: {
callback: function(value, index) {
const point = this.chart.data.datasets[0]?.data[index]
if (!point) return ''
const date = new Date(point.x)
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
},
maxTicksLimit: 12
},
grid: {
display: false
}
},
y: yAxisConfig
}
}
})
},
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()
}
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
hooks: {...colocatedHooks, SensorChart},
})
// Show progress bar on live navigation and form submits

14
assets/vendor/chart.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -65,6 +65,7 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
@doc """
Discovers MikroTik health sensors and system resources.
Returns comprehensive sensor list including temperature, voltage, current, fans, CPU, memory, disk.
Also discovers entity sensors from ENTITY-SENSOR-MIB (for additional temperature, voltage, etc.).
"""
@spec discover_sensors(Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
@ -74,7 +75,14 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
health_sensors = discover_health_sensors(client_opts)
resource_sensors = discover_resource_sensors(client_opts)
all_sensors = health_sensors ++ resource_sensors
# 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
all_sensors = health_sensors ++ resource_sensors ++ entity_sensors
if length(all_sensors) > 0 do
Logger.debug("Discovered #{length(all_sensors)} MikroTik sensors")

View file

@ -52,6 +52,11 @@ defmodule ToweropsWeb.EquipmentLive.Show do
recent_checks = Monitoring.list_equipment_checks(equipment_id, 50)
snmp_data = load_snmp_data(equipment_id)
events = Equipment.list_equipment_events(equipment_id, 100)
cpu_chart_data = load_sensor_chart_data(equipment_id, ["cpu_load"])
memory_chart_data = load_sensor_chart_data(equipment_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(equipment_id, ["disk_usage"])
temperature_chart_data = load_sensor_chart_data(equipment_id, ["temperature", "cpu_temperature", "celsius"])
voltage_chart_data = load_sensor_chart_data(equipment_id, ["voltage", "volts"])
# Calculate metrics for dashboard
metrics = calculate_metrics(recent_checks, equipment)
@ -65,6 +70,11 @@ defmodule ToweropsWeb.EquipmentLive.Show do
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:events, events)
|> assign(:cpu_chart_data, cpu_chart_data)
|> assign(:memory_chart_data, memory_chart_data)
|> assign(:storage_chart_data, storage_chart_data)
|> assign(:temperature_chart_data, temperature_chart_data)
|> assign(:voltage_chart_data, voltage_chart_data)
end
defp calculate_metrics(checks, _equipment) do
@ -201,4 +211,49 @@ defmodule ToweropsWeb.EquipmentLive.Show do
}
end
end
defp load_sensor_chart_data(equipment_id, sensor_types) when is_list(sensor_types) 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 for past 24 hours for each sensor
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets =
Enum.map(sensors, fn sensor ->
readings =
sensor.id
|> Snmp.get_sensor_readings(
since: twenty_four_hours_ago,
limit: 1000
)
|> 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
end

View file

@ -70,7 +70,7 @@
<div class="mt-6">
<%= case @active_tab do %>
<% "overview" -> %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Device Information -->
<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">
@ -178,6 +178,110 @@
</div>
<!-- Graphs and Metrics -->
<div class="space-y-6">
<!-- 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>
<div class="p-4">
<div
id="cpu-chart"
phx-hook="SensorChart"
data-chart={@cpu_chart_data}
style="height: 300px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- 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>
<div class="p-4">
<div
id="memory-chart"
phx-hook="SensorChart"
data-chart={@memory_chart_data}
style="height: 300px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- 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>
<div class="p-4">
<div
id="storage-chart"
phx-hook="SensorChart"
data-chart={@storage_chart_data}
style="height: 300px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- 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>
<div class="p-4">
<div
id="temperature-chart"
phx-hook="SensorChart"
data-chart={@temperature_chart_data}
data-unit="°C"
data-auto-scale="true"
style="height: 300px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- 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>
<div class="p-4">
<div
id="voltage-chart"
phx-hook="SensorChart"
data-chart={@voltage_chart_data}
data-unit="V"
data-auto-scale="true"
style="height: 300px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- Sensors -->
<%= if @snmp_sensors && length(@snmp_sensors) > 0 do %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">