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:
parent
4ad4e3a673
commit
05f1aec227
6 changed files with 320 additions and 3 deletions
131
assets/js/app.js
131
assets/js/app.js
|
|
@ -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
14
assets/vendor/chart.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
assets/vendor/chartjs-adapter-date-fns.js
vendored
Normal file
7
assets/vendor/chartjs-adapter-date-fns.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue