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:
parent
7606efc635
commit
c4e301a84c
15 changed files with 4642 additions and 1196 deletions
106
assets/js/app.ts
106
assets/js/app.ts
|
|
@ -633,6 +633,9 @@ const BetaBannerDismiss = {
|
|||
const NetworkMap = {
|
||||
cy: null as any,
|
||||
_topologyData: null as any,
|
||||
_zoomInHandler: null as ((e: Event) => void) | null,
|
||||
_zoomOutHandler: null as ((e: Event) => void) | null,
|
||||
_fitHandler: null as ((e: Event) => void) | null,
|
||||
mounted(this: any) {
|
||||
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
||||
this._topologyData = topologyData
|
||||
|
|
@ -671,6 +674,25 @@ const NetworkMap = {
|
|||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
// Remove DOM event listeners
|
||||
const zoomIn = document.getElementById('cy-zoom-in')
|
||||
const zoomOut = document.getElementById('cy-zoom-out')
|
||||
const fit = document.getElementById('cy-fit')
|
||||
|
||||
if (zoomIn && this._zoomInHandler) {
|
||||
zoomIn.removeEventListener('click', this._zoomInHandler)
|
||||
}
|
||||
if (zoomOut && this._zoomOutHandler) {
|
||||
zoomOut.removeEventListener('click', this._zoomOutHandler)
|
||||
}
|
||||
if (fit && this._fitHandler) {
|
||||
fit.removeEventListener('click', this._fitHandler)
|
||||
}
|
||||
|
||||
this._zoomInHandler = null
|
||||
this._zoomOutHandler = null
|
||||
this._fitHandler = null
|
||||
|
||||
if (this.cy) {
|
||||
// Stop any running layout before destroying to prevent async callbacks
|
||||
// from accessing a destroyed renderer
|
||||
|
|
@ -690,30 +712,33 @@ const NetworkMap = {
|
|||
const fit = document.getElementById('cy-fit')
|
||||
|
||||
if (zoomIn) {
|
||||
zoomIn.addEventListener('click', () => {
|
||||
this._zoomInHandler = () => {
|
||||
if (!this.cy) return
|
||||
this.cy.zoom({
|
||||
level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()),
|
||||
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
||||
})
|
||||
})
|
||||
}
|
||||
zoomIn.addEventListener('click', this._zoomInHandler)
|
||||
}
|
||||
|
||||
if (zoomOut) {
|
||||
zoomOut.addEventListener('click', () => {
|
||||
this._zoomOutHandler = () => {
|
||||
if (!this.cy) return
|
||||
this.cy.zoom({
|
||||
level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()),
|
||||
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
||||
})
|
||||
})
|
||||
}
|
||||
zoomOut.addEventListener('click', this._zoomOutHandler)
|
||||
}
|
||||
|
||||
if (fit) {
|
||||
fit.addEventListener('click', () => {
|
||||
this._fitHandler = () => {
|
||||
if (!this.cy) return
|
||||
this.cy.fit(undefined, 50)
|
||||
})
|
||||
}
|
||||
fit.addEventListener('click', this._fitHandler)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1717,14 +1742,24 @@ const SitesMap = {
|
|||
</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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
315
lib/towerops_web/live/device_live/components/backups_tab.ex
Normal file
315
lib/towerops_web/live/device_live/components/backups_tab.ex
Normal 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
|
||||
292
lib/towerops_web/live/device_live/components/checks_tab.ex
Normal file
292
lib/towerops_web/live/device_live/components/checks_tab.ex
Normal 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
|
||||
331
lib/towerops_web/live/device_live/components/gaiia_tab.ex
Normal file
331
lib/towerops_web/live/device_live/components/gaiia_tab.ex
Normal 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
|
||||
1084
lib/towerops_web/live/device_live/components/overview_tab.ex
Normal file
1084
lib/towerops_web/live/device_live/components/overview_tab.ex
Normal file
File diff suppressed because it is too large
Load diff
331
lib/towerops_web/live/device_live/components/ports_tab.ex
Normal file
331
lib/towerops_web/live/device_live/components/ports_tab.ex
Normal 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
|
||||
291
lib/towerops_web/live/device_live/components/preseem_tab.ex
Normal file
291
lib/towerops_web/live/device_live/components/preseem_tab.ex
Normal 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
|
||||
195
lib/towerops_web/live/device_live/components/wireless_tab.ex
Normal file
195
lib/towerops_web/live/device_live/components/wireless_tab.ex
Normal 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
|
||||
480
lib/towerops_web/live/device_live/helpers/chart_builders.ex
Normal file
480
lib/towerops_web/live/device_live/helpers/chart_builders.ex
Normal 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
|
||||
496
lib/towerops_web/live/device_live/helpers/data_loaders.ex
Normal file
496
lib/towerops_web/live/device_live/helpers/data_loaders.ex
Normal 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
|
||||
306
lib/towerops_web/live/device_live/helpers/formatters.ex
Normal file
306
lib/towerops_web/live/device_live/helpers/formatters.ex
Normal 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
|
||||
236
lib/towerops_web/live/device_live/helpers/sensor_classifiers.ex
Normal file
236
lib/towerops_web/live/device_live/helpers/sensor_classifiers.ex
Normal 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
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue