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: graham/towerops-web#192
This commit is contained in:
Graham McIntire 2026-03-27 16:34:42 -05:00 committed by graham
parent 7606efc635
commit c4e301a84c
15 changed files with 4642 additions and 1196 deletions

View file

@ -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 = {
</p>`
}
popupContent += `<button
onclick="window.liveSocket.execJS(document.querySelector('#leaflet-map'), 'this.pushEvent(\\'site_clicked\\', {site_id: \\'${site.id}\\'})')"
popupContent += `<button
data-site-id="${site.id}"
data-action="view-site"
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
>
View Site
</button></div>`
// 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)
}
},

View file

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

View file

@ -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"""
<div id="backups-tab">
<%= if false and @device.mikrotik_enabled do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Configuration Backups")}
<%= if Enum.any?(@mikrotik_backups) do %>
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@mikrotik_backups)})
</span>
<% end %>
</h3>
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-100 rounded dark:bg-blue-900 dark:text-blue-200">
<.icon name="hero-beaker" class="h-3 w-3" /> Experimental
</span>
</div>
<%= if @agent_info.agent_token_id do %>
<button
phx-click="backup_now"
phx-throttle="2000"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Backup Now
</button>
<% else %>
<div class="text-sm text-amber-600 dark:text-amber-400">
{t("Assign an agent to enable backups")}
</div>
<% end %>
</div>
</div>
<%= if Enum.any?(@mikrotik_backups) do %>
<%!-- Instructions for comparing backups --%>
<%= if length(@mikrotik_backups) > 1 do %>
<div class="px-4 py-3 bg-blue-50 dark:bg-blue-950/30 border-b border-blue-100 dark:border-blue-900/50">
<div class="flex items-start gap-2">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div class="text-sm text-blue-900 dark:text-blue-200">
<span class="font-medium">Compare configurations:</span>
{t("Select any 2 backups using the checkboxes to compare their differences")}
</div>
</div>
</div>
<% end %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th
class="px-4 py-3 text-center font-medium w-12"
title="Select backups to compare"
>
<.icon name="hero-check-circle" class="h-4 w-4 mx-auto" />
</th>
<th class="px-4 py-3 text-left font-medium">Date</th>
<th class="px-4 py-3 text-left font-medium">Original Size</th>
<th class="px-4 py-3 text-left font-medium">Compressed Size</th>
<th class="px-4 py-3 text-left font-medium">Ratio</th>
<th class="px-4 py-3 text-left font-medium">Source</th>
<th class="px-4 py-3 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= 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 %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 text-center">
<input
type="checkbox"
phx-click="toggle_backup_selection"
phx-value-id={backup.id}
phx-target={@myself}
checked={MapSet.member?(@selected_backup_ids, backup.id)}
disabled={
!MapSet.member?(@selected_backup_ids, backup.id) &&
MapSet.size(@selected_backup_ids) >= 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"
/>
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white whitespace-nowrap">
{ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
{format_bytes(backup.config_size_bytes)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
{format_bytes(backup.compressed_size_bytes)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{compression_ratio}%
</td>
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
backup.trigger_source == "daily_cron" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
backup.trigger_source == "manual" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
backup.trigger_source == "pre_change" &&
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
]}>
{String.replace(backup.trigger_source, "_", " ") |> String.capitalize()}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
phx-click="download_backup"
phx-value-id={backup.id}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
</button>
<%= 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
</.button>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%!-- Pagination --%>
<%= if assigns[:pagination] do %>
<div class="px-4 py-3 border-t border-gray-200 dark:border-white/10">
<.pagination
meta={@pagination}
path={~p"/devices/#{@device.id}"}
params={%{"tab" => "backups"}}
/>
</div>
<% end %>
<%!-- Floating Compare Button --%>
<%= if MapSet.size(@selected_backup_ids) > 0 do %>
<div class="fixed bottom-8 right-8 z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-white/10 p-4">
<div class="flex flex-col gap-2">
<%!-- Selection status --%>
<div class="flex items-center gap-2 text-sm">
<.icon
name="hero-check-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400"
/>
<div class="text-gray-700 dark:text-gray-300">
<span class="font-medium">{MapSet.size(@selected_backup_ids)}</span>
of 2 backups selected
</div>
</div>
<%!-- Instructions when only 1 selected --%>
<%= if MapSet.size(@selected_backup_ids) == 1 do %>
<div class="text-xs text-gray-500 dark:text-gray-400 pl-7">
{t("Select one more backup to compare")}
</div>
<% end %>
<%!-- Action buttons --%>
<div class="flex items-center gap-2 mt-1">
<button
phx-click="clear_selection"
phx-target={@myself}
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
{t("Clear")}
</button>
<button
phx-click="compare_selected"
disabled={MapSet.size(@selected_backup_ids) != 2}
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors shadow-sm"
>
<.icon name="hero-arrows-right-left" class="h-4 w-4" />
{if MapSet.size(@selected_backup_ids) == 2,
do: "Compare Selected",
else: "Compare"}
</button>
</div>
</div>
</div>
</div>
<% end %>
<% else %>
<div class="p-8 text-center">
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{t("No backups yet")}
</h3>
<%= if @agent_info.agent_token_id do %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour(
7,
@timezone
)}.
</p>
<% else %>
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400">
{t("Assign an agent to enable automatic backups.")}
</p>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
<div class="text-center">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{t("MikroTik API not enabled")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("Enable MikroTik API in device settings to enable configuration backups.")}
</p>
<div class="mt-6">
<.button navigate={~p"/devices/#{@device.id}/edit"}>
{t("Edit Device Settings")}
</.button>
</div>
</div>
</div>
<% end %>
</div>
"""
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

