live device polling

This commit is contained in:
Graham McIntire 2026-02-01 13:19:32 -06:00
parent 6bdd90fd97
commit 7232dab601
No known key found for this signature in database
7 changed files with 464 additions and 70 deletions

View file

@ -47,20 +47,40 @@ function getTimeRangeMs(range: string): number {
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
const SensorChart: SensorChartHook = {
mounted() {
this.isLiveMode = this.el?.dataset.range === 'live'
this.createChart()
// Listen for live updates
this.handleEvent?.("live_data_update", (data: any) => {
this.handleLiveDataUpdate(data)
})
},
updated() {
// Destroy and recreate chart to properly handle scale changes
if (this.chart) {
this.chart.destroy()
const newRange = this.el?.dataset.range
const wasLiveMode = this.isLiveMode
this.isLiveMode = newRange === 'live'
// Mode switch - recreate chart
if (wasLiveMode !== this.isLiveMode) {
if (this.chart) {
this.chart.destroy()
}
this.createChart()
} else if (!this.isLiveMode) {
// Historical mode - recreate chart (existing behavior)
if (this.chart) {
this.chart.destroy()
}
this.createChart()
}
this.createChart()
// Live mode - updates handled by event, don't recreate
},
destroyed() {
if (this.chart) {
this.chart.destroy()
this.chart = null
}
},
@ -224,8 +244,62 @@ const SensorChart: SensorChartHook = {
// Calculate time range based on selected range
const now = Date.now()
const timeRangeMs = getTimeRangeMs(range)
const rangeStart = now - timeRangeMs
let xAxisConfig: any
if (this.isLiveMode) {
// Live mode - 5 minute window
xAxisConfig = {
type: 'linear',
min: now - (5 * 60 * 1000),
max: now,
ticks: {
callback: function(value: number) {
const date = new Date(value)
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
},
maxTicksLimit: 10
},
grid: {
display: false
}
}
} else {
// Historical mode - existing configuration
const timeRangeMs = getTimeRangeMs(range)
const rangeStart = now - timeRangeMs
xAxisConfig = {
type: 'linear',
min: rangeStart,
max: now,
ticks: {
callback: function(value: number) {
const date = new Date(value)
// For ranges > 24h, show date + time; otherwise just time
if (timeRangeMs > 24 * 60 * 60 * 1000) {
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} else {
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
}
},
maxTicksLimit: 12
},
grid: {
display: false
}
}
}
this.chart = new Chart(ctx, {
type: 'line',
@ -269,36 +343,74 @@ const SensorChart: SensorChartHook = {
}
},
scales: {
x: {
type: 'linear',
min: rangeStart,
max: now,
ticks: {
callback: function(value: number) {
const date = new Date(value)
// For ranges > 24h, show date + time; otherwise just time
if (timeRangeMs > 24 * 60 * 60 * 1000) {
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} else {
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
}
},
maxTicksLimit: 12
},
grid: {
display: false
}
},
x: xAxisConfig,
y: yAxisConfig
}
}
})
},
handleLiveDataUpdate(event: any) {
if (!this.chart || !this.isLiveMode) return
const { points } = event
const now = Date.now()
// Update each dataset with new point
points.forEach((point: any) => {
const dataset = this.chart!.data.datasets.find(
(ds: any) => ds.label === point.label
)
if (dataset && dataset.data) {
// Add new point
(dataset.data as any[]).push({
x: point.timestamp,
y: point.value
})
// Remove points older than 5 minutes
const cutoff = now - (5 * 60 * 1000)
dataset.data = (dataset.data as any[]).filter(
(p: any) => p.x >= cutoff
)
} else if (!dataset) {
// New sensor - add 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 index = this.chart!.data.datasets.length
const color = colors[index % colors.length]
this.chart!.data.datasets.push({
label: point.label,
data: [{x: point.timestamp, y: point.value}],
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
borderWidth: 2,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
fill: false
} as any)
}
})
// Update x-axis range to keep 5-minute window
if (this.chart.options.scales?.x) {
this.chart.options.scales.x.min = now - (5 * 60 * 1000)
this.chart.options.scales.x.max = now
}
// Update chart without animation
this.chart.update('none')
}
}

View file

