diff --git a/assets/js/app.ts b/assets/js/app.ts index b43a432a..7f9ad907 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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') } } diff --git a/assets/js/types/liveview.d.ts b/assets/js/types/liveview.d.ts index b13bfd8c..80a2fa83 100644 --- a/assets/js/types/liveview.d.ts +++ b/assets/js/types/liveview.d.ts @@ -13,5 +13,7 @@ export interface LiveViewHook { export interface SensorChartHook extends LiveViewHook { chart?: any; + isLiveMode?: boolean; createChart(): void; + handleLiveDataUpdate(event: any): void; } diff --git a/lib/towerops_web/controllers/api/v1/mib_controller.ex b/lib/towerops_web/controllers/api/v1/mib_controller.ex index 46e075e4..d8d0beba 100644 --- a/lib/towerops_web/controllers/api/v1/mib_controller.ex +++ b/lib/towerops_web/controllers/api/v1/mib_controller.ex @@ -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. diff --git a/lib/towerops_web/live/admin/dashboard_live.html.heex b/lib/towerops_web/live/admin/dashboard_live.html.heex index 307f7d22..ef28de37 100644 --- a/lib/towerops_web/live/admin/dashboard_live.html.heex +++ b/lib/towerops_web/live/admin/dashboard_live.html.heex @@ -30,8 +30,7 @@

- <.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

Monitor background jobs and queues @@ -46,8 +45,7 @@

- <.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

System metrics and performance diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 00972421..ef1d4ab7 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -277,7 +277,9 @@

Current: - {@snmp_device.firmware_version} + + {@snmp_device.firmware_version} +
Latest: @@ -286,7 +288,9 @@ <%= if @available_firmware.release_date do %> - (released {format_date(@available_firmware.release_date)}) + (released {format_date( + @available_firmware.release_date + )}) <% end %>
@@ -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" + /> <%= if @available_firmware.changelog_url do %> 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" + /> <% end %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index cbe82a07..38085f0d 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/graph_live/show.html.heex b/lib/towerops_web/live/graph_live/show.html.heex index d6fd1ccd..452a99fd 100644 --- a/lib/towerops_web/live/graph_live/show.html.heex +++ b/lib/towerops_web/live/graph_live/show.html.heex @@ -23,16 +23,24 @@
Time Range:
- <%= 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 %>
+ + <%= if @is_live_mode do %> +
+ + + + + + Live polling (1 second interval, 5 minute window) +
+ <% end %> <%= if @chart_data do %>