View file

@ -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"""
<div id="checks-tab">
<%= if Enum.empty?(@checks) do %>
<!-- Empty State -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-12 text-center">
<.icon name="hero-clipboard-document-check" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{t("No checks configured")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t(
"Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually."
)}
</p>
<div class="mt-6 flex justify-center gap-3">
<%= if @device.snmp_enabled do %>
<.button type="button" phx-click="run_discovery">
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Run Discovery
</.button>
<% end %>
<.button type="button" phx-click="add_check">
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
</.button>
</div>
</div>
</div>
<% else %>
<!-- Checks List -->
<div class="space-y-6">
<!-- Header with count and Add button -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Checks ({length(@checks)})
</h2>
<.button type="button" phx-click="add_check">
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
</.button>
</div>
<%= for {group_key, group_checks} <- @grouped_checks do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<!-- Group Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-900/50">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{group_title(group_key)} ({length(group_checks)})
</h3>
</div>
<!-- Checks Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-900/30">
<tr>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Name")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Status")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Value")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{t("Last Checked")}
</th>
<th scope="col" class="relative px-4 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for check <- group_checks do %>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/30">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{check.name}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-left">
{render_status_badge(check.current_state)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if check.last_check_at do %>
{get_latest_value(check)}
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
Pending
</span>
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if check.last_check_at do %>
{format_relative_time(check.last_check_at)}
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
Never
</span>
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-3">
<.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")}
</.link>
<%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %>
<button
type="button"
phx-click="edit_check"
phx-value-id={check.id}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<.icon name="hero-pencil-square" class="h-4 w-4" />
</button>
<button
type="button"
phx-click="delete_check"
phx-value-id={check.id}
data-confirm="Are you sure you want to delete this check?"
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
"""
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"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
OK
</span>
"""
end
defp render_status_badge(1) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
WARNING
</span>
"""
end
defp render_status_badge(2) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
CRITICAL
</span>
"""
end
defp render_status_badge(3) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
UNKNOWN
</span>
"""
end
defp render_status_badge(_) do
assigns = %{}
~H"""
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
PENDING
</span>
"""
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

View file

