From c4e301a84c132aa95a3338703b682d7cea9433f2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Mar 2026 16:34:42 -0500 Subject: [PATCH] fix: address code review findings - memory leaks and redundant queries (#192) Fixed 5 critical issues from code review: 1. **GlobalSearchTrigger hook listener leak** - Added destroyed() callback to remove click listener - Prevents memory leak on LiveView unmount 2. **NetworkMap/WeathermapViewer hook listener leaks** - Store zoom/fit button handlers as properties - Remove DOM event listeners in destroyed() callback - Fixes leak on every LiveView remount 3. **SitesMap uses undocumented window.liveSocket.execJS** - Changed to proper LiveView pattern using pushEvent - Attach listener via Leaflet's popupopen event 4. **handle_info(:refresh_data) double DB fetch** - Removed redundant Devices.get_device call - Reuse device from assign_base_data 5. **Redundant DB queries in DeviceLive.Show overview** - Changed load_sensor_chart_data to accept snmp_device - Changed load_overall_traffic_chart_data to accept snmp_device - Eliminated 4 redundant Snmp.get_device_with_associations calls Also removed unused functions: - load_equipment_data/2 - reload_active_tab_data/1 - assign_subscriber_impact/1 Note: DeviceLive.Show module size (2252 lines) is acknowledged but deferred as it requires larger architectural refactoring. Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/192 --- assets/js/app.ts | 106 +- lib/towerops/alerts/storm_detector.ex | 3 + .../device_live/components/backups_tab.ex | 315 ++++ .../live/device_live/components/checks_tab.ex | 292 ++++ .../live/device_live/components/gaiia_tab.ex | 331 ++++ .../device_live/components/overview_tab.ex | 1084 +++++++++++++ .../live/device_live/components/ports_tab.ex | 331 ++++ .../device_live/components/preseem_tab.ex | 291 ++++ .../device_live/components/wireless_tab.ex | 195 +++ .../device_live/helpers/chart_builders.ex | 480 ++++++ .../live/device_live/helpers/data_loaders.ex | 496 ++++++ .../live/device_live/helpers/formatters.ex | 306 ++++ .../device_live/helpers/sensor_classifiers.ex | 236 +++ lib/towerops_web/live/device_live/show.ex | 1364 +++-------------- .../live/helpers/access_control.ex | 8 +- 15 files changed, 4642 insertions(+), 1196 deletions(-) create mode 100644 lib/towerops_web/live/device_live/components/backups_tab.ex create mode 100644 lib/towerops_web/live/device_live/components/checks_tab.ex create mode 100644 lib/towerops_web/live/device_live/components/gaiia_tab.ex create mode 100644 lib/towerops_web/live/device_live/components/overview_tab.ex create mode 100644 lib/towerops_web/live/device_live/components/ports_tab.ex create mode 100644 lib/towerops_web/live/device_live/components/preseem_tab.ex create mode 100644 lib/towerops_web/live/device_live/components/wireless_tab.ex create mode 100644 lib/towerops_web/live/device_live/helpers/chart_builders.ex create mode 100644 lib/towerops_web/live/device_live/helpers/data_loaders.ex create mode 100644 lib/towerops_web/live/device_live/helpers/formatters.ex create mode 100644 lib/towerops_web/live/device_live/helpers/sensor_classifiers.ex diff --git a/assets/js/app.ts b/assets/js/app.ts index d7ce5900..663f2de8 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -633,6 +633,9 @@ const BetaBannerDismiss = { const NetworkMap = { cy: null as any, _topologyData: null as any, + _zoomInHandler: null as ((e: Event) => void) | null, + _zoomOutHandler: null as ((e: Event) => void) | null, + _fitHandler: null as ((e: Event) => void) | null, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this._topologyData = topologyData @@ -671,6 +674,25 @@ const NetworkMap = { }, destroyed(this: any) { + // Remove DOM event listeners + const zoomIn = document.getElementById('cy-zoom-in') + const zoomOut = document.getElementById('cy-zoom-out') + const fit = document.getElementById('cy-fit') + + if (zoomIn && this._zoomInHandler) { + zoomIn.removeEventListener('click', this._zoomInHandler) + } + if (zoomOut && this._zoomOutHandler) { + zoomOut.removeEventListener('click', this._zoomOutHandler) + } + if (fit && this._fitHandler) { + fit.removeEventListener('click', this._fitHandler) + } + + this._zoomInHandler = null + this._zoomOutHandler = null + this._fitHandler = null + if (this.cy) { // Stop any running layout before destroying to prevent async callbacks // from accessing a destroyed renderer @@ -690,30 +712,33 @@ const NetworkMap = { const fit = document.getElementById('cy-fit') if (zoomIn) { - zoomIn.addEventListener('click', () => { + this._zoomInHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomIn.addEventListener('click', this._zoomInHandler) } if (zoomOut) { - zoomOut.addEventListener('click', () => { + this._zoomOutHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomOut.addEventListener('click', this._zoomOutHandler) } if (fit) { - fit.addEventListener('click', () => { + this._fitHandler = () => { if (!this.cy) return this.cy.fit(undefined, 50) - }) + } + fit.addEventListener('click', this._fitHandler) } }, @@ -1717,14 +1742,24 @@ const SitesMap = {

` } - popupContent += `` - + + // Bind popup and attach event listener after marker is added to map marker.bindPopup(popupContent) + marker.on('popupopen', () => { + const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement + if (button) { + button.addEventListener('click', () => { + this.pushEvent("site_clicked", { site_id: button.dataset.siteId }) + }) + } + }) this.markers.addLayer(marker) bounds.extend([site.latitude, site.longitude]) } @@ -1750,11 +1785,21 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" // Global Search (Cmd+K / Ctrl+K) hook const GlobalSearchTrigger = { - mounted() { - this.el.addEventListener("click", () => { + handleClick: null as ((e: Event) => void) | null, + + mounted(this: any) { + this.handleClick = () => { // Simulate Cmd+K to open global search document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })) - }) + } + this.el.addEventListener("click", this.handleClick) + }, + + destroyed(this: any) { + if (this.handleClick) { + this.el.removeEventListener("click", this.handleClick) + this.handleClick = null + } } } @@ -1829,6 +1874,9 @@ const ThemeSelector = { const WeathermapViewer = { cy: null as any, _topologyData: null as any, + _zoomInHandler: null as ((e: Event) => void) | null, + _zoomOutHandler: null as ((e: Event) => void) | null, + _fitHandler: null as ((e: Event) => void) | null, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') @@ -1868,6 +1916,25 @@ const WeathermapViewer = { }, destroyed(this: any) { + // Remove DOM event listeners + const zoomIn = document.getElementById('cy-zoom-in') + const zoomOut = document.getElementById('cy-zoom-out') + const fit = document.getElementById('cy-fit') + + if (zoomIn && this._zoomInHandler) { + zoomIn.removeEventListener('click', this._zoomInHandler) + } + if (zoomOut && this._zoomOutHandler) { + zoomOut.removeEventListener('click', this._zoomOutHandler) + } + if (fit && this._fitHandler) { + fit.removeEventListener('click', this._fitHandler) + } + + this._zoomInHandler = null + this._zoomOutHandler = null + this._fitHandler = null + if (this.cy) { try { this.cy.stop() @@ -2204,30 +2271,33 @@ const WeathermapViewer = { const fit = document.getElementById('cy-fit') if (zoomIn) { - zoomIn.addEventListener('click', () => { + this._zoomInHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomIn.addEventListener('click', this._zoomInHandler) } if (zoomOut) { - zoomOut.addEventListener('click', () => { + this._zoomOutHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomOut.addEventListener('click', this._zoomOutHandler) } if (fit) { - fit.addEventListener('click', () => { + this._fitHandler = () => { if (!this.cy) return this.cy.fit(undefined, 50) - }) + } + fit.addEventListener('click', this._fitHandler) } }, diff --git a/lib/towerops/alerts/storm_detector.ex b/lib/towerops/alerts/storm_detector.ex index 48dbdd25..c9bc1443 100644 --- a/lib/towerops/alerts/storm_detector.ex +++ b/lib/towerops/alerts/storm_detector.ex @@ -150,6 +150,8 @@ defmodule Towerops.Alerts.StormDetector do ) until = DateTime.add(DateTime.utc_now(), state.storm_cooldown_ms, :millisecond) + # Schedule automatic cooldown check so storm mode exits without needing an external poll + Process.send_after(self(), :check_storm_cooldown, state.storm_cooldown_ms) {true, until} else {state.storm_mode, state.storm_mode_until} @@ -270,6 +272,7 @@ defmodule Towerops.Alerts.StormDetector do case List.first(devices) do nil -> Logger.error("Attempted to create site outage alert with no devices for site_id=#{site_id}") + {:error, :no_devices} primary_device -> diff --git a/lib/towerops_web/live/device_live/components/backups_tab.ex b/lib/towerops_web/live/device_live/components/backups_tab.ex new file mode 100644 index 00000000..ff4b5fda --- /dev/null +++ b/lib/towerops_web/live/device_live/components/backups_tab.ex @@ -0,0 +1,315 @@ +defmodule ToweropsWeb.DeviceLive.Components.BackupsTab do + @moduledoc """ + LiveComponent for the device MikroTik backups tab. + + Displays MikroTik configuration backups with download, compare, + and backup-now functionality. + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + alias ToweropsWeb.DeviceLive.Helpers.Formatters + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_backups_data()} + end + + defp load_backups_data(socket) do + device = socket.assigns.device + mikrotik_backups = DataLoaders.load_mikrotik_backups(device) + + socket + |> assign(:mikrotik_backups, mikrotik_backups) + |> assign(:selected_backup_ids, MapSet.new()) + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= if false and @device.mikrotik_enabled do %> +
+
+
+
+

+ {t("Configuration Backups")} + <%= if Enum.any?(@mikrotik_backups) do %> + + ({length(@mikrotik_backups)}) + + <% end %> +

+ + <.icon name="hero-beaker" class="h-3 w-3" /> Experimental + +
+ <%= if @agent_info.agent_token_id do %> + + <% else %> +
+ {t("Assign an agent to enable backups")} +
+ <% end %> +
+
+ <%= if Enum.any?(@mikrotik_backups) do %> + <%!-- Instructions for comparing backups --%> + <%= if length(@mikrotik_backups) > 1 do %> +
+
+ <.icon + name="hero-information-circle" + class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" + /> +
+ Compare configurations: + {t("Select any 2 backups using the checkboxes to compare their differences")} +
+
+
+ <% end %> +
+ + + + + + + + + + + + + + <%= for backup <- @mikrotik_backups do %> + <% compression_ratio = + if backup.config_size_bytes && backup.config_size_bytes > 0 do + Float.round( + (1 - backup.compressed_size_bytes / backup.config_size_bytes) * 100, + 1 + ) + else + 0.0 + end %> + + + + + + + + + + <% end %> + +
+ <.icon name="hero-check-circle" class="h-4 w-4 mx-auto" /> + DateOriginal SizeCompressed SizeRatioSourceActions
+ = 2 + } + title={ + if MapSet.member?(@selected_backup_ids, backup.id), + do: "Selected for comparison", + else: + if(MapSet.size(@selected_backup_ids) >= 2, + do: "Maximum 2 backups can be selected", + else: "Select to compare" + ) + } + class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700" + /> + + {ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)} + + {format_bytes(backup.config_size_bytes)} + + {format_bytes(backup.compressed_size_bytes)} + + {compression_ratio}% + + + {String.replace(backup.trigger_source, "_", " ") |> String.capitalize()} + + +
+ + <%= if ToweropsWeb.Permissions.owner?(@current_scope) do %> + <.button + phx-click="delete_backup" + phx-value-id={backup.id} + data-confirm={ + t( + "Are you sure you want to delete this backup? This action cannot be undone." + ) + } + variant="danger" + > + <.icon name="hero-trash" class="h-4 w-4" /> Delete + + <% end %> +
+
+
+ <%!-- Pagination --%> + <%= if assigns[:pagination] do %> +
+ <.pagination + meta={@pagination} + path={~p"/devices/#{@device.id}"} + params={%{"tab" => "backups"}} + /> +
+ <% end %> + <%!-- Floating Compare Button --%> + <%= if MapSet.size(@selected_backup_ids) > 0 do %> +
+
+
+ <%!-- Selection status --%> +
+ <.icon + name="hero-check-circle" + class="h-5 w-5 text-blue-600 dark:text-blue-400" + /> +
+ {MapSet.size(@selected_backup_ids)} + of 2 backups selected +
+
+ <%!-- Instructions when only 1 selected --%> + <%= if MapSet.size(@selected_backup_ids) == 1 do %> +
+ {t("Select one more backup to compare")} +
+ <% end %> + <%!-- Action buttons --%> +
+ + +
+
+
+
+ <% end %> + <% else %> +
+ <.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" /> +

+ {t("No backups yet")} +

+ <%= if @agent_info.agent_token_id do %> +

+ Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour( + 7, + @timezone + )}. +

+ <% else %> +

+ {t("Assign an agent to enable automatic backups.")} +

+ <% end %> +
+ <% end %> +
+ <% else %> +
+
+ <.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" /> +

+ {t("MikroTik API not enabled")} +

+

+ {t("Enable MikroTik API in device settings to enable configuration backups.")} +

+
+ <.button navigate={~p"/devices/#{@device.id}/edit"}> + {t("Edit Device Settings")} + +
+
+
+ <% end %> +
+ """ + end + + @impl true + def handle_event("toggle_backup_selection", %{"id" => backup_id}, socket) do + selected = socket.assigns.selected_backup_ids + + updated_selected = + if MapSet.member?(selected, backup_id) do + MapSet.delete(selected, backup_id) + else + # Limit to 2 selections max + if MapSet.size(selected) >= 2 do + selected + else + MapSet.put(selected, backup_id) + end + end + + {:noreply, assign(socket, :selected_backup_ids, updated_selected)} + end + + @impl true + def handle_event("clear_selection", _params, socket) do + {:noreply, assign(socket, :selected_backup_ids, MapSet.new())} + end + + # Helper functions + defp format_bytes(bytes), do: Formatters.format_bytes(bytes) +end diff --git a/lib/towerops_web/live/device_live/components/checks_tab.ex b/lib/towerops_web/live/device_live/components/checks_tab.ex new file mode 100644 index 00000000..be9c8336 --- /dev/null +++ b/lib/towerops_web/live/device_live/components/checks_tab.ex @@ -0,0 +1,292 @@ +defmodule ToweropsWeb.DeviceLive.Components.ChecksTab do + @moduledoc """ + LiveComponent for the device monitoring checks tab. + + Displays all monitoring checks for the device grouped by type + (SNMP, HTTP, TCP, DNS, SSL, ping). + """ + use ToweropsWeb, :live_component + + alias Towerops.Monitoring + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_checks_data()} + end + + defp load_checks_data(socket) do + device_id = socket.assigns.device.id + organization_id = socket.assigns.device.organization_id + checks_data = DataLoaders.load_checks_data(device_id, organization_id) + + socket + |> assign(:checks, checks_data.checks) + |> assign(:grouped_checks, checks_data.grouped_checks) + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= if Enum.empty?(@checks) do %> + +
+
+ <.icon name="hero-clipboard-document-check" class="mx-auto h-12 w-12 text-gray-400" /> +

+ {t("No checks configured")} +

+

+ {t( + "Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually." + )} +

+
+ <%= if @device.snmp_enabled do %> + <.button type="button" phx-click="run_discovery"> + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Run Discovery + + <% end %> + <.button type="button" phx-click="add_check"> + <.icon name="hero-plus" class="h-4 w-4" /> Add Check + +
+
+
+ <% else %> + +
+ +
+

+ Checks ({length(@checks)}) +

+ <.button type="button" phx-click="add_check"> + <.icon name="hero-plus" class="h-4 w-4" /> Add Check + +
+ <%= for {group_key, group_checks} <- @grouped_checks do %> +
+ +
+

+ {group_title(group_key)} ({length(group_checks)}) +

+
+ +
+ + + + + + + + + + + + <%= for check <- group_checks do %> + + + + + + + + <% end %> + +
+ {t("Name")} + + {t("Status")} + + {t("Value")} + + {t("Last Checked")} + + Actions +
+ {check.name} + + {render_status_badge(check.current_state)} + + <%= if check.last_check_at do %> + {get_latest_value(check)} + <% else %> + + Pending + + <% end %> + + <%= if check.last_check_at do %> + {format_relative_time(check.last_check_at)} + <% else %> + + Never + + <% end %> + +
+ <.link + navigate={ + ~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h" + } + class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium" + > + {t("Graph")} + + <%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %> + + + <% end %> +
+
+
+
+ <% end %> +
+ <% end %> +
+ """ + end + + # Helper functions + defp group_title(:snmp_sensors), do: "SNMP Sensors" + defp group_title(:snmp_interfaces), do: "SNMP Interfaces" + defp group_title(:snmp_processors), do: "SNMP Processors" + defp group_title(:snmp_storage), do: "SNMP Storage" + defp group_title(:http_checks), do: "HTTP Checks" + defp group_title(:tcp_checks), do: "TCP Checks" + defp group_title(:dns_checks), do: "DNS Checks" + defp group_title(:ssl_checks), do: "SSL Certificate Checks" + defp group_title(:ping_checks), do: "Ping Checks" + defp group_title(:other_checks), do: "Other Checks" + + defp render_status_badge(0) do + assigns = %{} + + ~H""" + + OK + + """ + end + + defp render_status_badge(1) do + assigns = %{} + + ~H""" + + WARNING + + """ + end + + defp render_status_badge(2) do + assigns = %{} + + ~H""" + + CRITICAL + + """ + end + + defp render_status_badge(3) do + assigns = %{} + + ~H""" + + UNKNOWN + + """ + end + + defp render_status_badge(_) do + assigns = %{} + + ~H""" + + PENDING + + """ + end + + defp get_latest_value(check) do + # Get the latest check result to show current value + case Monitoring.get_latest_check_result(check.id) do + nil -> "-" + result -> format_check_value(result, check) + end + end + + defp format_check_value(%{value: nil}, _check), do: "-" + + defp format_check_value(%{value: value}, check) when is_number(value) do + case check.check_type do + "snmp_sensor" -> format_check_sensor_value(value, check.config) + "snmp_processor" -> "#{Float.round(value, 1)}%" + "snmp_storage" -> "#{Float.round(value, 1)}%" + t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms" + _ -> value |> Float.round(2) |> to_string() + end + end + + defp format_check_value(_result, _check), do: "-" + + @sensor_value_formats %{ + "temperature" => {1, "°C"}, + "voltage" => {2, "V"}, + "current" => {2, "A"}, + "power" => {1, "W"}, + "frequency" => {0, "Hz"}, + "humidity" => {1, "%"} + } + + defp format_check_sensor_value(value, %{"sensor_type" => "fanspeed", "sensor_unit" => unit}) do + "#{round(value)}#{unit || " RPM"}" + end + + defp format_check_sensor_value(value, config) do + sensor_type = config["sensor_type"] + unit = config["sensor_unit"] + {precision, default_unit} = Map.get(@sensor_value_formats, sensor_type, {2, ""}) + "#{Float.round(value, precision)}#{unit || default_unit}" + end + + defp format_relative_time(datetime) do + ToweropsWeb.TimeHelpers.format_time_ago(datetime) + end +end diff --git a/lib/towerops_web/live/device_live/components/gaiia_tab.ex b/lib/towerops_web/live/device_live/components/gaiia_tab.ex new file mode 100644 index 00000000..bc2fd6c4 --- /dev/null +++ b/lib/towerops_web/live/device_live/components/gaiia_tab.ex @@ -0,0 +1,331 @@ +defmodule ToweropsWeb.DeviceLive.Components.GaiiaTab do + @moduledoc """ + LiveComponent for the device Gaiia integration tab. + + Displays Gaiia inventory item data, assigned account, subscriptions, + and network site information. + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_gaiia_data()} + end + + defp load_gaiia_data(socket) do + device = socket.assigns.device + gaiia_data = DataLoaders.load_gaiia_data(device) + + socket + |> assign(:gaiia_item, gaiia_data.gaiia_item) + |> assign(:gaiia_account, gaiia_data.gaiia_account) + |> assign(:gaiia_subscriptions, gaiia_data.gaiia_subscriptions) + |> assign(:gaiia_network_site, gaiia_data.gaiia_network_site) + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @gaiia_item do %> +
+ <%!-- Inventory Item Details --%> +
+
+

+ {t("Gaiia Inventory Item")} +

+
+
+
+
+
Name
+
+ {@gaiia_item.name || "---"} +
+
+
+
Gaiia ID
+
+ {@gaiia_item.gaiia_id} +
+
+
+
Status
+
+ + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" + + "decommissioned" -> + "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" + + _ -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + end + ]}> + {@gaiia_item.status || "unknown"} + +
+
+ <%= if @gaiia_item.model_name do %> +
+
Model
+
+ {@gaiia_item.model_name} +
+
+ <% end %> + <%= if @gaiia_item.manufacturer_name do %> +
+
+ {t("Manufacturer")} +
+
+ {@gaiia_item.manufacturer_name} +
+
+ <% end %> + <%= if @gaiia_item.category do %> +
+
+ {t("Category")} +
+
+ {@gaiia_item.category} +
+
+ <% end %> + <%= if @gaiia_item.serial_number do %> +
+
+ {t("Serial Number")} +
+
+ {@gaiia_item.serial_number} +
+
+ <% end %> + <%= if @gaiia_item.mac_address do %> +
+
+ {t("MAC Address")} +
+
+ {@gaiia_item.mac_address} +
+
+ <% end %> + <%= if @gaiia_item.ip_address do %> +
+
+ {t("IP Address (Gaiia)")} +
+
+ {@gaiia_item.ip_address} + <%= if @device.ip_address && @gaiia_item.ip_address != @device.ip_address do %> + + <.icon name="hero-exclamation-triangle-mini" class="mr-0.5 h-3 w-3" /> + {t("Mismatch")} + + <% end %> +
+
+ <% end %> +
+
+
+ + <%!-- Network Site --%> + <%= if @gaiia_network_site do %> +
+
+

+ {t("Gaiia Network Site")} +

+
+
+
+
+
+ {t("Site Name")} +
+
+ {@gaiia_network_site.name} +
+
+ <%= if @gaiia_network_site.account_count do %> +
+
+ {t("Subscribers")} +
+
+ {@gaiia_network_site.account_count} +
+
+ <% end %> + <%= if @gaiia_network_site.total_mrr do %> +
+
+ {t("Monthly Revenue")} +
+
+ ${Decimal.round(@gaiia_network_site.total_mrr, 2)} +
+
+ <% end %> +
+
+
+ <% end %> + + <%!-- Linked Subscriber --%> + <%= if @gaiia_account do %> +
+
+

+ {t("Linked Subscriber")} +

+
+
+
+
+
Name
+
+ {@gaiia_account.name} +
+
+ <%= if @gaiia_account.readable_id do %> +
+
+ {t("Account ID")} +
+
+ {@gaiia_account.readable_id} +
+
+ <% end %> +
+
Status
+
+ + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" + + "suspended" -> + "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" + end + ]}> + {@gaiia_account.status || "unknown"} + +
+
+ <%= if @gaiia_account.mrr do %> +
+
MRR
+
+ ${Decimal.round(@gaiia_account.mrr, 2)} +
+
+ <% end %> +
+
+
+ <% end %> + + <%!-- Billing Subscriptions --%> + <%= if @gaiia_subscriptions != [] do %> +
+
+

+ {t("Billing Subscriptions")} +

+
+
+ + + + + + + + + + <%= for sub <- @gaiia_subscriptions do %> + + + + + + <% end %> + +
+ {t("Plan")} + + {t("Status")} + + {t("MRR")} +
+ {sub.product_name || "---"} + + + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" + + "suspended" -> + "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" + end + ]}> + {sub.status || "unknown"} + + + <%= if sub.mrr_amount do %> + ${Decimal.round(sub.mrr_amount, 2)} + + {sub.currency || ""} + + <% else %> + --- + <% end %> +
+
+
+ <% end %> +
+ <% else %> +
+

+ {t("No Gaiia inventory item linked to this device.")} +

+
+ <% end %> +
+ """ + end +end diff --git a/lib/towerops_web/live/device_live/components/overview_tab.ex b/lib/towerops_web/live/device_live/components/overview_tab.ex new file mode 100644 index 00000000..04560a52 --- /dev/null +++ b/lib/towerops_web/live/device_live/components/overview_tab.ex @@ -0,0 +1,1084 @@ +defmodule ToweropsWeb.DeviceLive.Components.OverviewTab do + @moduledoc """ + LiveComponent for the device overview tab. + + Displays device metrics, charts (latency, CPU, memory, storage, traffic), + and categorized sensors (temperature, voltage, power, etc.). + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + alias ToweropsWeb.DeviceLive.Helpers.Formatters + alias ToweropsWeb.DeviceLive.Helpers.SensorClassifiers + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_overview_data()} + end + + defp load_overview_data(socket) do + device = socket.assigns.device + device_id = device.id + snmp_device = socket.assigns.snmp_device + sensors = socket.assigns.snmp_sensors + + # Load processors and storage + processors = DataLoaders.load_processors(snmp_device) + storage = DataLoaders.load_storage(snmp_device) + + # Build chart data + latency_chart_data = ChartBuilders.load_latency_chart_data(device_id) + processor_chart_data = ChartBuilders.load_processor_chart_data(processors) + memory_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["memory_usage"]) + storage_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["disk_usage"]) + storage_volume_chart_data = ChartBuilders.load_storage_volume_chart_data(storage) + traffic_chart_data = ChartBuilders.load_overall_traffic_chart_data(snmp_device) + + wireless_chart_data = + ChartBuilders.load_sensor_chart_data(snmp_device, [ + "frequency", + "power", + "rssi", + "ccq", + "clients", + "distance", + "noise-floor", + "quality", + "rate", + "utilization" + ]) + + # Classify sensors by type + {transceiver_sensors, non_transceiver_sensors} = + SensorClassifiers.split_transceiver_sensors(sensors) + + temperature_sensors = + Enum.filter(non_transceiver_sensors, &SensorClassifiers.temperature_sensor?/1) + + voltage_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.voltage_sensor?/1) + current_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.current_sensor?/1) + power_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.power_sensor?/1) + fan_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.fan_sensor?/1) + load_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.load_sensor?/1) + storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"])) + count_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.counter_sensor?/1) + + {firewall_counters, general_counters} = + Enum.split_with(count_sensors, &SensorClassifiers.firewall_counter?/1) + + wireless_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.wireless_sensor?/1) + signal_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.signal_sensor?/1) + grouped_transceivers = SensorClassifiers.group_transceiver_sensors(transceiver_sensors) + metrics = DataLoaders.calculate_metrics([], device) + available_firmware = DataLoaders.get_available_firmware(snmp_device) + connected_devices = DataLoaders.load_connected_devices(device_id) + recent_config_changes = DataLoaders.load_recent_config_changes(device) + + socket + |> assign(:metrics, metrics) + |> assign(:processors, processors) + |> assign(:storage, storage) + |> assign(:latency_chart_data, latency_chart_data) + |> assign(:processor_chart_data, processor_chart_data) + |> assign(:memory_chart_data, memory_chart_data) + |> assign(:storage_chart_data, storage_chart_data) + |> assign(:storage_volume_chart_data, storage_volume_chart_data) + |> assign(:traffic_chart_data, traffic_chart_data) + |> assign(:wireless_chart_data, wireless_chart_data) + |> assign(:temperature_sensors, temperature_sensors) + |> assign(:voltage_sensors, voltage_sensors) + |> assign(:current_sensors, current_sensors) + |> assign(:power_sensors, power_sensors) + |> assign(:fan_sensors, fan_sensors) + |> assign(:load_sensors, load_sensors) + |> assign(:storage_sensors, storage_sensors) + |> assign(:count_sensors, count_sensors) + |> assign(:general_counters, general_counters) + |> assign(:firewall_counters, firewall_counters) + |> assign(:wireless_sensors, wireless_sensors) + |> assign(:signal_sensors, signal_sensors) + |> assign(:grouped_transceivers, grouped_transceivers) + |> assign(:available_firmware, available_firmware) + |> assign(:connected_devices, connected_devices) + |> assign(:recent_config_changes, recent_config_changes) + end + + @impl true + def render(assigns) do + ~H""" +
+
+ +
+ +
+
+

+ {t("Device Information")} +

+
+
+
+ <%= if @snmp_device do %> +
+
+ System Name +
+
+ {@snmp_device.sys_name || @device.name} +
+
+ +
+
+ {t("Resolved IP")} +
+
+ {@device.ip_address} + <.icon + name="hero-clipboard-document" + class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/rip:opacity-100 transition-opacity text-gray-400" + /> +
+
+ +
+
Hardware
+
+ {@snmp_device.model || "N/A"} +
+
+ +
+
+ Operating System +
+
+
+ + {cond do + @snmp_device.manufacturer && @snmp_device.firmware_version -> + "#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}" + + @snmp_device.manufacturer -> + @snmp_device.manufacturer + + true -> + @snmp_device.sys_descr || "N/A" + end} + + <%= if firmware_update_available?(@snmp_device, @available_firmware) do %> +
+ <.icon + name="hero-arrow-up-circle" + class="h-5 w-5 text-blue-500 cursor-help dark:text-blue-400" + /> + +
+ + +
+ <% end %> +
+
+
+ +
+
+ Serial Number +
+
+ {@snmp_device.serial_number} +
+
+ +
+
Object ID
+
+ {@snmp_device.sys_object_id || "N/A"} +
+
+ +
+
Contact
+
+ {@snmp_device.sys_contact || "N/A"} +
+
+ +
+
Location
+
+ {format_location(@snmp_device.sys_location)} +
+
+ +
+
Uptime
+
+ {format_uptime(@snmp_device.sys_uptime)} +
+
+ <% else %> +
+
Name
+
+ {@device.name} +
+
+ +
+
+ IP Address +
+
+ {@device.ip_address} +
+
+ <% end %> + +
+
+ Device Added +
+
+ {format_device_age(@device.inserted_at)} +
+
+ +
+
+ Last Discovered +
+
+ {if @device.last_discovery_at do + format_device_age(@device.last_discovery_at) + else + "Never" + end} +
+
+ +
+
Last Polled
+
+ {if @device.last_snmp_poll_at do + format_device_age(@device.last_snmp_poll_at) + else + "Never" + end} +
+
+
+
+
+ + <%= if @traffic_chart_data do %> +
+ <.link + navigate={~p"/devices/#{@device.id}/graph/traffic"} + class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" + > +
+

+ {t("Overall Traffic")} +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> +
+ +
+
+
+
+
+
+ {t("Loading chart...")} +
+
+ +
+
+
+ <% end %> + + <%= if @latency_chart_data do %> +
+ <.link + navigate={~p"/devices/#{@device.id}/graph/latency"} + class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" + > +
+

+ {t("ICMP Latency")} +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> +
+ +
+
+
+
+
+
+ {t("Loading chart...")} +
+
+ +
+
+
+ <% end %> + + <%!-- Recent Config Changes mini-card --%> + <%= if false && @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) do %> +
+
+

+ <.icon name="hero-clock" class="h-4 w-4 text-orange-500" /> + {t("Recent Config Changes")} +

+ <.link + navigate={~p"/devices/#{@device.id}/config-timeline"} + class="text-xs text-blue-500 hover:underline" + > + View Timeline → + +
+
+ <%= for event <- Enum.take(@recent_config_changes, 5) do %> +
+
+ + + {Calendar.strftime(event.changed_at, "%b %d %H:%M")} + + + {s} + +
+ + {event.change_size} lines + +
+ <% end %> +
+
+ <% end %> +
+ +
+ + <%= if @processor_chart_data || (@processors && length(@processors) > 0) do %> + <% processor_count = if @processors, do: length(@processors), else: 0 + + avg_load = + if processor_count > 0 do + loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1) + if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil + else + nil + end %> +
+ <.link + navigate={~p"/devices/#{@device.id}/graph/processors"} + class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" + > +
+

+ <%= if processor_count > 0 do %> + {processor_count} {if processor_count == 1, + do: "Processor", + else: "Processors"} + <% else %> + {t("Processors")} + <% end %> +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> +
+ + + <%= if @processor_chart_data do %> +
+
+ +
+
+ <% end %> + + <%= if avg_load do %> +
+
+ + {t("Average Load")} + +
+
+
= 50 && avg_load < 80 && "bg-yellow-500", + avg_load >= 80 && "bg-red-500" + ]} + style={"width: #{min(avg_load, 100)}%"} + > +
+
+ + {Float.round(avg_load, 0)}% + +
+
+
+ <% end %> +
+ <% end %> + + <%= if @memory_chart_data do %> +
+ <.link + navigate={~p"/devices/#{@device.id}/graph/memory"} + class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" + > +
+

+ {t("Memory Usage")} +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" /> +
+ +
+
+ +
+
+
+ <% end %> + + <%= if @storage_sensors && length(@storage_sensors) > 0 do %> +
+
+

+ {t("Storage Usage")} +

+
+
+
+ <%= for sensor <- @storage_sensors do %> +
+
+ <.link + navigate={~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"} + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading do %> + {format_sensor_value( + sensor.latest_reading.value, + sensor.sensor_divisor + )} + {sensor.sensor_unit} + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> +
+
+
+ <% end %> + + <%= if @storage && length(@storage) > 0 do %> +
+
+

+ {t("Storage Volumes")} + + ({length(@storage)}) + +

+
+ + <%= if @storage_volume_chart_data do %> +
+
+ +
+
+ <% end %> +
+ <%= for storage <- @storage do %> + <% usage_percent = + if storage.total_bytes && storage.total_bytes > 0 do + Float.round(storage.used_bytes / storage.total_bytes * 100, 1) + else + 0.0 + end %> + <.link + navigate={ + ~p"/devices/#{@device.id}/graph/storage_volume?storage_id=#{storage.id}" + } + class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1 rounded transition-colors" + > +
+ + {storage.description || "Storage #{storage.storage_index}"} + + + {format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)} + +
+
+
+
= 70 && usage_percent < 90 && "bg-yellow-500", + usage_percent >= 90 && "bg-red-500" + ]} + style={"width: #{min(usage_percent, 100)}%"} + > +
+
+ + {usage_percent}% + +
+
+ {String.replace(storage.storage_type || "other", "_", " ") + |> String.capitalize()} +
+ + <% end %> +
+
+ <% end %> + + <%= if @temperature_sensors && length(@temperature_sensors) > 0 do %> +
+
+

+ {t("Temperature")} +

+
+
+
+ <%= for sensor <- @temperature_sensors do %> +
+
+ <.link + navigate={ + ~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}" + } + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading do %> + {format_sensor_value( + sensor.latest_reading.value, + sensor.sensor_divisor + )} + {sensor.sensor_unit || "°C"} + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> +
+
+
+ <% end %> + + <%= if @voltage_sensors && length(@voltage_sensors) > 0 do %> +
+
+

+ {t("Voltage")} +

+
+
+
+ <%= for sensor <- @voltage_sensors do %> +
+
+ <.link + navigate={~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"} + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading do %> + {format_sensor_value( + sensor.latest_reading.value, + sensor.sensor_divisor + )} + {sensor.sensor_unit} + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> +
+
+
+ <% end %> + + <%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %> +
+
+

+ {t("Transceivers")} +

+
+
+
+ <%= for {_transceiver_name, sensors} <- @grouped_transceivers do %> + <%= for sensor <- sensors do %> +
+
+ <.link + navigate={~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"} + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading do %> + {format_sensor_value( + sensor.latest_reading.value, + sensor.sensor_divisor + )} + {sensor.sensor_unit} + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> + <% end %> +
+
+
+ <% end %> + + <%= if (@general_counters && length(@general_counters) > 0) || (@firewall_counters && length(@firewall_counters) > 0) do %> +
+
+

+ {t("Counters")} +

+
+
+ + <%= if @general_counters && length(@general_counters) > 0 do %> +
+ <%= for sensor <- @general_counters do %> +
+
+ <.link + navigate={ + ~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}" + } + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading && sensor.latest_reading.value do %> + {trunc(sensor.latest_reading.value)} + <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> + {sensor.sensor_unit} + <% end %> + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> +
+ <% end %> + + <%= if @firewall_counters && length(@firewall_counters) > 0 do %> +
+

+ {t("Firewall")} +

+
+ <%= for sensor <- @firewall_counters do %> +
+
+ <.link + navigate={ + ~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}" + } + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading && sensor.latest_reading.value do %> + {trunc(sensor.latest_reading.value)} + <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> + {sensor.sensor_unit} + <% end %> + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> +
+
+ <% end %> +
+
+ <% end %> + + <%= if @wireless_sensors && length(@wireless_sensors) > 0 do %> +
+
+

+ {t("Wireless")} +

+
+
+
+ <%= for sensor <- @wireless_sensors do %> +
+
+ <.link + navigate={~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"} + class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" + > + {sensor.sensor_descr} + +
+
+ <%= if sensor.latest_reading && sensor.latest_reading.value do %> + <%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %> + {trunc(sensor.latest_reading.value)} + <% else %> + {:erlang.float_to_binary(sensor.latest_reading.value * 1.0, + decimals: 1 + )} + <% end %> + <%= if sensor.sensor_unit && sensor.sensor_unit != "" do %> + {sensor.sensor_unit} + <% end %> + <% else %> + {t("N/A")} + <% end %> +
+
+ <% end %> +
+
+
+ <% end %> +
+
+ <%!-- Connected Devices --%> + <%= if @connected_devices != [] do %> +
+
+

+ {t("Connected Devices")} + + ({length(@connected_devices)} links) + +

+
+
+ + + + + + + + + + + + + <%= for link <- @connected_devices do %> + <% peer_device = + cond do + link.target_device && link.target_device.id != @device.id -> + link.target_device + + link.source_device && link.source_device.id != @device.id -> + link.source_device + + link.target_device -> + link.target_device + + true -> + nil + end %> + + + + + + + + + <% end %> + +
DeviceIP AddressInterfaceLink TypeConfidenceStatus
+ <%= if peer_device do %> + <.link + navigate={~p"/devices/#{peer_device.id}"} + class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" + > + {peer_device.name} + + <% else %> +
+ {link.discovered_remote_name || link.discovered_remote_mac || + "Unknown"} +
+ <% end %> +
+ <%= if peer_device do %> + {peer_device.ip_address} + <% else %> + {link.discovered_remote_ip || "-"} + <% end %> + + <%= if link.source_interface do %> + {link.source_interface.if_name} + <% else %> + - + <% end %> + + + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" + + link.link_type == "mac_match" -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + + link.link_type == "arp_inference" -> + "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" + + true -> + "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200" + end + ]}> + {link.link_type} + + +
+
+
= 0.9 -> "bg-green-500" + link.confidence >= 0.7 -> "bg-blue-500" + link.confidence >= 0.5 -> "bg-yellow-500" + true -> "bg-red-500" + end + ]} + style={"width: #{trunc(link.confidence * 100)}%"} + > +
+
+ + {trunc(link.confidence * 100)}% + +
+
+ <%= if peer_device do %> + + {t("Managed")} + + <% else %> + + {t("Discovered")} + + <% end %> +
+
+
+ <% end %> +
+ """ + end + + # Helper functions - delegate to Formatters or implement locally + defp format_date(date), do: Formatters.format_date(date) + defp format_sensor_value(value, divisor), do: Formatters.format_sensor_value(value, divisor) + defp format_location(location), do: Formatters.format_location(location) + defp format_uptime(timeticks), do: Formatters.format_uptime(timeticks) + defp format_device_age(datetime), do: Formatters.format_device_age(datetime) + defp format_bytes(bytes), do: Formatters.format_bytes(bytes) + + defp config_change_dot_color(event) do + cond do + event.change_size > 50 -> "bg-red-500" + event.change_size > 20 -> "bg-yellow-500" + true -> "bg-green-500" + end + end + + defp firmware_update_available?(nil, _), do: false + defp firmware_update_available?(_, nil), do: false + + defp firmware_update_available?(snmp_device, available_firmware) do + current = snmp_device.firmware_version + available = available_firmware.version + + # Extract clean version numbers for comparison + current_clean = extract_version_number(current) + available_clean = extract_version_number(available) + + current_clean && available_clean && + :towerops@devices@version_comparator.newer(current_clean, available_clean) + end + + defp extract_version_number(nil), do: nil + defp extract_version_number(""), do: nil + + defp extract_version_number(version_string) when is_binary(version_string) do + # Match semantic version pattern: X.Y or X.Y.Z + case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do + [version] -> version + _ -> nil + end + end +end diff --git a/lib/towerops_web/live/device_live/components/ports_tab.ex b/lib/towerops_web/live/device_live/components/ports_tab.ex new file mode 100644 index 00000000..78ce6e3b --- /dev/null +++ b/lib/towerops_web/live/device_live/components/ports_tab.ex @@ -0,0 +1,331 @@ +defmodule ToweropsWeb.DeviceLive.Components.PortsTab do + @moduledoc """ + LiveComponent for the device ports (interfaces) tab. + + Displays network interfaces grouped by type (Ethernet, VLAN, Wireless, etc.) + with traffic statistics and utilization metrics. + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.Formatters + + # SNMP interface type to category mappings + @interface_type_categories %{ + 6 => "Ethernet", + 24 => "Loopback", + 23 => "PPP", + 108 => "PPPoE", + 131 => "Tunnel", + 135 => "VLAN", + 136 => "VLAN", + 161 => "IEEE 802.11", + 244 => "WWP" + } + + # Display order for interface categories + @interface_category_order %{ + "Ethernet" => 1, + "VLAN" => 2, + "IEEE 802.11" => 3, + "PPPoE" => 4, + "PPP" => 5, + "Tunnel" => 6, + "Loopback" => 7, + "WWP" => 8, + "Other" => 99 + } + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_ports_data()} + end + + defp load_ports_data(socket) do + interfaces = socket.assigns.snmp_interfaces + + interfaces_with_utilization = + Enum.map(interfaces, fn interface -> + utilization = + if interface.configured_capacity_bps do + Towerops.Capacity.get_utilization(interface) + end + + Map.put(interface, :utilization, utilization) + end) + + interfaces_by_type = group_interfaces_by_type(interfaces_with_utilization) + + assign(socket, :interfaces_by_type, interfaces_by_type) + end + + # Group interfaces by type for organized display + defp group_interfaces_by_type(interfaces) do + interfaces + |> Enum.group_by(&get_interface_type_category(&1.if_type)) + |> Enum.map(fn {category, interfaces} -> + {category, Enum.sort_by(interfaces, & &1.if_index)} + end) + |> Enum.sort_by(fn {category, _} -> interface_type_order(category) end) + end + + # Map SNMP interface types to human-readable categories + defp get_interface_type_category(if_type) when is_integer(if_type) do + Map.get(@interface_type_categories, if_type, "Other") + end + + defp get_interface_type_category(_), do: "Other" + + # Define display order for interface categories + defp interface_type_order(category) do + Map.get(@interface_category_order, category, 99) + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %> +
+ <%= for {category, interfaces} <- @interfaces_by_type do %> +
+
+

+ {category} Interfaces + + ({length(interfaces)}) + +

+
+
+ + + + + + + + + + + + + + + + + + <%= for interface <- interfaces do %> + + + + + + + + + + + + + + <% end %> + +
#NameStatusSpeedIP AddressMACCapacityUtilizationInOutErrors
+ {interface.if_index} + + <.link + navigate={ + ~p"/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}" + } + class="hover:text-blue-600 dark:hover:text-blue-400" + > +
+ {interface.if_name || interface.if_descr} +
+ <%= if interface.if_alias do %> +
+ {interface.if_alias} +
+ <% end %> + +
+ + + + {String.upcase(interface.if_oper_status || "unknown")} + + + + {format_speed(interface.if_speed)} + + <% interface_ips = + Map.get(@ip_addresses_by_interface, interface.id, []) %> + <%= if Enum.empty?(interface_ips) do %> + - + <% else %> +
+ <%= for ip <- interface_ips do %> +
+ {ip.ip_address} + <%= if ip.prefix_length do %> + /{ip.prefix_length} + <% end %> + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/iip:opacity-100 transition-opacity text-gray-400" + /> +
+ <% end %> +
+ <% end %> +
+ + {interface.if_phys_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" + /> + + - + + <%= if interface.configured_capacity_bps do %> +
+ + {format_speed(interface.configured_capacity_bps)} + + + {capacity_source_label(interface.capacity_source)} + + +
+ <% else %> + + <% end %> +
+ <%= if interface.utilization do %> +
+
+
+
+
+ + {Float.round(interface.utilization.utilization_pct, 1)}% + +
+ <% else %> + - + <% end %> +
+ {if interface.latest_stat && interface.latest_stat.if_in_octets, + do: format_bytes(interface.latest_stat.if_in_octets), + else: "-"} + + {if interface.latest_stat && interface.latest_stat.if_out_octets, + do: format_bytes(interface.latest_stat.if_out_octets), + else: "-"} + + <% total_errors = + interface.latest_stat && + (interface.latest_stat.if_in_errors || 0) + + (interface.latest_stat.if_out_errors || 0) + + (interface.latest_stat.if_in_discards || 0) + + (interface.latest_stat.if_out_discards || 0) %> + <%= if total_errors && total_errors > 0 do %> + <.link + navigate={ + ~p"/devices/#{@device.id}/graph/interface_errors?interface_id=#{interface.id}" + } + class="text-red-600 dark:text-red-400 hover:underline" + > + {total_errors} + + <% else %> + - + <% end %> +
+
+
+ <% end %> +
+ <% else %> +
+

No interfaces discovered

+
+ <% end %> +
+ """ + end + + # Helper functions - delegate to Formatters module + defp format_speed(speed_bps), do: Formatters.format_speed(speed_bps) + defp format_bytes(bytes), do: Formatters.format_bytes(bytes) + defp capacity_source_label(source), do: Formatters.capacity_source_label(source) + defp capacity_source_class(source), do: Formatters.capacity_source_class(source) + defp utilization_color(pct), do: Formatters.utilization_color(pct) + + defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" + defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" + defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" +end diff --git a/lib/towerops_web/live/device_live/components/preseem_tab.ex b/lib/towerops_web/live/device_live/components/preseem_tab.ex new file mode 100644 index 00000000..d64a0ae6 --- /dev/null +++ b/lib/towerops_web/live/device_live/components/preseem_tab.ex @@ -0,0 +1,291 @@ +defmodule ToweropsWeb.DeviceLive.Components.PreseemTab do + @moduledoc """ + LiveComponent for the device Preseem integration tab. + + Displays Preseem access point data, subscriber metrics, and device insights. + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_preseem_data()} + end + + defp load_preseem_data(socket) do + device = socket.assigns.device + preseem_data = DataLoaders.load_preseem_data(device) + + socket + |> assign(:preseem_access_point, preseem_data.preseem_access_point) + |> assign(:preseem_metrics, preseem_data.preseem_metrics) + |> assign(:preseem_insights, preseem_data.preseem_insights) + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @preseem_access_point do %> +
+ <%!-- Insights section --%> + <%= if @preseem_insights != [] do %> +
+ <%= for insight <- @preseem_insights do %> +
+ "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" + + "warning" -> + "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800" + + _ -> + "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800" + end + ]}> +
+
"text-red-500" + "warning" -> "text-yellow-500" + _ -> "text-blue-500" + end + ]}> + <%= case insight.urgency do %> + <% "critical" -> %> + <.icon name="hero-exclamation-triangle" class="h-5 w-5" /> + <% "warning" -> %> + <.icon name="hero-exclamation-circle" class="h-5 w-5" /> + <% _ -> %> + <.icon name="hero-information-circle" class="h-5 w-5" /> + <% end %> +
+
+

+ {insight.title} +

+

+ {insight.description} +

+
+
+ +
+ <% end %> +
+ <% end %> + + <%!-- Score cards --%> +
+
+
QoE Score
+
+ {if @preseem_access_point.qoe_score, + do: Float.round(@preseem_access_point.qoe_score, 1), + else: "---"} +
+
+
+
+ {t("Capacity Score")} +
+
+ {if @preseem_access_point.capacity_score, + do: Float.round(@preseem_access_point.capacity_score, 1), + else: "---"} +
+
+
+
RF Score
+
+ {if @preseem_access_point.rf_score, + do: Float.round(@preseem_access_point.rf_score, 1), + else: "---"} +
+
+
+ + <%!-- Details card --%> +
+
+

+ {t("Access Point Details")} +

+
+
+
+
+
Name
+
+ {@preseem_access_point.name || "---"} +
+
+
+
+ {t("Preseem ID")} +
+
+ {@preseem_access_point.preseem_id} +
+
+
+
+ {t("Subscribers")} +
+
+ {@preseem_access_point.subscriber_count || "---"} +
+
+
+
+ {t("Busy Hours")} +
+
+ <%= if @preseem_access_point.busy_hours do %> + {@preseem_access_point.busy_hours} hrs/day + <% else %> + --- + <% end %> +
+
+
+
+ {t("Airtime Utilization")} +
+
+ <%= if @preseem_access_point.airtime_utilization do %> + {Float.round(@preseem_access_point.airtime_utilization, 1)}% + <% else %> + --- + <% end %> +
+
+
+
+ {t("Match Type")} +
+
+ {@preseem_access_point.match_confidence} +
+
+
+
+
+ + <%!-- Recent metrics table --%> + <%= if @preseem_metrics != [] do %> +
+
+

+ {t("Recent QoE Metrics")} +

+
+
+ + + + + + + + + + + + + + <%= for metric <- @preseem_metrics do %> + + + + + + + + + + <% end %> + +
+ {t("Time")} + + {t("Latency (ms)")} + + {t("P95 Latency")} + + {t("Jitter (ms)")} + + {t("Loss (%)")} + + {t("Throughput")} + + {t("Subscribers")} +
+ {ToweropsWeb.TimeHelpers.format_iso8601( + metric.recorded_at, + @current_scope.timezone, + @current_scope.time_format + )} + + {if metric.avg_latency, + do: Float.round(metric.avg_latency, 1), + else: "---"} + + {if metric.p95_latency, + do: Float.round(metric.p95_latency, 1), + else: "---"} + + {if metric.avg_jitter, + do: Float.round(metric.avg_jitter, 1), + else: "---"} + + {if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"} + + {if metric.avg_throughput, + do: "#{Float.round(metric.avg_throughput, 1)} Mbps", + else: "---"} + + {metric.subscriber_count || "---"} +
+
+
+ <% end %> +
+ <% else %> +
+

+ {t("No Preseem data linked to this device.")} +

+
+ <% end %> +
+ """ + end + + @impl true + def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do + case Towerops.Preseem.dismiss_insight(insight_id) do + {:ok, _} -> + # Reload preseem data after dismissing insight + {:noreply, load_preseem_data(socket)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to dismiss insight")} + end + end +end diff --git a/lib/towerops_web/live/device_live/components/wireless_tab.ex b/lib/towerops_web/live/device_live/components/wireless_tab.ex new file mode 100644 index 00000000..9c4a2b03 --- /dev/null +++ b/lib/towerops_web/live/device_live/components/wireless_tab.ex @@ -0,0 +1,195 @@ +defmodule ToweropsWeb.DeviceLive.Components.WirelessTab do + @moduledoc """ + LiveComponent for the device wireless clients tab. + + Displays wireless clients connected to the device with optional + subscriber matching from Gaiia integration. + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_wireless_data()} + end + + defp load_wireless_data(socket) do + device_id = socket.assigns.device.id + wireless_data = DataLoaders.load_wireless_data(device_id) + + socket + |> assign(:wireless_clients, wireless_data.wireless_clients) + |> assign(:wireless_client_subscribers, wireless_data.wireless_client_subscribers) + |> assign(:wireless_client_count, wireless_data.wireless_client_count) + |> assign(:wireless_matched_count, wireless_data.wireless_matched_count) + |> assign(:wireless_last_updated, wireless_data.wireless_last_updated) + |> assign(:wireless_clients_available, wireless_data.wireless_clients_available) + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @wireless_clients && length(@wireless_clients) > 0 do %> +
+
+
+

+ {t("Connected Wireless Clients")} +

+

+ {length(@wireless_clients)} clients — {@wireless_matched_count} subscribers matched + <%= if @wireless_last_updated do %> + · Last updated {format_relative_time(@wireless_last_updated)} + <% end %> +

+
+
+
+ + + + + + + + + + + + + + + + <%= for client <- @wireless_clients do %> + <% subscriber_link = + Map.get(@wireless_client_subscribers, String.downcase(client.mac_address)) %> + + + + + + + + + + + + <% end %> + +
MAC AddressIP AddressSubscriberSignalSNRDistanceTX RateRX RateUptime
+ + {client.mac_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" + /> + + + <%= if client.ip_address do %> + + {client.ip_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400" + /> + + <% else %> + + <% end %> + + <%= if subscriber_link && subscriber_link.gaiia_account do %> + + {subscriber_link.gaiia_account.account_name} + + <% else %> + + <% end %> + + <%= if client.signal_strength do %> + <.signal_badge value={client.signal_strength} /> + <% else %> + + <% end %> + + <%= if client.snr do %> + <.snr_badge value={client.snr} /> + <% else %> + + <% end %> + + <%= if client.distance do %> + {client.distance}m + <% else %> + + <% end %> + + {format_rate(client.tx_rate)} + + {format_rate(client.rx_rate)} + + <%= if client.uptime_seconds do %> + {format_uptime(client.uptime_seconds * 100)} + <% else %> + + <% end %> +
+
+
+ <% else %> +
+ <.icon + name="hero-signal" + class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600" + /> +

+ {t("No wireless clients")} +

+

+ {t("No clients are currently connected to this access point.")} +

+
+ <% end %> +
+ """ + end + + # Helper functions + defp format_relative_time(datetime) do + ToweropsWeb.TimeHelpers.format_time_ago(datetime) + end + + defp format_rate(nil), do: "—" + + defp format_rate(kbps) when is_integer(kbps) do + mbps = kbps / 1000 + "#{Float.round(mbps, 1)} Mbps" + end + + defp format_uptime(timeticks) when is_integer(timeticks) do + seconds = div(timeticks, 100) + hours = div(seconds, 3600) + remainder = rem(seconds, 3600) + minutes = div(remainder, 60) + + cond do + hours > 0 -> "#{hours}h #{minutes}m" + minutes > 0 -> "#{minutes}m" + true -> "<1m" + end + end + + defp format_uptime(_), do: "—" +end diff --git a/lib/towerops_web/live/device_live/helpers/chart_builders.ex b/lib/towerops_web/live/device_live/helpers/chart_builders.ex new file mode 100644 index 00000000..abfa3045 --- /dev/null +++ b/lib/towerops_web/live/device_live/helpers/chart_builders.ex @@ -0,0 +1,480 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do + @moduledoc """ + Chart data building functions for DeviceLive.Show. + + Pure functions that transform time-series data into Chart.js-compatible JSON. + All functions return nil for empty data or JSON-encoded strings for valid datasets. + """ + + alias Towerops.Monitoring + alias Towerops.Snmp + + require Logger + + @doc """ + Loads processor CPU usage chart data for the last 24 hours. + + Returns JSON-encoded chart data or nil if no data available. + """ + @spec load_processor_chart_data(list()) :: String.t() | nil + def load_processor_chart_data([]), do: nil + + def load_processor_chart_data(processors) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + datasets = + processors + |> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago)) + |> Enum.reject(&Enum.empty?(&1.data)) + + if Enum.empty?(datasets) do + nil + else + safe_encode_chart_data(%{datasets: datasets}) + end + end + + defp processor_to_chart_dataset(processor, since) do + readings = + processor.id + |> Snmp.get_processor_readings(since: since, limit: 1000) + |> Enum.reverse() + + label = + if processor.description do + processor.description + else + "CPU #{processor.processor_index}" + end + + %{ + label: label, + data: Enum.map(readings, &processor_reading_to_data_point/1) + } + end + + defp processor_reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.load_percent, do: Float.round(reading.load_percent, 1)) + } + end + + @doc """ + Loads storage volume usage chart data for the last 24 hours. + + Returns JSON-encoded chart data or nil if no data available. + """ + @spec load_storage_volume_chart_data(list()) :: String.t() | nil + def load_storage_volume_chart_data([]), do: nil + + def load_storage_volume_chart_data(storage_volumes) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + datasets = + storage_volumes + |> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago)) + |> Enum.reject(&Enum.empty?(&1.data)) + + if Enum.empty?(datasets) do + nil + else + safe_encode_chart_data(%{datasets: datasets}) + end + end + + defp storage_volume_to_chart_dataset(storage, since) do + readings = + storage.id + |> Snmp.get_storage_readings(since: since, limit: 1000) + |> Enum.reverse() + + label = storage.description || "Storage #{storage.storage_index}" + + %{ + label: label, + data: Enum.map(readings, &storage_reading_to_data_point/1) + } + end + + defp storage_reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1)) + } + end + + @doc """ + Loads sensor chart data for specific sensor types. + + Returns JSON-encoded chart data or nil if no matching sensors found. + """ + @spec load_sensor_chart_data(Snmp.SNMPDevice.t() | nil, list(String.t())) :: + String.t() | nil + def load_sensor_chart_data(nil, _sensor_types), do: nil + + def load_sensor_chart_data(snmp_device, sensor_types) when is_list(sensor_types) do + build_sensor_datasets_json(snmp_device, sensor_types) + end + + defp build_sensor_datasets_json(device, sensor_types) do + sensors = + device.sensors + |> Enum.filter(&(&1.sensor_type in sensor_types)) + |> Enum.sort_by(& &1.sensor_index) + + if Enum.empty?(sensors) do + nil + else + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + datasets = Enum.map(sensors, &sensor_to_chart_dataset(&1, twenty_four_hours_ago)) + safe_encode_chart_data(%{datasets: datasets}) + end + end + + defp sensor_to_chart_dataset(sensor, since) do + readings = + sensor.id + |> Snmp.get_sensor_readings(since: since, limit: 1000) + |> Enum.reverse() + + %{ + label: sensor.sensor_descr, + data: Enum.map(readings, &reading_to_data_point/1) + } + end + + defp reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.value, do: Float.round(reading.value, 1)) + } + end + + @doc """ + Loads ICMP latency chart data for the last 24 hours. + + Returns JSON-encoded chart data or nil if no latency data available. + """ + @spec load_latency_chart_data(binary()) :: String.t() | nil + def load_latency_chart_data(device_id) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + checks = + device_id + |> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000) + |> Enum.reverse() + + if Enum.empty?(checks) do + nil + else + dataset = %{ + label: "ICMP Latency", + data: Enum.map(checks, &latency_check_to_data_point/1) + } + + safe_encode_chart_data(%{datasets: [dataset]}) + end + end + + defp latency_check_to_data_point(check) do + %{ + x: DateTime.to_unix(check.checked_at, :millisecond), + y: check.response_time_ms + } + end + + @doc """ + Loads overall device traffic chart data (aggregate of all interfaces). + + Returns JSON-encoded chart data or nil if no traffic data available. + """ + @spec load_overall_traffic_chart_data(Snmp.SNMPDevice.t() | nil) :: String.t() | nil + def load_overall_traffic_chart_data(nil), do: nil + + def load_overall_traffic_chart_data(snmp_device) do + build_overall_traffic_chart_json(snmp_device) + end + + defp build_overall_traffic_chart_json(device) do + if Enum.empty?(device.interfaces) do + nil + else + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + all_stats = aggregate_interface_traffic_stats(device.interfaces, twenty_four_hours_ago) + + if Enum.empty?(all_stats) do + nil + else + build_traffic_json_datasets(all_stats, device) + end + end + end + + # Calculate BPS per interface first, then aggregate by timestamp + # This avoids issues with misaligned timestamps between interfaces + # Processes all interfaces (regardless of monitored status) to show total device traffic + defp aggregate_interface_traffic_stats(interfaces, since) do + interfaces + |> Enum.flat_map(fn interface -> + interface.id + |> Snmp.get_interface_stats(since: since, limit: 1000) + |> Enum.reverse() + |> calculate_interface_bps() + end) + |> Enum.group_by(& &1.timestamp_ms) + |> Enum.map(fn {ts, bps_list} -> + total_in = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.in_bps end) + total_out = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.out_bps end) + %{timestamp_ms: ts, in_bps: total_in, out_bps: total_out} + end) + |> Enum.sort_by(& &1.timestamp_ms) + end + + # Calculate BPS for a single interface's stats + defp calculate_interface_bps(stats) do + stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [s1, s2] -> + time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1) + + in_bps = + if s2.if_in_octets && s1.if_in_octets do + calculate_counter_bps(s1.if_in_octets, s2.if_in_octets, time_diff) + else + 0.0 + end + + out_bps = + if s2.if_out_octets && s1.if_out_octets do + calculate_counter_bps(s1.if_out_octets, s2.if_out_octets, time_diff) + else + 0.0 + end + + # Round timestamp to nearest minute for aggregation (avoids microsecond misalignment) + ts_seconds = DateTime.to_unix(s2.checked_at) + rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000 + + %{timestamp_ms: rounded_ts_ms, in_bps: in_bps, out_bps: out_bps} + end) + end + + defp calculate_counter_bps(prev_octets, curr_octets, time_diff) do + Towerops.Capacity.calculate_bps(prev_octets, curr_octets, time_diff) + end + + defp build_traffic_json_datasets(all_stats, device) do + in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end) + + out_data = + Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end) + + datasets = [ + %{label: "Outbound", data: out_data}, + %{label: "Inbound", data: in_data} + ] + + # For backhaul devices, add capacity reference lines + datasets = + if device.device.device_role == "backhaul" do + add_capacity_datasets(datasets, all_stats, device) + else + datasets + end + + Jason.encode!(%{datasets: datasets}) + end + + # Add capacity reference lines for backhaul devices + # Calculates capacity per timestamp based on which interfaces have data at that point + # For radios with Rx/Tx Capacity sensors, uses those values as total device capacity + defp add_capacity_datasets(datasets, all_stats, device) do + # Get capacity sensors if available (for radios like AirFiber) + rx_capacity_sensor = Enum.find(device.sensors, &(&1.sensor_descr == "Rx Capacity")) + tx_capacity_sensor = Enum.find(device.sensors, &(&1.sensor_descr == "Tx Capacity")) + + # If we have capacity sensors, use them as the total device capacity + # Otherwise, calculate from interface speeds + capacity_data = + if rx_capacity_sensor || tx_capacity_sensor do + calculate_sensor_capacity_data(all_stats, rx_capacity_sensor, tx_capacity_sensor) + else + calculate_interface_capacity_data(all_stats, device) + end + + # Only add capacity datasets if we have at least some capacity data + max_rx = capacity_data |> Enum.map(fn {_, rx, _} -> rx end) |> Enum.max(fn -> 0 end) + max_tx = capacity_data |> Enum.map(fn {_, _, tx} -> tx end) |> Enum.max(fn -> 0 end) + + if max_rx > 0 || max_tx > 0 do + capacity_in_data = + Enum.map(capacity_data, fn {ts, rx, _tx} -> + %{x: ts, y: Float.round(rx * 1.0, 2)} + end) + + capacity_out_data = + Enum.map(capacity_data, fn {ts, _rx, tx} -> + %{x: ts, y: -Float.round(tx * 1.0, 2)} + end) + + # Add capacity datasets with special styling + datasets ++ + [ + %{ + label: "Capacity (In)", + data: capacity_in_data, + borderDash: [5, 5], + borderWidth: 2, + pointRadius: 0, + fill: false + }, + %{ + label: "Capacity (Out)", + data: capacity_out_data, + borderDash: [5, 5], + borderWidth: 2, + pointRadius: 0, + fill: false + } + ] + else + datasets + end + end + + # Calculate capacity data using sensor values (for radios with Rx/Tx Capacity sensors) + # Sensor values represent total device capacity, not per-interface + # Loads time-series sensor readings to create a dynamic capacity line + defp calculate_sensor_capacity_data(all_stats, rx_capacity_sensor, tx_capacity_sensor) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + # Load time-series readings for both sensors + rx_readings = load_capacity_sensor_readings(rx_capacity_sensor, twenty_four_hours_ago) + tx_readings = load_capacity_sensor_readings(tx_capacity_sensor, twenty_four_hours_ago) + + # If we have sensor readings, use them directly (they have their own timestamps) + # Otherwise fall back to static values at traffic timestamps + if map_size(rx_readings) > 0 || map_size(tx_readings) > 0 do + build_capacity_data_from_sensors( + rx_readings, + tx_readings, + rx_capacity_sensor, + tx_capacity_sensor + ) + else + build_static_capacity_data(all_stats, rx_capacity_sensor, tx_capacity_sensor) + end + end + + # Load sensor readings and convert to timestamp -> bps map + defp load_capacity_sensor_readings(nil, _since), do: %{} + + defp load_capacity_sensor_readings(sensor, since) do + sensor.id + |> Snmp.get_sensor_readings(since: since, limit: 1000) + |> Map.new(&sensor_reading_to_capacity_point/1) + end + + # Convert a sensor reading to {timestamp_ms, bps} tuple + # Sensor values are in Mbps, convert to bps + defp sensor_reading_to_capacity_point(reading) do + bps = capacity_value_to_bps(reading.value) + ts_ms = DateTime.to_unix(reading.checked_at, :millisecond) + {ts_ms, bps} + end + + # Convert capacity sensor value (in Mbps) to bps + defp capacity_value_to_bps(nil), do: 0 + defp capacity_value_to_bps(value) when is_number(value), do: round(value * 1_000_000) + defp capacity_value_to_bps(_), do: 0 + + # Build capacity data points from sensor readings + defp build_capacity_data_from_sensors(rx_readings, tx_readings, rx_sensor, tx_sensor) do + # Combine all unique timestamps from both sensors + all_timestamps = + (Map.keys(rx_readings) ++ Map.keys(tx_readings)) + |> MapSet.new() + |> Enum.sort() + + # Build capacity data points using sensor readings + Enum.map(all_timestamps, fn ts_ms -> + rx_bps = Map.get(rx_readings, ts_ms, get_fallback_capacity_bps(rx_sensor)) + tx_bps = Map.get(tx_readings, ts_ms, get_fallback_capacity_bps(tx_sensor)) + {ts_ms, rx_bps, tx_bps} + end) + end + + # Build static capacity data at traffic timestamps + defp build_static_capacity_data(all_stats, rx_sensor, tx_sensor) do + rx_cap_bps = get_fallback_capacity_bps(rx_sensor) + tx_cap_bps = get_fallback_capacity_bps(tx_sensor) + + Enum.map(all_stats, fn stat -> + {stat.timestamp_ms, rx_cap_bps, tx_cap_bps} + end) + end + + # Get fallback capacity in bps from sensor's last_value (already in Mbps) + defp get_fallback_capacity_bps(nil), do: 0 + + defp get_fallback_capacity_bps(sensor) do + capacity_value_to_bps(sensor.last_value) + end + + # Calculate capacity data from interface configured_capacity_bps or if_speed + # Sums capacity of active interfaces at each timestamp + defp calculate_interface_capacity_data(all_stats, device) do + # Build a map of interface_id -> {rx_capacity, tx_capacity} for quick lookup + # Includes all interfaces to match the total device traffic + interface_capacity_map = + Map.new(device.interfaces, fn interface -> + cap = interface.configured_capacity_bps || interface.if_speed || 0 + {interface.id, {cap, cap}} + end) + + # Get the raw interface stats to determine which interfaces were active at each timestamp + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + # Track which interfaces have data at each timestamp + interface_activity_by_timestamp = + device.interfaces + |> Enum.flat_map(fn interface -> + interface.id + |> Snmp.get_interface_stats(since: twenty_four_hours_ago, limit: 1000) + |> Enum.map(fn stat -> + ts_seconds = DateTime.to_unix(stat.checked_at) + rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000 + {rounded_ts_ms, interface.id} + end) + end) + |> Enum.group_by(fn {ts, _} -> ts end, fn {_, iface_id} -> iface_id end) + + # Calculate capacity at each timestamp based on active interfaces + Enum.map(all_stats, fn stat -> + active_interfaces = Map.get(interface_activity_by_timestamp, stat.timestamp_ms, []) + + {total_rx, total_tx} = + active_interfaces + |> Enum.uniq() + |> Enum.reduce({0, 0}, fn iface_id, {rx_acc, tx_acc} -> + {rx_cap, tx_cap} = Map.get(interface_capacity_map, iface_id, {0, 0}) + {rx_acc + rx_cap, tx_acc + tx_cap} + end) + + {stat.timestamp_ms, total_rx, total_tx} + end) + end + + # Safely encode chart data to JSON, returning empty dataset on error + defp safe_encode_chart_data(data) do + case Jason.encode(data) do + {:ok, encoded} -> + encoded + + {:error, reason} -> + Logger.error("Failed to encode chart data: #{inspect(reason)}") + Jason.encode!(%{datasets: []}) + end + end +end diff --git a/lib/towerops_web/live/device_live/helpers/data_loaders.ex b/lib/towerops_web/live/device_live/helpers/data_loaders.ex new file mode 100644 index 00000000..ad4e8219 --- /dev/null +++ b/lib/towerops_web/live/device_live/helpers/data_loaders.ex @@ -0,0 +1,496 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.DataLoaders do + @moduledoc """ + Data loading functions for DeviceLive.Show. + + Pure functions that fetch data from the database without side effects. + All functions return data structures (maps, lists, structs) that can be + assigned to socket assigns. + """ + + alias Towerops.Agents + alias Towerops.Devices + alias Towerops.Devices.Firmware + alias Towerops.Devices.MikrotikBackups + alias Towerops.Gaiia + alias Towerops.Monitoring + alias Towerops.Snmp + + @doc """ + Loads agent information for display. + + Returns a map with agent details including type (cloud/agent), name, + connection status, and last seen timestamp. + """ + @spec load_agent_info(binary() | nil, atom()) :: map() + def load_agent_info(nil, :none) do + %{ + agent_token_id: nil, + type: :cloud, + name: "Cloud Polling", + source: nil, + last_seen_at: nil, + is_offline: false + } + end + + def load_agent_info(agent_token_id, source) do + agent_token = Agents.get_agent_token!(agent_token_id) + is_offline = agent_offline?(agent_token) + + %{ + agent_token_id: agent_token_id, + type: if(agent_token.is_cloud_poller, do: :cloud, else: :agent), + name: agent_token.name, + source: source, + last_seen_at: agent_token.last_seen_at, + is_offline: is_offline + } + rescue + Ecto.NoResultsError -> + # Agent token no longer exists, fall back to cloud polling display + %{ + agent_token_id: nil, + type: :cloud, + name: "Cloud Polling (agent not found)", + source: nil, + last_seen_at: nil, + is_offline: false + } + end + + # Agent is considered offline if it hasn't been seen in the last 5 minutes + defp agent_offline?(%{is_cloud_poller: true}), do: false + defp agent_offline?(%{last_seen_at: nil}), do: true + + defp agent_offline?(%{last_seen_at: last_seen_at}) do + five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute) + DateTime.before?(last_seen_at, five_minutes_ago) + end + + @doc """ + Loads SNMP device data with associations (interfaces, sensors). + + Returns a map with `:device`, `:interfaces`, and `:sensors` keys. + Enriches sensors and interfaces with their latest readings/stats. + """ + @spec load_snmp_data(binary()) :: %{ + device: Snmp.SNMPDevice.t() | nil, + interfaces: list(), + sensors: list() + } + def load_snmp_data(device_id) do + case Snmp.get_device_with_associations(device_id) do + nil -> + %{device: nil, interfaces: [], sensors: []} + + device -> + # Batch load latest readings for all sensors in a single query + sensor_ids = Enum.map(device.sensors, & &1.id) + latest_readings_map = Snmp.get_latest_sensor_readings_batch(sensor_ids) + + sensors_with_readings = + Enum.map(device.sensors, fn sensor -> + latest_reading = Map.get(latest_readings_map, sensor.id) + Map.put(sensor, :latest_reading, latest_reading) + end) + + # Batch load latest stats for all interfaces in a single query + interface_ids = Enum.map(device.interfaces, & &1.id) + latest_stats_map = Snmp.get_latest_interface_stats_batch(interface_ids) + + interfaces_with_stats = + Enum.map(device.interfaces, fn interface -> + latest_stat = Map.get(latest_stats_map, interface.id) + Map.put(interface, :latest_stat, latest_stat) + end) + + %{ + device: device, + interfaces: interfaces_with_stats, + sensors: sensors_with_readings + } + end + end + + @doc """ + Loads VLAN information for an SNMP device. + """ + @spec load_vlans(Snmp.SNMPDevice.t() | nil) :: list() + def load_vlans(nil), do: [] + def load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id) + + @doc """ + Loads IP addresses for an SNMP device. + """ + @spec load_ip_addresses(Snmp.SNMPDevice.t() | nil) :: list() + def load_ip_addresses(nil), do: [] + def load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id) + + @doc """ + Loads processor information for an SNMP device. + """ + @spec load_processors(Snmp.SNMPDevice.t() | nil) :: list() + def load_processors(nil), do: [] + def load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id) + + @doc """ + Loads storage information for an SNMP device. + """ + @spec load_storage(Snmp.SNMPDevice.t() | nil) :: list() + def load_storage(nil), do: [] + def load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id) + + @doc """ + Loads recent configuration changes for a MikroTik device. + + Returns a list of config changes from the last 30 days (max 5). + Returns empty list if device doesn't have MikroTik API enabled. + """ + @spec load_recent_config_changes(Devices.Device.t()) :: list() + def load_recent_config_changes(device) do + if device.mikrotik_enabled do + since = DateTime.add(DateTime.utc_now(), -30 * 24 * 3600, :second) + Towerops.ConfigChanges.list_device_changes(device.id, after: since, limit: 5) + else + [] + end + end + + @doc """ + Gets available firmware information for an SNMP device. + + Returns firmware release information if available, nil otherwise. + Currently only supports MikroTik RouterOS. + """ + @spec get_available_firmware(Snmp.SNMPDevice.t() | nil) :: map() | nil + def get_available_firmware(nil), do: nil + + def get_available_firmware(snmp_device) do + vendor = determine_vendor(snmp_device.manufacturer) + product_line = determine_product_line(snmp_device.manufacturer) + + # Only check firmware if both vendor and product_line are known + # Currently only MikroTik/RouterOS is supported + if vendor && product_line do + Firmware.get_latest_firmware_release(vendor, product_line) + end + end + + defp determine_vendor(manufacturer) when is_binary(manufacturer) do + manufacturer_lower = String.downcase(manufacturer) + + cond do + String.contains?(manufacturer_lower, "mikrotik") -> "mikrotik" + String.contains?(manufacturer_lower, "cisco") -> "cisco" + String.contains?(manufacturer_lower, "ubiquiti") -> "ubiquiti" + true -> nil + end + end + + defp determine_vendor(_), do: nil + + defp determine_product_line(manufacturer) when is_binary(manufacturer) do + manufacturer_lower = String.downcase(manufacturer) + + if String.contains?(manufacturer_lower, "mikrotik") do + "routeros" + end + end + + defp determine_product_line(_), do: nil + + @doc """ + Loads neighbors (LLDP/CDP) with equipment associations. + """ + @spec load_neighbors(binary()) :: list() + def load_neighbors(device_id) do + Snmp.list_neighbors_with_equipment(device_id) + end + + @doc """ + Loads ARP entries for a device. + """ + @spec load_arp_entries(binary()) :: list() + def load_arp_entries(device_id) do + Snmp.list_arp_entries(device_id) + end + + @doc """ + Loads MAC address table for a device. + """ + @spec load_mac_addresses(binary()) :: list() + def load_mac_addresses(device_id) do + Snmp.list_mac_addresses(device_id) + end + + @doc """ + Loads device events (logs). + """ + @spec load_device_events(binary(), pos_integer()) :: list() + def load_device_events(device_id, limit \\ 100) do + Devices.list_devices_events(device_id, limit) + end + + @doc """ + Loads MikroTik backups for a device. + + Returns empty list if device doesn't have MikroTik API enabled. + """ + @spec load_mikrotik_backups(Devices.Device.t(), pos_integer()) :: list() + def load_mikrotik_backups(device, limit \\ 50) do + if device.mikrotik_enabled do + MikrotikBackups.list_device_backups(device.id, limit: limit) + else + [] + end + end + + @doc """ + Loads monitoring checks for a device. + + Returns a map with `:checks` (all checks) and `:grouped_checks` (checks grouped by type). + """ + @spec load_checks_data(binary(), binary()) :: %{checks: list(), grouped_checks: map()} + def load_checks_data(device_id, organization_id) do + # Check type to group key mapping + check_type_groups = %{ + "snmp_sensor" => :snmp_sensors, + "snmp_interface" => :snmp_interfaces, + "snmp_processor" => :snmp_processors, + "snmp_storage" => :snmp_storage, + "http" => :http_checks, + "tcp" => :tcp_checks, + "dns" => :dns_checks, + "ssl" => :ssl_checks, + "ping" => :ping_checks + } + + checks = Monitoring.list_checks(organization_id, device_id: device_id) + + grouped_checks = + Enum.group_by(checks, fn check -> + Map.get(check_type_groups, check.check_type, :other_checks) + end) + + %{checks: checks, grouped_checks: grouped_checks} + end + + @doc """ + Loads Gaiia integration data for a device. + + Returns a map with `:gaiia_item`, `:gaiia_account`, `:gaiia_subscriptions`, and `:gaiia_network_site`. + """ + @spec load_gaiia_data(Devices.Device.t()) :: map() + def load_gaiia_data(device) do + gaiia_item = Gaiia.get_inventory_item_for_device(device.id) + + {gaiia_account, gaiia_subscriptions, gaiia_network_site} = + if gaiia_item do + account = + if gaiia_item.assigned_account_gaiia_id do + Gaiia.get_account(device.organization_id, gaiia_item.assigned_account_gaiia_id) + end + + subscriptions = + if gaiia_item.assigned_account_gaiia_id do + Gaiia.list_billing_subscriptions_for_account( + device.organization_id, + gaiia_item.assigned_account_gaiia_id + ) + else + [] + end + + network_site = + if gaiia_item.assigned_network_site_gaiia_id do + Gaiia.get_network_site( + device.organization_id, + gaiia_item.assigned_network_site_gaiia_id + ) + end + + {account, subscriptions, network_site} + else + {nil, [], nil} + end + + %{ + gaiia_item: gaiia_item, + gaiia_account: gaiia_account, + gaiia_subscriptions: gaiia_subscriptions, + gaiia_network_site: gaiia_network_site + } + end + + @doc """ + Loads Preseem integration data for a device. + + Returns a map with `:preseem_access_point`, `:preseem_metrics`, and `:preseem_insights`. + """ + @spec load_preseem_data(Devices.Device.t()) :: map() + def load_preseem_data(device) do + preseem_ap = Towerops.Preseem.get_access_point_for_device(device.id) + + {metrics, insights} = + if preseem_ap do + { + Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50), + Towerops.Preseem.list_device_insights(device.id) + } + else + {[], []} + end + + %{ + preseem_access_point: preseem_ap, + preseem_metrics: metrics, + preseem_insights: insights + } + end + + @doc """ + Loads wireless clients for a device with subscriber matching. + + Returns a map with wireless client data, subscriber mappings, and statistics. + """ + @spec load_wireless_data(binary()) :: map() + def load_wireless_data(device_id) do + wireless_clients = Snmp.list_wireless_clients(device_id) + + # Build MAC → subscriber link mapping + wireless_client_subscribers = + if Enum.any?(wireless_clients) do + # Get all device-subscriber links for this device + links = Gaiia.list_device_subscriber_links_with_macs(device_id) + + # Build a map of MAC address → link + Map.new(links, &{String.downcase(&1.subscriber_mac), &1}) + else + %{} + end + + # Count matched subscribers + matched_count = + Enum.count(wireless_clients, fn client -> + Map.has_key?(wireless_client_subscribers, String.downcase(client.mac_address)) + end) + + # Get last update timestamp + last_updated = + wireless_clients + |> Enum.map(& &1.last_seen_at) + |> Enum.max(DateTime, fn -> nil end) + + %{ + wireless_clients: wireless_clients, + wireless_client_subscribers: wireless_client_subscribers, + wireless_client_count: length(wireless_clients), + wireless_matched_count: matched_count, + wireless_last_updated: last_updated, + wireless_clients_available: wireless_clients != [] + } + end + + @doc """ + Loads transceiver data for a device. + """ + @spec load_transceivers(Snmp.SNMPDevice.t() | nil) :: map() + def load_transceivers(nil) do + %{ + transceivers: [], + transceivers_available: false + } + end + + def load_transceivers(snmp_device) do + transceivers = Snmp.list_transceivers(snmp_device.id) + + %{ + transceivers: transceivers, + transceivers_available: transceivers != [] + } + end + + @doc """ + Loads printer supplies data for a device. + """ + @spec load_printer_supplies(Snmp.SNMPDevice.t() | nil) :: map() + def load_printer_supplies(nil) do + %{ + printer_supplies: [], + printer_supplies_available: false + } + end + + def load_printer_supplies(snmp_device) do + printer_supplies = Snmp.list_printer_supplies(snmp_device.id) + + %{ + printer_supplies: printer_supplies, + printer_supplies_available: printer_supplies != [] + } + end + + @doc """ + Loads hardware inventory (entity physical) data for a device. + """ + @spec load_hardware_inventory(Snmp.SNMPDevice.t() | nil) :: map() + def load_hardware_inventory(nil) do + %{ + hardware_inventory: [], + hardware_inventory_available: false + } + end + + def load_hardware_inventory(snmp_device) do + hardware_inventory = Snmp.list_entity_physical(snmp_device.id) + + %{ + hardware_inventory: hardware_inventory, + hardware_inventory_available: hardware_inventory != [] + } + end + + @doc """ + Groups IP addresses by interface ID for efficient lookup. + """ + @spec group_ip_addresses_by_interface(list()) :: map() + def group_ip_addresses_by_interface(ip_addresses) do + Enum.group_by(ip_addresses, & &1.snmp_interface_id) + end + + @doc """ + Loads connected devices (topology) for a device. + """ + @spec load_connected_devices(binary()) :: list() + def load_connected_devices(device_id) do + Towerops.Topology.list_connected_devices(device_id) + end + + @doc """ + Calculates uptime metrics from monitoring checks. + + Returns a map with uptime_percentage, total_checks, and successful_checks. + """ + @spec calculate_metrics(list(), Devices.Device.t()) :: map() + def calculate_metrics(checks, _equipment) do + total_checks = length(checks) + + if total_checks > 0 do + successful_checks = Enum.count(checks, &(&1.status == :success)) + uptime_percentage = Float.round(successful_checks / total_checks * 100, 1) + + %{ + uptime_percentage: uptime_percentage, + total_checks: total_checks, + successful_checks: successful_checks + } + else + %{ + uptime_percentage: 0, + total_checks: 0, + successful_checks: 0 + } + end + end +end diff --git a/lib/towerops_web/live/device_live/helpers/formatters.ex b/lib/towerops_web/live/device_live/helpers/formatters.ex new file mode 100644 index 00000000..a8c29489 --- /dev/null +++ b/lib/towerops_web/live/device_live/helpers/formatters.ex @@ -0,0 +1,306 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do + @moduledoc """ + Formatting helper functions for DeviceLive.Show. + + Pure functions that format values for display without side effects. + """ + + @doc """ + Formats a date to human-readable string. + + Examples: + - DateTime ~U[2024-02-15 10:30:00Z] -> "February 15, 2024" + - nil -> "" + """ + @spec format_date(DateTime.t() | nil) :: String.t() + def format_date(nil), do: "" + + def format_date(date) do + Calendar.strftime(date, "%B %-d, %Y") + end + + @doc """ + Formats a speed in bps to human-readable units (Kbps, Mbps, Gbps). + + Examples: + - 1_500_000_000 -> "1.5 Gbps" + - 100_000_000 -> "100.0 Mbps" + - 512 -> "512 bps" + - nil -> "-" + """ + @spec format_speed(integer() | nil) :: String.t() + def format_speed(speed_bps) when is_integer(speed_bps) do + cond do + speed_bps >= 1_000_000_000 -> + "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" + + speed_bps >= 1_000_000 -> + "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" + + speed_bps >= 1_000 -> + "#{Float.round(speed_bps / 1_000, 1)} Kbps" + + true -> + "#{speed_bps} bps" + end + end + + def format_speed(_), do: "-" + + @doc """ + Formats bytes to human-readable units (KB, MB, GB, TB). + + Examples: + - 1_073_741_824 -> "1.0 GB" + - 1_048_576 -> "1.0 MB" + - 0 -> "0 B" + - nil -> "-" + """ + @spec format_bytes(integer() | nil) :: String.t() + def format_bytes(nil), do: "-" + def format_bytes(0), do: "0 B" + + def format_bytes(bytes) when is_integer(bytes) do + cond do + bytes >= 1_099_511_627_776 -> + "#{Float.round(bytes / 1_099_511_627_776, 1)} TB" + + bytes >= 1_073_741_824 -> + "#{Float.round(bytes / 1_073_741_824, 1)} GB" + + bytes >= 1_048_576 -> + "#{Float.round(bytes / 1_048_576, 1)} MB" + + bytes >= 1024 -> + "#{Float.round(bytes / 1024, 1)} KB" + + true -> + "#{bytes} B" + end + end + + def format_bytes(_), do: "-" + + @doc """ + Formats a sensor value (already divided by divisor). + + Returns value as string with 1 decimal place. + """ + @spec format_sensor_value(number() | nil, any()) :: String.t() + def format_sensor_value(value, _divisor) when is_number(value) do + # Value is already divided by divisor when stored in sensor_reading + # Format as string to avoid scientific notation for large numbers + :erlang.float_to_binary(value * 1.0, decimals: 1) + end + + def format_sensor_value(_, _), do: "-" + + @doc """ + Formats a location string, parsing GPS coordinates to 6 decimal places. + + Examples: + - "40.7128, -74.0060" -> "40.712800, -74.006000" + - "New York" -> "New York" + - nil -> "N/A" + """ + @spec format_location(String.t() | nil) :: String.t() + def format_location(location) when is_binary(location) do + if gps_coordinates?(location) do + format_gps_coordinates(location) + else + location + end + end + + def format_location(nil), do: "N/A" + def format_location(_), do: "N/A" + + defp gps_coordinates?(location) do + String.contains?(location, ",") && String.match?(location, ~r/[-\d.]+,\s*[-\d.]+/) + end + + defp format_gps_coordinates(location) do + location + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.map_join(", ", &format_coordinate/1) + end + + defp format_coordinate(coord) do + case Float.parse(coord) do + {num, _} -> :erlang.float_to_binary(num, decimals: 6) + :error -> coord + end + end + + @doc """ + Formats a DateTime as "time ago" relative to now. + + Examples: + - 30 seconds ago -> "30s ago" + - 5 minutes ago -> "5m ago" + - 2 hours ago -> "2h ago" + - 7 days ago -> "7d ago" + - nil -> "Never" + """ + @spec time_ago(DateTime.t() | nil) :: String.t() + def time_ago(nil), do: "Never" + + def time_ago(datetime) do + diff = DateTime.diff(DateTime.utc_now(), datetime, :second) + + cond do + diff < 60 -> "#{diff}s ago" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86_400 -> "#{div(diff, 3600)}h ago" + true -> "#{div(diff, 86_400)}d ago" + end + end + + @doc """ + Formats SNMP uptime (in timeticks) to human-readable duration. + + Timeticks are in hundredths of a second (centiseconds). + + Examples: + - 8_640_000 (1 day) -> "1 day" + - 360_000 (1 hour) -> "1 hour" + - nil -> "N/A" + """ + @spec format_uptime(integer() | nil) :: String.t() + def format_uptime(nil), do: "N/A" + + def format_uptime(timeticks) when is_integer(timeticks) do + # Timeticks are in hundredths of a second + seconds = div(timeticks, 100) + format_duration(seconds) + end + + def format_uptime(_), do: "N/A" + + @doc """ + Formats a duration in seconds to human-readable string. + + Examples: + - 86400 -> "1 day" + - 3661 -> "1 hour 1 minute" + - 45 -> "less than a minute" + """ + @spec format_duration(integer()) :: String.t() + def format_duration(total_seconds) do + {weeks, remainder} = seconds_to_weeks(total_seconds) + {days, remainder} = seconds_to_days(remainder) + {hours, remainder} = seconds_to_hours(remainder) + {minutes, _seconds} = seconds_to_minutes(remainder) + + parts = + Enum.reject( + [ + format_time_part(weeks, "week"), + format_time_part(days, "day"), + format_time_part(hours, "hour"), + format_time_part(minutes, "minute") + ], + &is_nil/1 + ) + + case parts do + [] -> "less than a minute" + parts -> Enum.join(parts, " ") + end + end + + @spec seconds_to_weeks(integer()) :: {integer(), integer()} + defp seconds_to_weeks(seconds), do: {div(seconds, 604_800), rem(seconds, 604_800)} + + @spec seconds_to_days(integer()) :: {integer(), integer()} + defp seconds_to_days(seconds), do: {div(seconds, 86_400), rem(seconds, 86_400)} + + @spec seconds_to_hours(integer()) :: {integer(), integer()} + defp seconds_to_hours(seconds), do: {div(seconds, 3600), rem(seconds, 3600)} + + @spec seconds_to_minutes(integer()) :: {integer(), integer()} + defp seconds_to_minutes(seconds), do: {div(seconds, 60), rem(seconds, 60)} + + @spec format_time_part(integer(), String.t()) :: String.t() | nil + defp format_time_part(0, _unit), do: nil + defp format_time_part(1, unit), do: "1 #{unit}" + defp format_time_part(count, unit), do: "#{count} #{unit}s" + + @doc """ + Formats device age (time since created) as duration + "ago". + + Examples: + - 1 day old -> "1 day ago" + - nil -> "N/A" + """ + @spec format_device_age(DateTime.t() | nil) :: String.t() + def format_device_age(nil), do: "N/A" + + def format_device_age(datetime) do + diff = DateTime.diff(DateTime.utc_now(), datetime, :second) + format_duration(diff) <> " ago" + end + + @doc """ + Formats event type from snake_case to Title Case. + + Examples: + - "interface_down" -> "Interface down" + - "sensor_threshold_exceeded" -> "Sensor threshold exceeded" + """ + @spec format_event_type(String.t()) :: String.t() + def format_event_type(event_type) do + event_type + |> String.replace("_", " ") + |> String.capitalize() + end + + @doc """ + Returns capacity source label for display. + """ + @spec capacity_source_label(String.t()) :: String.t() + def capacity_source_label("manual"), do: "Manual" + def capacity_source_label("sensor"), do: "Sensor" + def capacity_source_label("if_speed"), do: "Link" + def capacity_source_label("peak"), do: "Peak" + def capacity_source_label(_), do: "" + + @doc """ + Returns Tailwind CSS classes for capacity source badges. + """ + @spec capacity_source_class(String.t()) :: String.t() + def capacity_source_class("manual"), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" + + def capacity_source_class("sensor"), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" + + def capacity_source_class("if_speed"), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" + + def capacity_source_class("peak"), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300" + + def capacity_source_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" + + @doc """ + Returns background color class for utilization percentage. + + - >= 90%: red + - >= 70%: yellow + - < 70%: green + """ + @spec utilization_color(number()) :: String.t() + def utilization_color(pct) when pct >= 90, do: "bg-red-500" + def utilization_color(pct) when pct >= 70, do: "bg-yellow-500" + def utilization_color(_pct), do: "bg-green-500" + + @doc """ + Returns text color class for utilization percentage. + + - >= 90%: red + - >= 70%: yellow + - < 70%: green + """ + @spec utilization_text_color(number()) :: String.t() + def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" + def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" + def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" +end diff --git a/lib/towerops_web/live/device_live/helpers/sensor_classifiers.ex b/lib/towerops_web/live/device_live/helpers/sensor_classifiers.ex new file mode 100644 index 00000000..cd6e94d4 --- /dev/null +++ b/lib/towerops_web/live/device_live/helpers/sensor_classifiers.ex @@ -0,0 +1,236 @@ +defmodule ToweropsWeb.DeviceLive.Helpers.SensorClassifiers do + @moduledoc """ + Sensor classification helper functions for DeviceLive.Show. + + Pure predicate functions that classify sensors by type based on + sensor_type, sensor_unit, and sensor_descr fields. + + Classification priority: + 1. sensor_type (set during SNMP discovery based on OID) + 2. sensor_unit (fallback for devices that don't set sensor_type) + 3. sensor_descr (last resort, only for unambiguous keywords) + """ + + @doc """ + Returns true if sensor is a temperature sensor. + + Checks sensor_type, unit, and description for temperature indicators. + """ + @spec temperature_sensor?(map()) :: boolean() + def temperature_sensor?(sensor) do + # Primary: Check sensor_type (set during discovery based on OID) + type_match = sensor.sensor_type in ["temperature", "cpu_temperature", "celsius"] + + # Fallback: Check unit for devices that don't set sensor_type correctly + unit_match = sensor.sensor_unit in ["°C", "C", "celsius"] + + # Only use description as last resort if type and unit don't match + # and only for unambiguous temperature keywords + descr_match = + !type_match && !unit_match && + sensor.sensor_descr && + String.contains?(String.downcase(sensor.sensor_descr), "temperature") && + !String.contains?(String.downcase(sensor.sensor_descr), "voltage") + + (type_match || unit_match || descr_match) && !voltage_by_description?(sensor) + end + + @doc """ + Returns true if sensor is a voltage sensor. + + Checks sensor_type, unit, and description for voltage indicators. + """ + @spec voltage_sensor?(map()) :: boolean() + def voltage_sensor?(sensor) do + type_match = sensor.sensor_type in ["voltage", "volts"] + unit_match = sensor.sensor_unit in ["V", "mV", "volts"] + descr_match = voltage_by_description?(sensor) + + type_match || unit_match || descr_match + end + + defp voltage_by_description?(sensor) do + sensor.sensor_descr && + String.contains?(String.downcase(sensor.sensor_descr), "voltage") + end + + @doc """ + Returns true if sensor is a wireless-related sensor. + + Includes frequency, power, RSSI, CCQ, clients, distance, noise floor, + quality, rate, and utilization sensors. + """ + @spec wireless_sensor?(map()) :: boolean() + def wireless_sensor?(sensor) do + sensor.sensor_type in [ + "frequency", + "power", + "rssi", + "ccq", + "clients", + "distance", + "noise-floor", + "quality", + "rate", + "utilization" + ] + end + + @doc """ + Returns true if sensor is a counter/gauge sensor. + + Includes connection counters, session counters, client counts, + DHCP lease counts, etc. + """ + @spec counter_sensor?(map()) :: boolean() + def counter_sensor?(sensor) do + sensor.sensor_type in [ + "count", + "pppoe_sessions", + "connections", + "clients", + "leases", + "sessions" + ] + end + + @doc """ + Returns true if sensor is a firewall counter. + + Firewall counters are connection-related metrics. + """ + @spec firewall_counter?(map()) :: boolean() + def firewall_counter?(sensor) do + descr = String.downcase(sensor.sensor_descr || "") + String.contains?(descr, "connection") + end + + @doc """ + Returns true if sensor is a current (amperes) sensor. + + Checks for ampere-related sensor types and units. + """ + @spec current_sensor?(map()) :: boolean() + def current_sensor?(sensor) do + type_match = sensor.sensor_type in ["current", "amperes"] + unit_match = sensor.sensor_unit in ["A", "mA", "amperes"] + + type_match || unit_match + end + + @doc """ + Returns true if sensor is a power (watts) sensor. + + Excludes wireless power sensors (those are classified as wireless). + """ + @spec power_sensor?(map()) :: boolean() + def power_sensor?(sensor) do + type_match = sensor.sensor_type in ["power", "watts"] + unit_match = sensor.sensor_unit in ["W", "mW", "watts"] + + # Exclude wireless power sensors (those are in wireless category) + (type_match || unit_match) && !wireless_sensor?(sensor) + end + + @doc """ + Returns true if sensor is a fan speed (RPM) sensor. + + Checks for fan-related sensor types, units, and descriptions. + """ + @spec fan_sensor?(map()) :: boolean() + def fan_sensor?(sensor) do + type_match = sensor.sensor_type in ["fanspeed", "rpm"] + unit_match = sensor.sensor_unit in ["RPM", "rpm"] + + descr_match = + sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "fan") + + type_match || unit_match || descr_match + end + + @doc """ + Returns true if sensor is a system load sensor. + + Includes CPU load, system load, and other load-related metrics. + """ + @spec load_sensor?(map()) :: boolean() + def load_sensor?(sensor) do + type_match = sensor.sensor_type in ["load", "cpu_load", "system_load"] + + descr_match = + sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "load") + + type_match || descr_match + end + + @doc """ + Returns true if sensor is a signal quality sensor. + + Includes SNR, RSSI, RSRP, RSRQ, SINR, noise floor, and other + signal quality metrics. + """ + @spec signal_sensor?(map()) :: boolean() + def signal_sensor?(sensor) do + sensor.sensor_type in [ + "snr", + "rssi", + "rsrp", + "rsrq", + "sinr", + "ssr", + "mse", + "noise", + "noise-floor", + "dbm" + ] + end + + @doc """ + Groups transceiver sensors by transceiver name. + + Returns list of {name, sensors} tuples sorted by name. + + Examples: + - "sfp-sfpplus1 Rx Power" -> grouped under "sfp-sfpplus1" + - "SFP2 Temperature" -> grouped under "sfp2" + """ + @spec group_transceiver_sensors(list()) :: list({String.t(), list()}) + def group_transceiver_sensors(transceiver_sensors) do + transceiver_sensors + |> Enum.group_by(&extract_transceiver_name/1) + |> Enum.sort_by(fn {name, _sensors} -> name end) + end + + # Extract transceiver name from sensor description + # Examples: + # "sfp-sfpplus1 Rx Power" -> "sfp-sfpplus1" + # "sfp-sfpplus2 Temperature" -> "sfp-sfpplus2" + # "SFP1 Voltage" -> "sfp1" + defp extract_transceiver_name(sensor) do + case sensor.sensor_descr do + nil -> + "unknown" + + descr -> + # Extract the first word (transceiver identifier) + descr + |> String.split(" ") + |> List.first() + |> String.downcase() + end + end + + @doc """ + Splits sensors into transceiver and non-transceiver groups. + + Transceiver sensors contain "sfp" in their description (case-insensitive). + + Returns {transceiver_sensors, non_transceiver_sensors}. + """ + @spec split_transceiver_sensors(list()) :: {list(), list()} + def split_transceiver_sensors(sensors) do + Enum.split_with(sensors, fn sensor -> + sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp") + end) + end +end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 21211137..f0898bcc 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -4,13 +4,15 @@ defmodule ToweropsWeb.DeviceLive.Show do alias Towerops.Agents alias Towerops.Devices - alias Towerops.Devices.Firmware alias Towerops.Devices.MikrotikBackups - alias Towerops.Gaiia alias Towerops.Monitoring alias Towerops.Snmp alias Towerops.Workers.DiscoveryWorker alias ToweropsWeb.CheckLive.FormComponent + alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + alias ToweropsWeb.DeviceLive.Helpers.Formatters + alias ToweropsWeb.DeviceLive.Helpers.SensorClassifiers alias ToweropsWeb.Live.Helpers.AccessControl require Logger @@ -41,25 +43,13 @@ defmodule ToweropsWeb.DeviceLive.Show do "Other" => 99 } - # Check type to group key mapping for organizing checks by type - @check_type_groups %{ - "snmp_sensor" => :snmp_sensors, - "snmp_interface" => :snmp_interfaces, - "snmp_processor" => :snmp_processors, - "snmp_storage" => :snmp_storage, - "http" => :http_checks, - "tcp" => :tcp_checks, - "dns" => :dns_checks, - "ssl" => :ssl_checks, - "ping" => :ping_checks - } - @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:show_check_form, false) - |> assign(:edit_check, nil)} + |> assign(:edit_check, nil) + |> assign(:subscribed, false)} end @impl true @@ -69,7 +59,7 @@ defmodule ToweropsWeb.DeviceLive.Show do # Check if device exists and verify organization access case AccessControl.verify_device_access(id, organization.id) do {:ok, _device} -> - maybe_subscribe_and_schedule_refresh(socket, id) + socket = maybe_subscribe_and_schedule_refresh(socket, id) # If no tab parameter, redirect to add it to the URL if Map.has_key?(params, "tab") do @@ -78,8 +68,10 @@ defmodule ToweropsWeb.DeviceLive.Show do socket = socket - |> load_equipment_data(id) + |> assign_base_data(id) + |> assign_tab_nav_data(id) |> assign(:active_tab, tab) + |> reload_current_tab_data(id) |> apply_backups_pagination(tab, page) {:noreply, socket} @@ -104,18 +96,27 @@ defmodule ToweropsWeb.DeviceLive.Show do @impl true def handle_info(:refresh_data, socket) do - # Check if device still exists before scheduling next refresh - case Devices.get_device(socket.assigns.device.id) do + device_id = socket.assigns.device.id + + # Schedule next refresh + Process.send_after(self(), :refresh_data, 10_000) + + # Check if device still exists and reload + case Devices.get_device(device_id) do nil -> - # Device was deleted, don't schedule another refresh + # Device was deleted between refreshes {:noreply, socket |> put_flash(:error, t_equipment("Device no longer exists")) |> push_navigate(to: ~p"/devices")} _device -> - Process.send_after(self(), :refresh_data, 10_000) - {:noreply, reload_active_tab_data(socket)} + # Reload data (assign_base_data will fetch the device with full associations) + {:noreply, + socket + |> assign_base_data(device_id) + |> assign_tab_nav_data(device_id) + |> reload_current_tab_data(device_id)} end end @@ -127,10 +128,14 @@ defmodule ToweropsWeb.DeviceLive.Show do @impl true def handle_info({:discovery_completed, _device_id}, socket) do - # Structural change - full reload needed + # Structural change - full reload needed (new interfaces, sensors, etc.) + device_id = socket.assigns.device.id + {:noreply, socket - |> load_equipment_data(socket.assigns.device.id) + |> assign_base_data(device_id) + |> assign_tab_nav_data(device_id) + |> reload_current_tab_data(device_id) |> put_flash(:info, t_equipment("Discovery completed"))} end @@ -261,7 +266,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:show_check_form, false) |> assign(:edit_check, nil) |> put_flash(:info, t("Check created successfully")) - |> load_equipment_data(socket.assigns.device.id)} + |> assign_checks_data(socket.assigns.device.id)} end @impl true @@ -271,7 +276,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:show_check_form, false) |> assign(:edit_check, nil) |> put_flash(:info, t("Check updated successfully")) - |> load_equipment_data(socket.assigns.device.id)} + |> assign_checks_data(socket.assigns.device.id)} end @impl true @@ -287,37 +292,15 @@ defmodule ToweropsWeb.DeviceLive.Show do # Private functions defp maybe_subscribe_and_schedule_refresh(socket, device_id) do - _ = - if connected?(socket) do - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}") - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health") - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "wireless_clients:device:#{device_id}") - Process.send_after(self(), :refresh_data, 10_000) - end - - :ok - end - - # -- Full data load (initial page load and discovery_completed) -- - - # Loads all data for every tab. Called from handle_params (initial load) - # and discovery_completed (structural change requiring full refresh). - defp load_equipment_data(socket, device_id) do - socket - |> assign_base_data(device_id) - |> assign_tab_nav_data(device_id) - |> assign_overview_data(device_id) - |> assign_ports_data() - |> assign_wireless_data(device_id) - |> assign_transceivers_data(device_id) - |> assign_printer_supplies_data(device_id) - |> assign_hardware_inventory_data(device_id) - |> assign_logs_data(device_id) - |> assign_backups_data(device_id) - |> assign_checks_data(device_id) - |> assign_preseem_data() - |> assign_gaiia_data() - |> assign_subscriber_impact() + if connected?(socket) and not socket.assigns.subscribed do + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "wireless_clients:device:#{device_id}") + Process.send_after(self(), :refresh_data, 10_000) + assign(socket, :subscribed, true) + else + socket + end end # -- Selective reload helpers (used by handle_info handlers) -- @@ -328,17 +311,6 @@ defmodule ToweropsWeb.DeviceLive.Show do assign_base_data(socket, socket.assigns.device.id) end - # Reload base data + data for the currently active tab only. - # Used by the periodic refresh timer. - defp reload_active_tab_data(socket) do - device_id = socket.assigns.device.id - - socket - |> assign_base_data(device_id) - |> assign_tab_nav_data(device_id) - |> reload_current_tab_data(device_id) - end - # Reload data only if the active tab is one of the relevant tabs. # Otherwise does nothing - the event is irrelevant to what the user sees. defp reload_if_tab(socket, relevant_tabs) do @@ -408,9 +380,12 @@ defmodule ToweropsWeb.DeviceLive.Show do device -> device_with_associations = Devices.get_device_with_agent_info(device) - {agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations) - agent_info = load_agent_info(agent_token_id, agent_source) - snmp_data = load_snmp_data(device_id) + + {agent_token_id, agent_source} = + Agents.get_effective_agent_token_with_source(device_with_associations) + + agent_info = DataLoaders.load_agent_info(agent_token_id, agent_source) + snmp_data = DataLoaders.load_snmp_data(device_id) socket |> assign(:page_title, device.name) @@ -420,18 +395,47 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:snmp_device, snmp_data.device) |> assign(:snmp_interfaces, snmp_data.interfaces) |> assign(:snmp_sensors, snmp_data.sensors) + |> assign_subscriber_impact() + |> assign(:can_view_financials, can_view_financials?(socket)) end end + defp assign_subscriber_impact(socket) do + device = socket.assigns.device + + impact = + try do + Towerops.Gaiia.get_device_impact(device.id) + rescue + _ -> %{subscriber_count: 0, mrr: nil, accounts: []} + end + + assign(socket, :subscriber_impact, impact) + end + + defp can_view_financials?(socket) do + import ToweropsWeb.Permissions + + owner?(socket) || admin?(socket) + end + # Data needed for the tab navigation bar to decide which tabs to show. # Neighbors, ARP, MAC, VLANs, and IP addresses determine tab visibility. defp assign_tab_nav_data(socket, device_id) do snmp_device = socket.assigns.snmp_device - neighbors = Snmp.list_neighbors_with_equipment(device_id) - arp_entries = Snmp.list_arp_entries(device_id) - mac_addresses = Snmp.list_mac_addresses(device_id) - vlans = load_vlans(snmp_device) - ip_addresses = load_ip_addresses(snmp_device) + neighbors = DataLoaders.load_neighbors(device_id) + arp_entries = DataLoaders.load_arp_entries(device_id) + mac_addresses = DataLoaders.load_mac_addresses(device_id) + vlans = DataLoaders.load_vlans(snmp_device) + ip_addresses = DataLoaders.load_ip_addresses(snmp_device) + + # Load data needed for tab visibility decisions + gaiia_item = Towerops.Gaiia.get_inventory_item_for_device(device_id) + preseem_access_point = Towerops.Preseem.get_access_point_for_device(device_id) + wireless_data = DataLoaders.load_wireless_data(device_id) + transceiver_data = DataLoaders.load_transceivers(snmp_device) + printer_data = DataLoaders.load_printer_supplies(snmp_device) + hardware_data = DataLoaders.load_hardware_inventory(snmp_device) socket |> assign(:neighbors, neighbors) @@ -440,7 +444,15 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:vlans, vlans) |> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4"))) |> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6"))) - |> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses)) + |> assign(:ip_addresses_by_interface, DataLoaders.group_ip_addresses_by_interface(ip_addresses)) + |> assign(:gaiia_item, gaiia_item) + |> assign(:preseem_access_point, preseem_access_point) + |> assign(:wireless_clients_available, wireless_data.wireless_clients_available) + |> assign(:wireless_client_count, wireless_data.wireless_client_count) + |> assign(:wireless_last_updated, wireless_data.wireless_last_updated) + |> assign(:transceivers_available, transceiver_data.transceivers_available) + |> assign(:printer_supplies_available, printer_data.printer_supplies_available) + |> assign(:hardware_inventory_available, hardware_data.hardware_inventory_available) end # Overview tab: charts, sensor classifications, metrics, processors, storage, firmware. @@ -450,17 +462,17 @@ defmodule ToweropsWeb.DeviceLive.Show do snmp_device = socket.assigns.snmp_device sensors = socket.assigns.snmp_sensors - processors = load_processors(snmp_device) - storage = load_storage(snmp_device) - latency_chart_data = load_latency_chart_data(device_id) - processor_chart_data = load_processor_chart_data(processors) - memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"]) - storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"]) - storage_volume_chart_data = load_storage_volume_chart_data(storage) - traffic_chart_data = load_overall_traffic_chart_data(device_id) + processors = DataLoaders.load_processors(snmp_device) + storage = DataLoaders.load_storage(snmp_device) + latency_chart_data = ChartBuilders.load_latency_chart_data(device_id) + processor_chart_data = ChartBuilders.load_processor_chart_data(processors) + memory_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["memory_usage"]) + storage_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["disk_usage"]) + storage_volume_chart_data = ChartBuilders.load_storage_volume_chart_data(storage) + traffic_chart_data = ChartBuilders.load_overall_traffic_chart_data(snmp_device) wireless_chart_data = - load_sensor_chart_data(device_id, [ + ChartBuilders.load_sensor_chart_data(snmp_device, [ "frequency", "power", "rssi", @@ -475,27 +487,25 @@ defmodule ToweropsWeb.DeviceLive.Show do # Classify sensors by type {transceiver_sensors, non_transceiver_sensors} = - Enum.split_with(sensors, fn sensor -> - sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp") - end) + SensorClassifiers.split_transceiver_sensors(sensors) - temperature_sensors = Enum.filter(non_transceiver_sensors, &temperature_sensor?/1) - voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1) - current_sensors = Enum.filter(non_transceiver_sensors, ¤t_sensor?/1) - power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1) - fan_sensors = Enum.filter(non_transceiver_sensors, &fan_sensor?/1) - load_sensors = Enum.filter(non_transceiver_sensors, &load_sensor?/1) + temperature_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.temperature_sensor?/1) + voltage_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.voltage_sensor?/1) + current_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.current_sensor?/1) + power_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.power_sensor?/1) + fan_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.fan_sensor?/1) + load_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.load_sensor?/1) storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"])) - count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1) + count_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.counter_sensor?/1) {firewall_counters, general_counters} = - Enum.split_with(count_sensors, &firewall_counter?/1) + Enum.split_with(count_sensors, &SensorClassifiers.firewall_counter?/1) - wireless_sensors = Enum.filter(non_transceiver_sensors, &wireless_sensor?/1) - signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1) - grouped_transceivers = group_transceiver_sensors(transceiver_sensors) - metrics = calculate_metrics([], device) - available_firmware = get_available_firmware(snmp_device) + wireless_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.wireless_sensor?/1) + signal_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.signal_sensor?/1) + grouped_transceivers = SensorClassifiers.group_transceiver_sensors(transceiver_sensors) + metrics = DataLoaders.calculate_metrics([], device) + available_firmware = DataLoaders.get_available_firmware(snmp_device) socket |> assign(:metrics, metrics) @@ -522,17 +532,8 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:signal_sensors, signal_sensors) |> assign(:grouped_transceivers, grouped_transceivers) |> assign(:available_firmware, available_firmware) - |> assign(:connected_devices, Towerops.Topology.list_connected_devices(device_id)) - |> assign(:recent_config_changes, load_recent_config_changes(device)) - end - - defp load_recent_config_changes(device) do - if device.mikrotik_enabled do - since = DateTime.add(DateTime.utc_now(), -30 * 24 * 3600, :second) - Towerops.ConfigChanges.list_device_changes(device.id, after: since, limit: 5) - else - [] - end + |> assign(:connected_devices, DataLoaders.load_connected_devices(device_id)) + |> assign(:recent_config_changes, DataLoaders.load_recent_config_changes(device)) end # Ports tab: interfaces grouped by type, enriched with utilization data. @@ -556,20 +557,14 @@ defmodule ToweropsWeb.DeviceLive.Show do # Logs/events tab data. defp assign_logs_data(socket, device_id) do - events = Devices.list_devices_events(device_id, 100) + events = DataLoaders.load_device_events(device_id) assign(socket, :events, events) end # Backups tab data. - defp assign_backups_data(socket, device_id) do + defp assign_backups_data(socket, _device_id) do device = socket.assigns.device - - mikrotik_backups = - if device.mikrotik_enabled do - MikrotikBackups.list_device_backups(device_id, limit: 50) - else - [] - end + mikrotik_backups = DataLoaders.load_mikrotik_backups(device) socket |> assign(:mikrotik_backups, mikrotik_backups) @@ -578,218 +573,104 @@ defmodule ToweropsWeb.DeviceLive.Show do # Checks tab data. defp assign_checks_data(socket, device_id) do - # Load all checks for this device - checks = Monitoring.list_checks(socket.assigns.device.organization_id, device_id: device_id) - - # Group checks by type for organized display - grouped_checks = - Enum.group_by(checks, fn check -> - Map.get(@check_type_groups, check.check_type, :other_checks) - end) + checks_data = DataLoaders.load_checks_data(device_id, socket.assigns.device.organization_id) socket - |> assign(:checks, checks) - |> assign(:grouped_checks, grouped_checks) + |> assign(:checks, checks_data.checks) + |> assign(:grouped_checks, checks_data.grouped_checks) end # Gaiia tab data. defp assign_gaiia_data(socket) do device = socket.assigns.device - gaiia_item = Gaiia.get_inventory_item_for_device(device.id) - - {gaiia_account, gaiia_subscriptions, gaiia_network_site} = - if gaiia_item do - account = - if gaiia_item.assigned_account_gaiia_id do - Gaiia.get_account(device.organization_id, gaiia_item.assigned_account_gaiia_id) - end - - subscriptions = - if gaiia_item.assigned_account_gaiia_id do - Gaiia.list_billing_subscriptions_for_account( - device.organization_id, - gaiia_item.assigned_account_gaiia_id - ) - else - [] - end - - network_site = - if gaiia_item.assigned_network_site_gaiia_id do - Gaiia.get_network_site( - device.organization_id, - gaiia_item.assigned_network_site_gaiia_id - ) - end - - {account, subscriptions, network_site} - else - {nil, [], nil} - end + gaiia_data = DataLoaders.load_gaiia_data(device) socket - |> assign(:gaiia_item, gaiia_item) - |> assign(:gaiia_account, gaiia_account) - |> assign(:gaiia_subscriptions, gaiia_subscriptions) - |> assign(:gaiia_network_site, gaiia_network_site) - end - - # Subscriber impact data (from Gaiia device-to-subscriber matching). - defp assign_subscriber_impact(socket) do - device = socket.assigns.device - - impact = - try do - Gaiia.get_device_impact(device.id) - rescue - _ -> %{subscriber_count: 0, mrr: nil, accounts: []} - end - - assign(socket, :subscriber_impact, impact) + |> assign(:gaiia_item, gaiia_data.gaiia_item) + |> assign(:gaiia_account, gaiia_data.gaiia_account) + |> assign(:gaiia_subscriptions, gaiia_data.gaiia_subscriptions) + |> assign(:gaiia_network_site, gaiia_data.gaiia_network_site) end # Preseem tab data. defp assign_preseem_data(socket) do device = socket.assigns.device - preseem_ap = Towerops.Preseem.get_access_point_for_device(device.id) - - {metrics, insights} = - if preseem_ap do - { - Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50), - Towerops.Preseem.list_device_insights(device.id) - } - else - {[], []} - end + preseem_data = DataLoaders.load_preseem_data(device) socket - |> assign(:preseem_access_point, preseem_ap) - |> assign(:preseem_metrics, metrics) - |> assign(:preseem_insights, insights) + |> assign(:preseem_access_point, preseem_data.preseem_access_point) + |> assign(:preseem_metrics, preseem_data.preseem_metrics) + |> assign(:preseem_insights, preseem_data.preseem_insights) end # Wireless clients tab data. defp assign_wireless_data(socket, device_id) do - wireless_clients = Snmp.list_wireless_clients(device_id) - - # Build MAC → subscriber link mapping - wireless_client_subscribers = - if Enum.any?(wireless_clients) do - # Get all device-subscriber links for this device - links = Gaiia.list_device_subscriber_links_with_macs(device_id) - - # Build a map of MAC address → link - Map.new(links, &{String.downcase(&1.subscriber_mac), &1}) - else - %{} - end - - # Count matched subscribers - matched_count = - Enum.count(wireless_clients, fn client -> - Map.has_key?(wireless_client_subscribers, String.downcase(client.mac_address)) - end) - - # Get last update timestamp - last_updated = - wireless_clients - |> Enum.map(& &1.last_seen_at) - |> Enum.max(DateTime, fn -> nil end) + wireless_data = DataLoaders.load_wireless_data(device_id) socket - |> assign(:wireless_clients, wireless_clients) - |> assign(:wireless_client_subscribers, wireless_client_subscribers) - |> assign(:wireless_client_count, length(wireless_clients)) - |> assign(:wireless_matched_count, matched_count) - |> assign(:wireless_last_updated, last_updated) - |> assign(:wireless_clients_available, wireless_clients != []) + |> assign(:wireless_clients, wireless_data.wireless_clients) + |> assign(:wireless_client_subscribers, wireless_data.wireless_client_subscribers) + |> assign(:wireless_client_count, wireless_data.wireless_client_count) + |> assign(:wireless_matched_count, wireless_data.wireless_matched_count) + |> assign(:wireless_last_updated, wireless_data.wireless_last_updated) + |> assign(:wireless_clients_available, wireless_data.wireless_clients_available) end # Transceivers tab data. defp assign_transceivers_data(socket, _device_id) do snmp_device = socket.assigns.snmp_device - - transceivers = - if snmp_device do - Snmp.list_transceivers(snmp_device.id) - else - [] - end + transceiver_data = DataLoaders.load_transceivers(snmp_device) socket - |> assign(:transceivers, transceivers) - |> assign(:transceivers_available, transceivers != []) + |> assign(:transceivers, transceiver_data.transceivers) + |> assign(:transceivers_available, transceiver_data.transceivers_available) end # Printer supplies tab data. defp assign_printer_supplies_data(socket, _device_id) do snmp_device = socket.assigns.snmp_device - - printer_supplies = - if snmp_device do - Snmp.list_printer_supplies(snmp_device.id) - else - [] - end + printer_data = DataLoaders.load_printer_supplies(snmp_device) socket - |> assign(:printer_supplies, printer_supplies) - |> assign(:printer_supplies_available, printer_supplies != []) + |> assign(:printer_supplies, printer_data.printer_supplies) + |> assign(:printer_supplies_available, printer_data.printer_supplies_available) end # Hardware inventory tab data. defp assign_hardware_inventory_data(socket, _device_id) do snmp_device = socket.assigns.snmp_device - - hardware_inventory = - if snmp_device do - Snmp.list_entity_physical(snmp_device.id) - else - [] - end + hardware_data = DataLoaders.load_hardware_inventory(snmp_device) socket - |> assign(:hardware_inventory, hardware_inventory) - |> assign(:hardware_inventory_available, hardware_inventory != []) + |> assign(:hardware_inventory, hardware_data.hardware_inventory) + |> assign(:hardware_inventory_available, hardware_data.hardware_inventory_available) end - defp get_available_firmware(nil), do: nil + # Formatting delegation wrappers for template compatibility + # These delegate to Formatters module - templates will be updated in next task + defp format_date(date), do: Formatters.format_date(date) + defp format_speed(speed_bps), do: Formatters.format_speed(speed_bps) + defp format_bytes(bytes), do: Formatters.format_bytes(bytes) + defp format_sensor_value(value, divisor), do: Formatters.format_sensor_value(value, divisor) + defp format_location(location), do: Formatters.format_location(location) + defp format_uptime(timeticks), do: Formatters.format_uptime(timeticks) + defp format_device_age(datetime), do: Formatters.format_device_age(datetime) + defp time_ago(datetime), do: Formatters.time_ago(datetime) + defp capacity_source_label(source), do: Formatters.capacity_source_label(source) + defp capacity_source_class(source), do: Formatters.capacity_source_class(source) + defp utilization_color(pct), do: Formatters.utilization_color(pct) - defp get_available_firmware(snmp_device) do - vendor = determine_vendor(snmp_device.manufacturer) - product_line = determine_product_line(snmp_device.manufacturer) + # Additional template-used formatting functions (not yet in Formatters module) + defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" + defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" + defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" - # Only check firmware if both vendor and product_line are known - # Currently only MikroTik/RouterOS is supported - if vendor && product_line do - Firmware.get_latest_firmware_release(vendor, product_line) - end + defp format_event_type(event_type) do + event_type + |> String.replace("_", " ") + |> String.capitalize() end - defp determine_vendor(manufacturer) when is_binary(manufacturer) do - manufacturer_lower = String.downcase(manufacturer) - - cond do - String.contains?(manufacturer_lower, "mikrotik") -> "mikrotik" - String.contains?(manufacturer_lower, "cisco") -> "cisco" - String.contains?(manufacturer_lower, "ubiquiti") -> "ubiquiti" - true -> nil - end - end - - defp determine_vendor(_), do: nil - - defp determine_product_line(manufacturer) when is_binary(manufacturer) do - manufacturer_lower = String.downcase(manufacturer) - - if String.contains?(manufacturer_lower, "mikrotik") do - "routeros" - end - end - - defp determine_product_line(_), do: nil - defp firmware_update_available?(nil, _), do: false defp firmware_update_available?(_, nil), do: false @@ -798,8 +679,6 @@ defmodule ToweropsWeb.DeviceLive.Show do available = available_firmware.version # Extract clean version numbers for comparison - # "RouterOS 7.20.6 (Level 6)" -> "7.20.6" - # "7.21.2" -> "7.21.2" current_clean = extract_version_number(current) available_clean = extract_version_number(available) @@ -807,11 +686,6 @@ defmodule ToweropsWeb.DeviceLive.Show do :towerops@devices@version_comparator.newer(current_clean, available_clean) end - # Extract semantic version number from version string - # Examples: - # "RouterOS 7.20.6 (Level 6)" -> "7.20.6" - # "7.21.2" -> "7.21.2" - # "v7.14.1" -> "7.14.1" defp extract_version_number(nil), do: nil defp extract_version_number(""), do: nil @@ -823,719 +697,6 @@ defmodule ToweropsWeb.DeviceLive.Show do end end - defp format_date(nil), do: "" - - defp format_date(date) do - Calendar.strftime(date, "%B %-d, %Y") - end - - defp load_agent_info(nil, :none) do - %{ - agent_token_id: nil, - type: :cloud, - name: "Cloud Polling", - source: nil, - last_seen_at: nil, - is_offline: false - } - end - - defp load_agent_info(agent_token_id, source) do - agent_token = Agents.get_agent_token!(agent_token_id) - is_offline = agent_offline?(agent_token) - - %{ - agent_token_id: agent_token_id, - type: if(agent_token.is_cloud_poller, do: :cloud, else: :agent), - name: agent_token.name, - source: source, - last_seen_at: agent_token.last_seen_at, - is_offline: is_offline - } - rescue - Ecto.NoResultsError -> - # Agent token no longer exists, fall back to cloud polling display - %{ - agent_token_id: nil, - type: :cloud, - name: "Cloud Polling (agent not found)", - source: nil, - last_seen_at: nil, - is_offline: false - } - end - - # Agent is considered offline if it hasn't been seen in the last 5 minutes - defp agent_offline?(%{is_cloud_poller: true}), do: false - defp agent_offline?(%{last_seen_at: nil}), do: true - - defp agent_offline?(%{last_seen_at: last_seen_at}) do - five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute) - DateTime.before?(last_seen_at, five_minutes_ago) - end - - defp calculate_metrics(checks, _equipment) do - total_checks = length(checks) - - if total_checks > 0 do - successful_checks = Enum.count(checks, &(&1.status == :success)) - uptime_percentage = Float.round(successful_checks / total_checks * 100, 1) - - %{ - uptime_percentage: uptime_percentage, - total_checks: total_checks, - successful_checks: successful_checks - } - else - %{ - uptime_percentage: 0, - total_checks: 0, - successful_checks: 0 - } - end - end - - defp format_speed(speed_bps) when is_integer(speed_bps) do - cond do - speed_bps >= 1_000_000_000 -> - "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" - - speed_bps >= 1_000_000 -> - "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" - - speed_bps >= 1_000 -> - "#{Float.round(speed_bps / 1_000, 1)} Kbps" - - true -> - "#{speed_bps} bps" - end - end - - defp format_speed(_), do: "-" - - defp capacity_source_label("manual"), do: "Manual" - defp capacity_source_label("sensor"), do: "Sensor" - defp capacity_source_label("if_speed"), do: "Link" - defp capacity_source_label("peak"), do: "Peak" - defp capacity_source_label(_), do: "" - - defp capacity_source_class("manual"), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" - defp capacity_source_class("sensor"), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" - defp capacity_source_class("if_speed"), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" - defp capacity_source_class("peak"), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300" - defp capacity_source_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" - - defp utilization_color(pct) when pct >= 90, do: "bg-red-500" - defp utilization_color(pct) when pct >= 70, do: "bg-yellow-500" - defp utilization_color(_pct), do: "bg-green-500" - - defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" - defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" - defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" - - defp format_bytes(nil), do: "-" - defp format_bytes(0), do: "0 B" - - defp format_bytes(bytes) when is_integer(bytes) do - cond do - bytes >= 1_099_511_627_776 -> - "#{Float.round(bytes / 1_099_511_627_776, 1)} TB" - - bytes >= 1_073_741_824 -> - "#{Float.round(bytes / 1_073_741_824, 1)} GB" - - bytes >= 1_048_576 -> - "#{Float.round(bytes / 1_048_576, 1)} MB" - - bytes >= 1024 -> - "#{Float.round(bytes / 1024, 1)} KB" - - true -> - "#{bytes} B" - end - end - - defp format_bytes(_), do: "-" - - defp format_sensor_value(value, _divisor) when is_number(value) do - # Value is already divided by divisor when stored in sensor_reading - # Format as string to avoid scientific notation for large numbers - :erlang.float_to_binary(value * 1.0, decimals: 1) - end - - defp format_sensor_value(_, _), do: "-" - - # Format GPS coordinates to 6 decimal places (standard precision, ~0.1m accuracy) - defp format_location(location) when is_binary(location) do - if gps_coordinates?(location) do - format_gps_coordinates(location) - else - location - end - end - - defp format_location(nil), do: "N/A" - defp format_location(_), do: "N/A" - - defp gps_coordinates?(location) do - String.contains?(location, ",") && String.match?(location, ~r/[-\d.]+,\s*[-\d.]+/) - end - - defp format_gps_coordinates(location) do - location - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.map_join(", ", &format_coordinate/1) - end - - defp format_coordinate(coord) do - case Float.parse(coord) do - {num, _} -> :erlang.float_to_binary(num, decimals: 6) - :error -> coord - end - end - - defp time_ago(nil), do: "Never" - - defp time_ago(datetime) do - diff = DateTime.diff(DateTime.utc_now(), datetime, :second) - - cond do - diff < 60 -> "#{diff}s ago" - diff < 3600 -> "#{div(diff, 60)}m ago" - diff < 86_400 -> "#{div(diff, 3600)}h ago" - true -> "#{div(diff, 86_400)}d ago" - end - end - - defp format_uptime(nil), do: "N/A" - - defp format_uptime(timeticks) when is_integer(timeticks) do - # Timeticks are in hundredths of a second - seconds = div(timeticks, 100) - format_duration(seconds) - end - - defp format_uptime(_), do: "N/A" - - defp format_duration(total_seconds) do - {weeks, remainder} = seconds_to_weeks(total_seconds) - {days, remainder} = seconds_to_days(remainder) - {hours, remainder} = seconds_to_hours(remainder) - {minutes, _seconds} = seconds_to_minutes(remainder) - - parts = - Enum.reject( - [ - format_time_part(weeks, "week"), - format_time_part(days, "day"), - format_time_part(hours, "hour"), - format_time_part(minutes, "minute") - ], - &is_nil/1 - ) - - case parts do - [] -> "less than a minute" - parts -> Enum.join(parts, " ") - end - end - - @spec seconds_to_weeks(integer()) :: {integer(), integer()} - defp seconds_to_weeks(seconds), do: {div(seconds, 604_800), rem(seconds, 604_800)} - - @spec seconds_to_days(integer()) :: {integer(), integer()} - defp seconds_to_days(seconds), do: {div(seconds, 86_400), rem(seconds, 86_400)} - - @spec seconds_to_hours(integer()) :: {integer(), integer()} - defp seconds_to_hours(seconds), do: {div(seconds, 3600), rem(seconds, 3600)} - - @spec seconds_to_minutes(integer()) :: {integer(), integer()} - defp seconds_to_minutes(seconds), do: {div(seconds, 60), rem(seconds, 60)} - - @spec format_time_part(integer(), String.t()) :: String.t() | nil - defp format_time_part(0, _unit), do: nil - defp format_time_part(1, unit), do: "1 #{unit}" - defp format_time_part(count, unit), do: "#{count} #{unit}s" - - defp format_device_age(nil), do: "N/A" - - defp format_device_age(datetime) do - diff = DateTime.diff(DateTime.utc_now(), datetime, :second) - format_duration(diff) <> " ago" - end - - defp format_event_type(event_type) do - event_type - |> String.replace("_", " ") - |> String.capitalize() - end - - defp load_snmp_data(device_id) do - case Snmp.get_device_with_associations(device_id) do - nil -> - %{device: nil, interfaces: [], sensors: []} - - device -> - # Batch load latest readings for all sensors in a single query - sensor_ids = Enum.map(device.sensors, & &1.id) - latest_readings_map = Snmp.get_latest_sensor_readings_batch(sensor_ids) - - sensors_with_readings = - Enum.map(device.sensors, fn sensor -> - latest_reading = Map.get(latest_readings_map, sensor.id) - Map.put(sensor, :latest_reading, latest_reading) - end) - - # Batch load latest stats for all interfaces in a single query - interface_ids = Enum.map(device.interfaces, & &1.id) - latest_stats_map = Snmp.get_latest_interface_stats_batch(interface_ids) - - interfaces_with_stats = - Enum.map(device.interfaces, fn interface -> - latest_stat = Map.get(latest_stats_map, interface.id) - Map.put(interface, :latest_stat, latest_stat) - end) - - %{ - device: device, - interfaces: interfaces_with_stats, - sensors: sensors_with_readings - } - end - end - - defp load_vlans(nil), do: [] - defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id) - - defp load_ip_addresses(nil), do: [] - defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id) - - defp load_processors(nil), do: [] - defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id) - - defp load_storage(nil), do: [] - defp load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id) - - defp load_processor_chart_data([]), do: nil - - defp load_processor_chart_data(processors) do - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - datasets = - processors - |> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago)) - |> Enum.reject(&Enum.empty?(&1.data)) - - if Enum.empty?(datasets) do - nil - else - safe_encode_chart_data(%{datasets: datasets}) - end - end - - defp processor_to_chart_dataset(processor, since) do - readings = - processor.id - |> Snmp.get_processor_readings(since: since, limit: 1000) - |> Enum.reverse() - - label = - if processor.description do - processor.description - else - "CPU #{processor.processor_index}" - end - - %{ - label: label, - data: Enum.map(readings, &processor_reading_to_data_point/1) - } - end - - defp processor_reading_to_data_point(reading) do - %{ - x: DateTime.to_unix(reading.checked_at, :millisecond), - y: if(reading.load_percent, do: Float.round(reading.load_percent, 1)) - } - end - - defp load_storage_volume_chart_data([]), do: nil - - defp load_storage_volume_chart_data(storage_volumes) do - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - datasets = - storage_volumes - |> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago)) - |> Enum.reject(&Enum.empty?(&1.data)) - - if Enum.empty?(datasets) do - nil - else - safe_encode_chart_data(%{datasets: datasets}) - end - end - - defp storage_volume_to_chart_dataset(storage, since) do - readings = - storage.id - |> Snmp.get_storage_readings(since: since, limit: 1000) - |> Enum.reverse() - - label = storage.description || "Storage #{storage.storage_index}" - - %{ - label: label, - data: Enum.map(readings, &storage_reading_to_data_point/1) - } - end - - defp storage_reading_to_data_point(reading) do - %{ - x: DateTime.to_unix(reading.checked_at, :millisecond), - y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1)) - } - end - - defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do - case Snmp.get_device_with_associations(device_id) do - nil -> nil - device -> build_sensor_datasets_json(device, sensor_types) - end - end - - defp build_sensor_datasets_json(device, sensor_types) do - sensors = - device.sensors - |> Enum.filter(&(&1.sensor_type in sensor_types)) - |> Enum.sort_by(& &1.sensor_index) - - if Enum.empty?(sensors) do - nil - else - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - datasets = Enum.map(sensors, &sensor_to_chart_dataset(&1, twenty_four_hours_ago)) - safe_encode_chart_data(%{datasets: datasets}) - end - end - - defp sensor_to_chart_dataset(sensor, since) do - readings = - sensor.id - |> Snmp.get_sensor_readings(since: since, limit: 1000) - |> Enum.reverse() - - %{ - label: sensor.sensor_descr, - data: Enum.map(readings, &reading_to_data_point/1) - } - end - - defp reading_to_data_point(reading) do - %{ - x: DateTime.to_unix(reading.checked_at, :millisecond), - y: if(reading.value, do: Float.round(reading.value, 1)) - } - end - - defp load_latency_chart_data(device_id) do - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - checks = - device_id - |> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000) - |> Enum.reverse() - - if Enum.empty?(checks) do - nil - else - dataset = %{ - label: "ICMP Latency", - data: Enum.map(checks, &latency_check_to_data_point/1) - } - - safe_encode_chart_data(%{datasets: [dataset]}) - end - end - - defp latency_check_to_data_point(check) do - %{ - x: DateTime.to_unix(check.checked_at, :millisecond), - y: check.response_time_ms - } - end - - defp load_overall_traffic_chart_data(device_id) do - case Snmp.get_device_with_associations(device_id) do - nil -> nil - device -> build_overall_traffic_chart_json(device) - end - end - - defp build_overall_traffic_chart_json(device) do - if Enum.empty?(device.interfaces) do - nil - else - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - all_stats = aggregate_interface_traffic_stats(device.interfaces, twenty_four_hours_ago) - - if Enum.empty?(all_stats) do - nil - else - build_traffic_json_datasets(all_stats, device) - end - end - end - - # Calculate BPS per interface first, then aggregate by timestamp - # This avoids issues with misaligned timestamps between interfaces - # Processes all interfaces (regardless of monitored status) to show total device traffic - defp aggregate_interface_traffic_stats(interfaces, since) do - interfaces - |> Enum.flat_map(fn interface -> - interface.id - |> Snmp.get_interface_stats(since: since, limit: 1000) - |> Enum.reverse() - |> calculate_interface_bps() - end) - |> Enum.group_by(& &1.timestamp_ms) - |> Enum.map(fn {ts, bps_list} -> - total_in = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.in_bps end) - total_out = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.out_bps end) - %{timestamp_ms: ts, in_bps: total_in, out_bps: total_out} - end) - |> Enum.sort_by(& &1.timestamp_ms) - end - - # Calculate BPS for a single interface's stats - defp calculate_interface_bps(stats) do - stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [s1, s2] -> - time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1) - - in_bps = - if s2.if_in_octets && s1.if_in_octets do - calculate_counter_bps(s1.if_in_octets, s2.if_in_octets, time_diff) - else - 0.0 - end - - out_bps = - if s2.if_out_octets && s1.if_out_octets do - calculate_counter_bps(s1.if_out_octets, s2.if_out_octets, time_diff) - else - 0.0 - end - - # Round timestamp to nearest minute for aggregation (avoids microsecond misalignment) - ts_seconds = DateTime.to_unix(s2.checked_at) - rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000 - - %{timestamp_ms: rounded_ts_ms, in_bps: in_bps, out_bps: out_bps} - end) - end - - defp calculate_counter_bps(prev_octets, curr_octets, time_diff) do - Towerops.Capacity.calculate_bps(prev_octets, curr_octets, time_diff) - end - - defp build_traffic_json_datasets(all_stats, device) do - in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end) - out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end) - - datasets = [ - %{label: "Outbound", data: out_data}, - %{label: "Inbound", data: in_data} - ] - - # For backhaul devices, add capacity reference lines - datasets = - if device.device.device_role == "backhaul" do - add_capacity_datasets(datasets, all_stats, device) - else - datasets - end - - Jason.encode!(%{datasets: datasets}) - end - - # Add capacity reference lines for backhaul devices - # Calculates capacity per timestamp based on which interfaces have data at that point - # For radios with Rx/Tx Capacity sensors, uses those values as total device capacity - defp add_capacity_datasets(datasets, all_stats, device) do - # Get capacity sensors if available (for radios like AirFiber) - rx_capacity_sensor = Enum.find(device.sensors, &(&1.sensor_descr == "Rx Capacity")) - tx_capacity_sensor = Enum.find(device.sensors, &(&1.sensor_descr == "Tx Capacity")) - - # If we have capacity sensors, use them as the total device capacity - # Otherwise, calculate from interface speeds - capacity_data = - if rx_capacity_sensor || tx_capacity_sensor do - calculate_sensor_capacity_data(all_stats, rx_capacity_sensor, tx_capacity_sensor) - else - calculate_interface_capacity_data(all_stats, device) - end - - # Only add capacity datasets if we have at least some capacity data - max_rx = capacity_data |> Enum.map(fn {_, rx, _} -> rx end) |> Enum.max(fn -> 0 end) - max_tx = capacity_data |> Enum.map(fn {_, _, tx} -> tx end) |> Enum.max(fn -> 0 end) - - if max_rx > 0 || max_tx > 0 do - capacity_in_data = - Enum.map(capacity_data, fn {ts, rx, _tx} -> - %{x: ts, y: Float.round(rx * 1.0, 2)} - end) - - capacity_out_data = - Enum.map(capacity_data, fn {ts, _rx, tx} -> - %{x: ts, y: -Float.round(tx * 1.0, 2)} - end) - - # Add capacity datasets with special styling - datasets ++ - [ - %{ - label: "Capacity (In)", - data: capacity_in_data, - borderDash: [5, 5], - borderWidth: 2, - pointRadius: 0, - fill: false - }, - %{ - label: "Capacity (Out)", - data: capacity_out_data, - borderDash: [5, 5], - borderWidth: 2, - pointRadius: 0, - fill: false - } - ] - else - datasets - end - end - - # Calculate capacity data using sensor values (for radios with Rx/Tx Capacity sensors) - # Sensor values represent total device capacity, not per-interface - # Loads time-series sensor readings to create a dynamic capacity line - defp calculate_sensor_capacity_data(all_stats, rx_capacity_sensor, tx_capacity_sensor) do - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - # Load time-series readings for both sensors - rx_readings = load_capacity_sensor_readings(rx_capacity_sensor, twenty_four_hours_ago) - tx_readings = load_capacity_sensor_readings(tx_capacity_sensor, twenty_four_hours_ago) - - # If we have sensor readings, use them directly (they have their own timestamps) - # Otherwise fall back to static values at traffic timestamps - if map_size(rx_readings) > 0 || map_size(tx_readings) > 0 do - build_capacity_data_from_sensors(rx_readings, tx_readings, rx_capacity_sensor, tx_capacity_sensor) - else - build_static_capacity_data(all_stats, rx_capacity_sensor, tx_capacity_sensor) - end - end - - # Load sensor readings and convert to timestamp -> bps map - defp load_capacity_sensor_readings(nil, _since), do: %{} - - defp load_capacity_sensor_readings(sensor, since) do - sensor.id - |> Snmp.get_sensor_readings(since: since, limit: 1000) - |> Map.new(&sensor_reading_to_capacity_point/1) - end - - # Convert a sensor reading to {timestamp_ms, bps} tuple - # Sensor values are in Mbps, convert to bps - defp sensor_reading_to_capacity_point(reading) do - bps = capacity_value_to_bps(reading.value) - ts_ms = DateTime.to_unix(reading.checked_at, :millisecond) - {ts_ms, bps} - end - - # Convert capacity sensor value (in Mbps) to bps - defp capacity_value_to_bps(nil), do: 0 - defp capacity_value_to_bps(value) when is_number(value), do: round(value * 1_000_000) - defp capacity_value_to_bps(_), do: 0 - - # Build capacity data points from sensor readings - defp build_capacity_data_from_sensors(rx_readings, tx_readings, rx_sensor, tx_sensor) do - # Combine all unique timestamps from both sensors - all_timestamps = - (Map.keys(rx_readings) ++ Map.keys(tx_readings)) - |> MapSet.new() - |> Enum.sort() - - # Build capacity data points using sensor readings - Enum.map(all_timestamps, fn ts_ms -> - rx_bps = Map.get(rx_readings, ts_ms, get_fallback_capacity_bps(rx_sensor)) - tx_bps = Map.get(tx_readings, ts_ms, get_fallback_capacity_bps(tx_sensor)) - {ts_ms, rx_bps, tx_bps} - end) - end - - # Build static capacity data at traffic timestamps - defp build_static_capacity_data(all_stats, rx_sensor, tx_sensor) do - rx_cap_bps = get_fallback_capacity_bps(rx_sensor) - tx_cap_bps = get_fallback_capacity_bps(tx_sensor) - - Enum.map(all_stats, fn stat -> - {stat.timestamp_ms, rx_cap_bps, tx_cap_bps} - end) - end - - # Get fallback capacity in bps from sensor's last_value (already in Mbps) - defp get_fallback_capacity_bps(nil), do: 0 - - defp get_fallback_capacity_bps(sensor) do - capacity_value_to_bps(sensor.last_value) - end - - # Calculate capacity data from interface configured_capacity_bps or if_speed - # Sums capacity of active interfaces at each timestamp - defp calculate_interface_capacity_data(all_stats, device) do - # Build a map of interface_id -> {rx_capacity, tx_capacity} for quick lookup - # Includes all interfaces to match the total device traffic - interface_capacity_map = - Map.new(device.interfaces, fn interface -> - cap = interface.configured_capacity_bps || interface.if_speed || 0 - {interface.id, {cap, cap}} - end) - - # Get the raw interface stats to determine which interfaces were active at each timestamp - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - # Track which interfaces have data at each timestamp - interface_activity_by_timestamp = - device.interfaces - |> Enum.flat_map(fn interface -> - interface.id - |> Snmp.get_interface_stats(since: twenty_four_hours_ago, limit: 1000) - |> Enum.map(fn stat -> - ts_seconds = DateTime.to_unix(stat.checked_at) - rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000 - {rounded_ts_ms, interface.id} - end) - end) - |> Enum.group_by(fn {ts, _} -> ts end, fn {_, iface_id} -> iface_id end) - - # Calculate capacity at each timestamp based on active interfaces - Enum.map(all_stats, fn stat -> - active_interfaces = Map.get(interface_activity_by_timestamp, stat.timestamp_ms, []) - - {total_rx, total_tx} = - active_interfaces - |> Enum.uniq() - |> Enum.reduce({0, 0}, fn iface_id, {rx_acc, tx_acc} -> - {rx_cap, tx_cap} = Map.get(interface_capacity_map, iface_id, {0, 0}) - {rx_acc + rx_cap, tx_acc + tx_cap} - end) - - {stat.timestamp_ms, total_rx, total_tx} - end) - end - # Group interfaces by type for organized display defp group_interfaces_by_type(interfaces) do interfaces @@ -1558,156 +719,6 @@ defmodule ToweropsWeb.DeviceLive.Show do Map.get(@interface_category_order, category, 99) end - # Group transceiver sensors by transceiver name - # Returns list of {name, sensors} tuples sorted by name - defp group_transceiver_sensors(transceiver_sensors) do - transceiver_sensors - |> Enum.group_by(&extract_transceiver_name/1) - |> Enum.sort_by(fn {name, _sensors} -> name end) - end - - # Extract transceiver name from sensor description - # Examples: - # "sfp-sfpplus1 Rx Power" -> "sfp-sfpplus1" - # "sfp-sfpplus2 Temperature" -> "sfp-sfpplus2" - # "SFP1 Voltage" -> "SFP1" - defp extract_transceiver_name(sensor) do - case sensor.sensor_descr do - nil -> - "unknown" - - descr -> - # Extract the first word (transceiver identifier) - descr - |> String.split(" ") - |> List.first() - |> String.downcase() - end - end - - # Group IP addresses by interface ID for efficient lookup in templates - defp group_ip_addresses_by_interface(ip_addresses) do - Enum.group_by(ip_addresses, & &1.snmp_interface_id) - end - - # Sensor type classification helpers - # Trust sensor_type first (set during SNMP discovery based on OID) - # Only fall back to unit/description matching if sensor_type is missing/unknown - defp temperature_sensor?(sensor) do - # Primary: Check sensor_type (set during discovery based on OID) - type_match = sensor.sensor_type in ["temperature", "cpu_temperature", "celsius"] - - # Fallback: Check unit for devices that don't set sensor_type correctly - unit_match = sensor.sensor_unit in ["°C", "C", "celsius"] - - # Only use description as last resort if type and unit don't match - # and only for unambiguous temperature keywords - descr_match = - !type_match && !unit_match && - sensor.sensor_descr && - String.contains?(String.downcase(sensor.sensor_descr), "temperature") && - !String.contains?(String.downcase(sensor.sensor_descr), "voltage") - - (type_match || unit_match || descr_match) && !voltage_by_description?(sensor) - end - - defp voltage_sensor?(sensor) do - type_match = sensor.sensor_type in ["voltage", "volts"] - unit_match = sensor.sensor_unit in ["V", "mV", "volts"] - descr_match = voltage_by_description?(sensor) - - type_match || unit_match || descr_match - end - - defp voltage_by_description?(sensor) do - sensor.sensor_descr && - String.contains?(String.downcase(sensor.sensor_descr), "voltage") - end - - defp wireless_sensor?(sensor) do - sensor.sensor_type in [ - "frequency", - "power", - "rssi", - "ccq", - "clients", - "distance", - "noise-floor", - "quality", - "rate", - "utilization" - ] - end - - # Counter sensors include various count and gauge types - # (connections, sessions, clients, DHCP leases, etc.) - defp counter_sensor?(sensor) do - sensor.sensor_type in [ - "count", - "pppoe_sessions", - "connections", - "clients", - "leases", - "sessions" - ] - end - - # Firewall counters are connection-related metrics - defp firewall_counter?(sensor) do - descr = String.downcase(sensor.sensor_descr || "") - String.contains?(descr, "connection") - end - - # Current sensors (amperes) - defp current_sensor?(sensor) do - type_match = sensor.sensor_type in ["current", "amperes"] - unit_match = sensor.sensor_unit in ["A", "mA", "amperes"] - - type_match || unit_match - end - - # Power sensors (watts) - separate from wireless power - defp power_sensor?(sensor) do - type_match = sensor.sensor_type in ["power", "watts"] - unit_match = sensor.sensor_unit in ["W", "mW", "watts"] - - # Exclude wireless power sensors (those are in wireless category) - (type_match || unit_match) && !wireless_sensor?(sensor) - end - - # Fan speed sensors (RPM) - defp fan_sensor?(sensor) do - type_match = sensor.sensor_type in ["fanspeed", "rpm"] - unit_match = sensor.sensor_unit in ["RPM", "rpm"] - descr_match = sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "fan") - - type_match || unit_match || descr_match - end - - # System load sensors (CPU load, etc.) - defp load_sensor?(sensor) do - type_match = sensor.sensor_type in ["load", "cpu_load", "system_load"] - descr_match = sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "load") - - type_match || descr_match - end - - # Signal quality sensors (SNR, RSSI, RSRP, etc.) - defp signal_sensor?(sensor) do - sensor.sensor_type in [ - "snr", - "rssi", - "rsrp", - "rsrq", - "sinr", - "ssr", - "mse", - "noise", - "noise-floor", - "dbm" - ] - end - @impl true def handle_event("set_capacity", %{"interface_id" => id, "capacity_mbps" => mbps_str}, socket) do case Float.parse(mbps_str) do @@ -1805,9 +816,16 @@ defmodule ToweropsWeb.DeviceLive.Show do case AccessControl.verify_device_access(backup.device_id, organization.id) do {:ok, device} -> config_text = MikrotikBackups.decompress_config(backup.config_compressed) - filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" - {:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})} + filename = + "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" + + {:noreply, + push_event(socket, "download", %{ + content: config_text, + filename: filename, + mime_type: "text/plain" + })} {:error, _} -> {:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))} @@ -1885,7 +903,11 @@ defmodule ToweropsWeb.DeviceLive.Show do cond do is_nil(agent_token_id) -> {:noreply, - put_flash(socket, :error, t_equipment("Device has no agent assigned. Assign an agent to create backups."))} + put_flash( + socket, + :error, + t_equipment("Device has no agent assigned. Assign an agent to create backups.") + )} !device.mikrotik_enabled -> {:noreply, put_flash(socket, :error, t_equipment("MikroTik API is not enabled for this device."))} @@ -1897,7 +919,7 @@ defmodule ToweropsWeb.DeviceLive.Show do defp reload_snmp_and_ports(socket) do device_id = socket.assigns.device.id - snmp_data = load_snmp_data(device_id) + snmp_data = DataLoaders.load_snmp_data(device_id) socket |> assign(:snmp_interfaces, snmp_data.interfaces) @@ -1974,10 +996,15 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, socket |> put_flash(:info, t_equipment("Manual backup requested. Results will appear shortly.")) - |> load_equipment_data(device.id)} + |> assign_backups_data(device.id)} {:error, _reason} -> - {:noreply, put_flash(socket, :error, t_equipment("Failed to create backup request. Please try again."))} + {:noreply, + put_flash( + socket, + :error, + t_equipment("Failed to create backup request. Please try again.") + )} end end @@ -1994,7 +1021,7 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, socket |> put_flash(:info, t_equipment("Backup deleted successfully")) - |> load_equipment_data(socket.assigns.device.id)} + |> assign_backups_data(socket.assigns.device.id)} {:error, _changeset} -> {:noreply, put_flash(socket, :error, t_equipment("Failed to delete backup"))} @@ -2178,17 +1205,6 @@ defmodule ToweropsWeb.DeviceLive.Show do defp safe_to_integer(_, default), do: default - defp safe_encode_chart_data(data) do - case Jason.encode(data) do - {:ok, encoded} -> - encoded - - {:error, reason} -> - Logger.error("Failed to encode chart data: #{inspect(reason)}") - Jason.encode!(%{datasets: []}) - end - end - @impl true def terminate(_reason, _socket) do :ok diff --git a/lib/towerops_web/live/helpers/access_control.ex b/lib/towerops_web/live/helpers/access_control.ex index ba81cbf4..c0b32307 100644 --- a/lib/towerops_web/live/helpers/access_control.ex +++ b/lib/towerops_web/live/helpers/access_control.ex @@ -6,7 +6,7 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do (devices, sites, alerts) within their organization scope. """ - alias Towerops.Alerts + alias Towerops.Alerts.Alert alias Towerops.Devices alias Towerops.Repo alias Towerops.Sites @@ -93,14 +93,14 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do {:error, :not_found} """ @spec verify_alert_access(binary(), binary()) :: - {:ok, Alerts.Alert.t()} | {:error, :not_found | :unauthorized} + {:ok, Alert.t()} | {:error, :not_found | :unauthorized} def verify_alert_access(alert_id, organization_id) do - case Alerts.get_alert(alert_id) do + case Repo.get(Alert, alert_id) do nil -> {:error, :not_found} alert -> - alert = Repo.preload(alert, device: [site: :organization]) + alert = Repo.preload(alert, [:acknowledged_by, device: [site: :organization]]) if alert.device.site.organization_id == organization_id do {:ok, alert}