@ -13,5 +13,7 @@ export interface LiveViewHook {
export interface SensorChartHook extends LiveViewHook {
chart?: any;
isLiveMode?: boolean;
createChart(): void;
handleLiveDataUpdate(event: any): void;
}

View file

@ -40,18 +40,7 @@ defmodule ToweropsWeb.Api.V1.MibController do
with :ok <- require_superuser(conn) do
case params["file"] do
%Plug.Upload{} = upload ->
vendor = params["vendor"] || "custom"
# Validate vendor name to prevent directory traversal
case validate_vendor_name(vendor) do
:ok ->
handle_upload(conn, upload, vendor)
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
handle_upload_with_vendor(conn, upload, params)
_ ->
conn
@ -61,6 +50,21 @@ defmodule ToweropsWeb.Api.V1.MibController do
end
end
defp handle_upload_with_vendor(conn, upload, params) do
vendor = params["vendor"] || "custom"
# Validate vendor name to prevent directory traversal
case validate_vendor_name(vendor) do
:ok ->
handle_upload(conn, upload, vendor)
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
end
@doc """
List all available MIB vendors.

View file

@ -30,8 +30,7 @@
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<.icon name="hero-queue-list" class="w-5 h-5 inline mr-1" />
Oban Dashboard
<.icon name="hero-queue-list" class="w-5 h-5 inline mr-1" /> Oban Dashboard
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Monitor background jobs and queues
@ -46,8 +45,7 @@
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<.icon name="hero-chart-bar" class="w-5 h-5 inline mr-1" />
LiveDashboard
<.icon name="hero-chart-bar" class="w-5 h-5 inline mr-1" /> LiveDashboard
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
System metrics and performance

View file

@ -277,7 +277,9 @@
<div class="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<div>
<span class="font-medium">Current:</span>
<span class="font-mono ml-1">{@snmp_device.firmware_version}</span>
<span class="font-mono ml-1">
{@snmp_device.firmware_version}
</span>
</div>
<div>
<span class="font-medium">Latest:</span>
@ -286,7 +288,9 @@
</span>
<%= if @available_firmware.release_date do %>
<span class="text-gray-500 dark:text-gray-500">
(released {format_date(@available_firmware.release_date)})
(released {format_date(
@available_firmware.release_date
)})
</span>
<% end %>
</div>
@ -299,7 +303,10 @@
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium bg-blue-600 text-white hover:bg-blue-700"
>
Download
<.icon name="hero-arrow-top-right-on-square" class="h-3 w-3" />
<.icon
name="hero-arrow-top-right-on-square"
class="h-3 w-3"
/>
</a>
<%= if @available_firmware.changelog_url do %>
<a
@ -309,7 +316,10 @@
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20"
>
Release Notes
<.icon name="hero-arrow-top-right-on-square" class="h-3 w-3" />
<.icon
name="hero-arrow-top-right-on-square"
class="h-3 w-3"
/>
</a>
<% end %>
</div>

View file

@ -7,6 +7,9 @@ defmodule ToweropsWeb.GraphLive.Show do
alias Towerops.Repo
alias Towerops.Snmp
# Maximum live data points (5 minutes at 1 second intervals)
@max_live_points 300
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
@ -44,6 +47,8 @@ defmodule ToweropsWeb.GraphLive.Show do
|> assign(:interface_id, interface_id)
|> assign(:sensor_id, sensor_id)
|> assign(:storage_id, storage_id)
|> assign(:is_live_mode, range == "live")
|> maybe_start_live_polling()
|> load_graph_data()
{:noreply, socket}
@ -102,30 +107,65 @@ defmodule ToweropsWeb.GraphLive.Show do
# Private functions
defp load_graph_data(socket) do
if socket.assigns.is_live_mode do
load_live_mode_graph_data(socket)
else
# Historical mode - existing logic
device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
device = Devices.get_device!(device_id)
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
{title, unit, auto_scale} = get_chart_config(sensor_type)
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
show_zero_line = sensor_type == "traffic"
# Calculate max/min values from chart data
{max_value, min_value} = calculate_chart_stats(chart_data)
socket
|> assign(:device, device)
|> assign(:page_title, "#{device.name} - #{chart_title}")
|> assign(:chart_title, chart_title)
|> assign(:chart_data, chart_data)
|> assign(:unit, unit)
|> assign(:auto_scale, auto_scale)
|> assign(:show_zero_line, show_zero_line)
|> assign(:max_value, max_value)
|> assign(:min_value, min_value)
end
end
defp load_live_mode_graph_data(socket) do
device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
device = Devices.get_device!(device_id)
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
{title, unit, auto_scale} = get_chart_config(sensor_type)
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
show_zero_line = sensor_type == "traffic"
# Calculate max/min values from chart data
{max_value, min_value} = calculate_chart_stats(chart_data)
title_suffix = get_title_suffix(socket.assigns)
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
socket
|> assign(:device, device)
|> assign(:page_title, "#{device.name} - #{chart_title}")
|> assign(:page_title, "#{device.name} - #{chart_title} (Live)")
|> assign(:chart_title, chart_title)
|> assign(:chart_data, chart_data)
|> assign(:chart_data, Jason.encode!(%{datasets: []}))
|> assign(:unit, unit)
|> assign(:auto_scale, auto_scale)
|> assign(:show_zero_line, show_zero_line)
|> assign(:max_value, max_value)
|> assign(:min_value, min_value)
|> assign(:show_zero_line, sensor_type == "traffic")
|> assign(:max_value, nil)
|> assign(:min_value, nil)
end
defp get_title_suffix(assigns) do
cond do
assigns[:sensor_id] -> get_sensor_name(assigns[:sensor_id])
assigns[:interface_id] && assigns[:sensor_type] == "traffic" -> get_interface_name(assigns[:interface_id])
assigns[:storage_id] && assigns[:sensor_type] == "storage_volume" -> get_storage_name(assigns[:storage_id])
true -> nil
end
end
defp load_chart_data_for_type(%{sensor_type: "latency", device_id: device_id}, range) do
@ -521,4 +561,213 @@ defmodule ToweropsWeb.GraphLive.Show do
defp format_value(value, unit) do
"#{Float.round(value, 1)}#{unit}"
end
# Live polling functions
# Start/stop live polling based on mode
defp maybe_start_live_polling(socket) do
if socket.assigns[:is_live_mode] && connected?(socket) do
cancel_live_polling_timer(socket)
socket
|> assign(:live_data_buffer, %{})
|> assign(:live_data_points, 0)
|> schedule_live_poll()
else
cancel_live_polling_timer(socket)
end
end
# Schedule next poll in 1 second
defp schedule_live_poll(socket) do
timer_ref = Process.send_after(self(), :live_poll, 1_000)
assign(socket, :live_poll_timer, timer_ref)
end
# Cancel existing timer
defp cancel_live_polling_timer(socket) do
case Map.get(socket.assigns, :live_poll_timer) do
nil ->
socket
timer_ref ->
Process.cancel_timer(timer_ref)
assign(socket, :live_poll_timer, nil)
end
end
# Handle live poll event
@impl true
def handle_info(:live_poll, socket) do
if socket.assigns[:is_live_mode] do
socket = perform_live_poll(socket)
{:noreply, schedule_live_poll(socket)}
else
{:noreply, socket}
end
end
# Perform SNMP polling and update buffer
defp perform_live_poll(socket) do
device_id = socket.assigns.device_id
# Verify device still exists and access is valid
case Devices.get_device(device_id) do
nil ->
socket
|> put_flash(:error, "Device no longer exists")
|> push_navigate(to: ~p"/devices")
device ->
device = Repo.preload(device, site: :organization)
organization = socket.assigns.current_scope.organization
if device.site.organization_id == organization.id do
poll_and_update_buffer(socket, device)
else
socket
|> put_flash(:error, "Access to device revoked")
|> push_navigate(to: ~p"/devices")
end
end
end
# Poll sensors and update buffer
defp poll_and_update_buffer(socket, device) do
client_opts = build_snmp_client_opts(device)
now = DateTime.utc_now()
timestamp_ms = DateTime.to_unix(now, :millisecond)
# Poll sensors based on graph type
new_points = poll_sensors_for_live_mode(socket.assigns, client_opts, timestamp_ms)
# Update buffer with new points (rolling window)
{updated_buffer, point_count} =
update_live_data_buffer(
socket.assigns.live_data_buffer,
socket.assigns.live_data_points,
new_points
)
# Push update to client
socket
|> assign(:live_data_buffer, updated_buffer)
|> assign(:live_data_points, point_count)
|> push_event("live_data_update", %{
timestamp: timestamp_ms,
points: new_points
})
end
# Build SNMP client options
defp build_snmp_client_opts(device) do
snmp_config = Devices.get_snmp_config(device)
[
ip: device.ip_address,
community: snmp_config.community,
version: snmp_config.version,
port: device.snmp_port || 161,
# Increased for live polling - devices may be slow to respond
timeout: 3000
]
end
# Poll sensors in parallel
defp poll_sensors_for_live_mode(assigns, client_opts, timestamp_ms) do
sensors = get_sensors_for_live_mode(assigns)
sensors
|> Task.async_stream(
fn sensor ->
case poll_single_sensor(sensor, client_opts) do
{:ok, value} ->
%{
sensor_id: sensor.id,
label: sensor.sensor_descr,
value: Float.round(value, 1),
timestamp: timestamp_ms
}
{:error, _reason} ->
# Skip failed polls - chart will show gap
nil
end
end,
max_concurrency: 10,
# Give SNMP client (3000ms timeout) enough time plus overhead
timeout: 4000,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, nil}, acc -> acc
{:ok, point}, acc -> [point | acc]
{:exit, _reason}, acc -> acc
end)
end
# Get sensors based on graph type
defp get_sensors_for_live_mode(%{sensor_id: sensor_id}) when not is_nil(sensor_id) do
case Snmp.get_sensor(sensor_id) do
nil -> []
sensor -> [sensor]
end
end
defp get_sensors_for_live_mode(%{sensor_type: sensor_type, device_id: device_id}) do
sensor_types = get_sensor_types_for_chart(sensor_type)
device = Snmp.get_device_with_associations(device_id)
device.sensors
|> Enum.filter(&(&1.sensor_type in sensor_types))
|> Enum.sort_by(& &1.sensor_index)
end
# Poll single sensor via SNMP
defp poll_single_sensor(sensor, client_opts) do
case Snmp.Client.get(client_opts, sensor.sensor_oid) do
{:ok, raw_value} when is_number(raw_value) ->
value = raw_value / sensor.sensor_divisor
{:ok, value}
{:ok, _non_numeric} ->
{:error, :non_numeric}
{:error, reason} ->
{:error, reason}
end
end
# Update live data buffer with rolling window
defp update_live_data_buffer(buffer, current_count, new_points) do
updated_buffer =
Enum.reduce(new_points, buffer, fn point, acc ->
sensor_buffer = Map.get(acc, point.sensor_id, [])
# Add new point at the front
new_buffer = [
%{x: point.timestamp, y: point.value} | sensor_buffer
]
# Trim to max size
trimmed_buffer =
if current_count >= @max_live_points do
Enum.take(new_buffer, @max_live_points)
else
new_buffer
end
Map.put(acc, point.sensor_id, trimmed_buffer)
end)
new_count = min(current_count + 1, @max_live_points)
{updated_buffer, new_count}
end
# Cleanup on LiveView termination
@impl true
def terminate(_reason, socket) do
cancel_live_polling_timer(socket)
:ok
end
end

View file

@ -23,16 +23,24 @@
<div class="flex items-center gap-2 mb-6">
<span class="text-sm font-medium text-gray-700 dark:text-gray-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 %>
<%= for {label, value} <- [{"Live", "live"}, {"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"
"px-3 py-1.5 text-sm font-medium rounded border transition-all duration-200",
if value == "live" do
if @range == "live" do
"bg-green-500 text-white border-green-500 shadow-lg shadow-green-500/50"
else
"bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 text-green-700 dark:text-green-400 border-green-300 dark:border-green-700 hover:from-green-100 hover:to-emerald-100 dark:hover:from-green-900/30 dark:hover:to-emerald-900/30 hover:border-green-400 dark:hover:border-green-600 hover:shadow-md hover:shadow-green-500/20 font-semibold"
end
else
"bg-white dark:bg-gray-800/50 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800"
if @range == value do
"bg-blue-500 text-white border-blue-500"
else
"bg-white dark:bg-gray-800/50 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800"
end
end
]}
>
@ -41,6 +49,17 @@
<% end %>
</div>
</div>
<!-- Live Mode Indicator -->
<%= if @is_live_mode do %>
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-4">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span>Live polling (1 second interval, 5 minute window)</span>
</div>
<% end %>
<!-- Large Chart -->
<%= if @chart_data do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">