@ -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"""
<div id="gaiia-tab">
<%= if @gaiia_item do %>
<div class="space-y-6">
<%!-- Inventory Item Details --%>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{t("Gaiia Inventory Item")}
</h3>
</div>
<div class="px-4 py-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_item.name || "---"}
</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Gaiia ID</dt>
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
{@gaiia_item.gaiia_id}
</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="mt-1">
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case @gaiia_item.status do
"active" ->
"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"}
</span>
</dd>
</div>
<%= if @gaiia_item.model_name do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Model</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_item.model_name}
</dd>
</div>
<% end %>
<%= if @gaiia_item.manufacturer_name do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Manufacturer")}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_item.manufacturer_name}
</dd>
</div>
<% end %>
<%= if @gaiia_item.category do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Category")}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_item.category}
</dd>
</div>
<% end %>
<%= if @gaiia_item.serial_number do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Serial Number")}
</dt>
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
{@gaiia_item.serial_number}
</dd>
</div>
<% end %>
<%= if @gaiia_item.mac_address do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("MAC Address")}
</dt>
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
{@gaiia_item.mac_address}
</dd>
</div>
<% end %>
<%= if @gaiia_item.ip_address do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("IP Address (Gaiia)")}
</dt>
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
{@gaiia_item.ip_address}
<%= if @device.ip_address && @gaiia_item.ip_address != @device.ip_address do %>
<span class="ml-2 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
<.icon name="hero-exclamation-triangle-mini" class="mr-0.5 h-3 w-3" />
{t("Mismatch")}
</span>
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<%!-- Network Site --%>
<%= if @gaiia_network_site do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{t("Gaiia Network Site")}
</h3>
</div>
<div class="px-4 py-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Site Name")}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_network_site.name}
</dd>
</div>
<%= if @gaiia_network_site.account_count do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Subscribers")}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_network_site.account_count}
</dd>
</div>
<% end %>
<%= if @gaiia_network_site.total_mrr do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Monthly Revenue")}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
${Decimal.round(@gaiia_network_site.total_mrr, 2)}
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
<%!-- Linked Subscriber --%>
<%= if @gaiia_account do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{t("Linked Subscriber")}
</h3>
</div>
<div class="px-4 py-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@gaiia_account.name}
</dd>
</div>
<%= if @gaiia_account.readable_id do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("Account ID")}
</dt>
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
{@gaiia_account.readable_id}
</dd>
</div>
<% end %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="mt-1">
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case @gaiia_account.status do
"active" ->
"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"}
</span>
</dd>
</div>
<%= if @gaiia_account.mrr do %>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">MRR</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
${Decimal.round(@gaiia_account.mrr, 2)}
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
<%!-- Billing Subscriptions --%>
<%= if @gaiia_subscriptions != [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{t("Billing Subscriptions")}
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{t("Plan")}
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{t("Status")}
</th>
<th
scope="col"
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{t("MRR")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for sub <- @gaiia_subscriptions do %>
<tr class="hover:bg-gray-50 dark:hover:bg-white/5">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
{sub.product_name || "---"}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case sub.status do
"active" ->
"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"}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-900 dark:text-white">
<%= if sub.mrr_amount do %>
${Decimal.round(sub.mrr_amount, 2)}
<span class="text-gray-400">
{sub.currency || ""}
</span>
<% else %>
---
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No Gaiia inventory item linked to this device.")}
</p>
</div>
<% end %>
</div>
"""
end
end

File diff suppressed because it is too large Load diff

View file

@ -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"""
<div id="ports-tab">
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
<div class="space-y-6">
<%= for {category, interfaces} <- @interfaces_by_type do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{category} Interfaces
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(interfaces)})
</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-3 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Name</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-right font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">IP Address</th>
<th class="px-3 py-2 text-left font-medium">MAC</th>
<th class="px-3 py-2 text-left font-medium">Capacity</th>
<th class="px-3 py-2 text-left font-medium">Utilization</th>
<th class="px-3 py-2 text-right font-medium">In</th>
<th class="px-3 py-2 text-right font-medium">Out</th>
<th class="px-3 py-2 text-right font-medium">Errors</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for interface <- interfaces do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td class="px-3 py-2 text-gray-500 dark:text-gray-400 font-mono text-xs">
{interface.if_index}
</td>
<td class="px-3 py-2">
<.link
navigate={
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400"
>
<div class="font-medium text-gray-900 dark:text-white hover:underline text-sm">
{interface.if_name || interface.if_descr}
</div>
<%= if interface.if_alias do %>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
{interface.if_alias}
</div>
<% end %>
</.link>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center gap-1.5 text-xs">
<span class={[
"h-2 w-2 rounded-full flex-shrink-0",
interface.if_oper_status == "up" && "bg-green-500",
interface.if_oper_status == "down" && "bg-red-500",
interface.if_oper_status not in ["up", "down"] && "bg-gray-400"
]} />
<span class={[
"font-medium",
interface.if_oper_status == "up" &&
"text-green-700 dark:text-green-400",
interface.if_oper_status == "down" &&
"text-red-700 dark:text-red-400",
interface.if_oper_status not in ["up", "down"] &&
"text-gray-600 dark:text-gray-400"
]}>
{String.upcase(interface.if_oper_status || "unknown")}
</span>
</span>
</td>
<td class="px-3 py-2 text-right text-gray-900 dark:text-white text-xs font-mono">
{format_speed(interface.if_speed)}
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
<% interface_ips =
Map.get(@ip_addresses_by_interface, interface.id, []) %>
<%= if Enum.empty?(interface_ips) do %>
<span class="text-gray-400">-</span>
<% else %>
<div class="space-y-0.5">
<%= for ip <- interface_ips do %>
<div
class="cursor-pointer group/iip"
phx-click={JS.dispatch("phx:copy", detail: %{text: ip.ip_address})}
title={t("Click to copy")}
>
{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"
/>
</div>
<% end %>
</div>
<% end %>
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
<span
:if={interface.if_phys_address}
class="cursor-pointer group/mac"
phx-click={
JS.dispatch("phx:copy", detail: %{text: interface.if_phys_address})
}
title={t("Click to copy")}
>
{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"
/>
</span>
<span :if={!interface.if_phys_address} class="text-gray-400">-</span>
</td>
<td class="px-3 py-2 text-xs">
<%= if interface.configured_capacity_bps do %>
<div class="flex items-center gap-1">
<span class="font-mono text-gray-900 dark:text-white">
{format_speed(interface.configured_capacity_bps)}
</span>
<span class={[
"px-1 py-0.5 rounded text-[10px] font-medium",
capacity_source_class(interface.capacity_source)
]}>
{capacity_source_label(interface.capacity_source)}
</span>
<button
phx-click="clear_capacity"
phx-value-interface_id={interface.id}
class="text-gray-400 hover:text-red-500 transition-colors ml-0.5"
title={t("Clear capacity")}
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</div>
<% else %>
<button
phx-click={
JS.push("set_capacity",
value: %{
interface_id: interface.id,
capacity_mbps:
if(interface.if_speed,
do: to_string(div(interface.if_speed, 1_000_000)),
else: ""
)
}
)
}
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{t("Set")}
</button>
<% end %>
</td>
<td class="px-3 py-2 text-xs">
<%= if interface.utilization do %>
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
class={[
"h-full rounded-full transition-all",
utilization_color(interface.utilization.utilization_pct)
]}
style={"width: #{min(interface.utilization.utilization_pct, 100)}%"}
>
</div>
</div>
<span class={[
"font-mono whitespace-nowrap",
utilization_text_color(interface.utilization.utilization_pct)
]}>
{Float.round(interface.utilization.utilization_pct, 1)}%
</span>
</div>
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
{if interface.latest_stat && interface.latest_stat.if_in_octets,
do: format_bytes(interface.latest_stat.if_in_octets),
else: "-"}
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
{if interface.latest_stat && interface.latest_stat.if_out_octets,
do: format_bytes(interface.latest_stat.if_out_octets),
else: "-"}
</td>
<td class="px-3 py-2 text-right font-mono text-xs">
<% 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}
</.link>
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<p class="text-gray-500 dark:text-gray-400">No interfaces discovered</p>
</div>
<% end %>
</div>
"""
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

View file

@ -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"""
<div id="preseem-tab">
<%= if @preseem_access_point do %>
<div class="space-y-6">
<%!-- Insights section --%>
<%= if @preseem_insights != [] do %>
<div class="space-y-3">
<%= for insight <- @preseem_insights do %>
<div class={[
"rounded-lg border p-4 flex items-start justify-between",
case insight.urgency do
"critical" ->
"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
]}>
<div class="flex items-start gap-3">
<div class={[
"mt-0.5",
case insight.urgency do
"critical" -> "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 %>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{insight.title}
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{insight.description}
</p>
</div>
</div>
<button
phx-click="dismiss_insight"
phx-value-id={insight.id}
phx-target={@myself}
class="ml-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0"
title="Dismiss"
>
<.icon name="hero-x-mark" class="h-4 w-4" />
</button>
</div>
<% end %>
</div>
<% end %>
<%!-- Score cards --%>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">QoE Score</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{if @preseem_access_point.qoe_score,
do: Float.round(@preseem_access_point.qoe_score, 1),
else: "---"}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
{t("Capacity Score")}
</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{if @preseem_access_point.capacity_score,
do: Float.round(@preseem_access_point.capacity_score, 1),
else: "---"}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">RF Score</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{if @preseem_access_point.rf_score,
do: Float.round(@preseem_access_point.rf_score, 1),
else: "---"}
</div>
</div>
</div>
<%!-- Details card --%>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Access Point Details")}
</h3>
</div>
<div class="px-4 py-3">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.name || "---"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{t("Preseem ID")}
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.preseem_id}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{t("Subscribers")}
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.subscriber_count || "---"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{t("Busy Hours")}
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
<%= if @preseem_access_point.busy_hours do %>
{@preseem_access_point.busy_hours} hrs/day
<% else %>
---
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{t("Airtime Utilization")}
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
<%= if @preseem_access_point.airtime_utilization do %>
{Float.round(@preseem_access_point.airtime_utilization, 1)}%
<% else %>
---
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{t("Match Type")}
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.match_confidence}
</dd>
</div>
</dl>
</div>
</div>
<%!-- Recent metrics table --%>
<%= if @preseem_metrics != [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Recent QoE Metrics")}
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Time")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Latency (ms)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("P95 Latency")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Jitter (ms)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Loss (%)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Throughput")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{t("Subscribers")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for metric <- @preseem_metrics do %>
<tr>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">
{ToweropsWeb.TimeHelpers.format_iso8601(
metric.recorded_at,
@current_scope.timezone,
@current_scope.time_format
)}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_latency,
do: Float.round(metric.avg_latency, 1),
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.p95_latency,
do: Float.round(metric.p95_latency, 1),
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_jitter,
do: Float.round(metric.avg_jitter, 1),
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_throughput,
do: "#{Float.round(metric.avg_throughput, 1)} Mbps",
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{metric.subscriber_count || "---"}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No Preseem data linked to this device.")}
</p>
</div>
<% end %>
</div>
"""
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

View file

@ -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"""
<div id="wireless-tab">
<%= if @wireless_clients && length(@wireless_clients) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Connected Wireless Clients")}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{length(@wireless_clients)} clients {@wireless_matched_count} subscribers matched
<%= if @wireless_last_updated do %>
· Last updated {format_relative_time(@wireless_last_updated)}
<% end %>
</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-3 py-2 text-left font-medium">MAC Address</th>
<th class="px-3 py-2 text-left font-medium">IP Address</th>
<th class="px-3 py-2 text-left font-medium">Subscriber</th>
<th class="px-3 py-2 text-left font-medium">Signal</th>
<th class="px-3 py-2 text-left font-medium">SNR</th>
<th class="px-3 py-2 text-right font-medium">Distance</th>
<th class="px-3 py-2 text-right font-medium">TX Rate</th>
<th class="px-3 py-2 text-right font-medium">RX Rate</th>
<th class="px-3 py-2 text-right font-medium">Uptime</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for client <- @wireless_clients do %>
<% subscriber_link =
Map.get(@wireless_client_subscribers, String.downcase(client.mac_address)) %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td class="px-3 py-2 font-mono text-xs">
<span
class="cursor-pointer group/mac text-gray-900 dark:text-white"
phx-click={JS.dispatch("phx:copy", detail: %{text: client.mac_address})}
title={t("Click to copy")}
>
{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"
/>
</span>
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
<%= if client.ip_address do %>
<span
class="cursor-pointer group/ip"
phx-click={JS.dispatch("phx:copy", detail: %{text: client.ip_address})}
title={t("Click to copy")}
>
{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"
/>
</span>
<% else %>
<span class="text-gray-400"></span>
<% end %>
</td>
<td class="px-3 py-2 text-xs">
<%= if subscriber_link && subscriber_link.gaiia_account do %>
<span class="text-gray-900 dark:text-white font-medium">
{subscriber_link.gaiia_account.account_name}
</span>
<% else %>
<span class="text-gray-400"></span>
<% end %>
</td>
<td class="px-3 py-2 text-xs">
<%= if client.signal_strength do %>
<.signal_badge value={client.signal_strength} />
<% else %>
<span class="text-gray-400"></span>
<% end %>
</td>
<td class="px-3 py-2 text-xs">
<%= if client.snr do %>
<.snr_badge value={client.snr} />
<% else %>
<span class="text-gray-400"></span>
<% end %>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
<%= if client.distance do %>
{client.distance}m
<% else %>
<span class="text-gray-400"></span>
<% end %>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
{format_rate(client.tx_rate)}
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
{format_rate(client.rx_rate)}
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
<%= if client.uptime_seconds do %>
{format_uptime(client.uptime_seconds * 100)}
<% else %>
<span class="text-gray-400"></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="text-center py-12">
<.icon
name="hero-signal"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600"
/>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{t("No wireless clients")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("No clients are currently connected to this access point.")}
</p>
</div>
<% end %>
</div>
"""
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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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}