towerops/lib/towerops_web/live/device_live/components/overview_tab.ex
Graham McIntire efaf5558ff refactor: convert 6 Gleam modules to idiomatic Elixir with TDD (#196)
Phase 1: Foundation Types (100% complete)
- query_helpers: SQL LIKE sanitization with pipe operators
- numeric: Integer parsing with pattern matching guards
- result: Pure Elixir Result monad (map, and_then, unwrap_or)

Phase 2: Ecto Domain Types (100% complete)
- ip_address: IPv4/IPv6 validation using :inet directly
- mac_address: Multi-format MAC parsing (colon/hyphen/dot/compact)
- snmp_oid: OID parsing/manipulation with recursive pattern matching

All 198 tests passing across converted modules.
API changed from Gleam-style {:some/:none to idiomatic {:ok/:error.
Refactored parse_numeric_oid to use with statement, reducing nesting depth.

Reviewed-on: graham/towerops-web#196
2026-03-28 09:52:07 -05:00

1085 lines
52 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Components.OverviewTab do
@moduledoc """
LiveComponent for the device overview tab.
Displays device metrics, charts (latency, CPU, memory, storage, traffic),
and categorized sensors (temperature, voltage, power, etc.).
"""
use ToweropsWeb, :live_component
alias Towerops.Devices.VersionComparator
alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
alias ToweropsWeb.DeviceLive.Helpers.Formatters
alias ToweropsWeb.DeviceLive.Helpers.SensorClassifiers
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> load_overview_data()}
end
defp load_overview_data(socket) do
device = socket.assigns.device
device_id = device.id
snmp_device = socket.assigns.snmp_device
sensors = socket.assigns.snmp_sensors
# Load processors and storage
processors = DataLoaders.load_processors(snmp_device)
storage = DataLoaders.load_storage(snmp_device)
# Build chart data
latency_chart_data = ChartBuilders.load_latency_chart_data(device_id)
processor_chart_data = ChartBuilders.load_processor_chart_data(processors)
memory_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["memory_usage"])
storage_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["disk_usage"])
storage_volume_chart_data = ChartBuilders.load_storage_volume_chart_data(storage)
traffic_chart_data = ChartBuilders.load_overall_traffic_chart_data(snmp_device)
wireless_chart_data =
ChartBuilders.load_sensor_chart_data(snmp_device, [
"frequency",
"power",
"rssi",
"ccq",
"clients",
"distance",
"noise-floor",
"quality",
"rate",
"utilization"
])
# Classify sensors by type
{transceiver_sensors, non_transceiver_sensors} =
SensorClassifiers.split_transceiver_sensors(sensors)
temperature_sensors =
Enum.filter(non_transceiver_sensors, &SensorClassifiers.temperature_sensor?/1)
voltage_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.voltage_sensor?/1)
current_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.current_sensor?/1)
power_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.power_sensor?/1)
fan_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.fan_sensor?/1)
load_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.load_sensor?/1)
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
count_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.counter_sensor?/1)
{firewall_counters, general_counters} =
Enum.split_with(count_sensors, &SensorClassifiers.firewall_counter?/1)
wireless_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.wireless_sensor?/1)
signal_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.signal_sensor?/1)
grouped_transceivers = SensorClassifiers.group_transceiver_sensors(transceiver_sensors)
metrics = DataLoaders.calculate_metrics([], device)
available_firmware = DataLoaders.get_available_firmware(snmp_device)
connected_devices = DataLoaders.load_connected_devices(device_id)
recent_config_changes = DataLoaders.load_recent_config_changes(device)
socket
|> assign(:metrics, metrics)
|> assign(:processors, processors)
|> assign(:storage, storage)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:processor_chart_data, processor_chart_data)
|> assign(:memory_chart_data, memory_chart_data)
|> assign(:storage_chart_data, storage_chart_data)
|> assign(:storage_volume_chart_data, storage_volume_chart_data)
|> assign(:traffic_chart_data, traffic_chart_data)
|> assign(:wireless_chart_data, wireless_chart_data)
|> assign(:temperature_sensors, temperature_sensors)
|> assign(:voltage_sensors, voltage_sensors)
|> assign(:current_sensors, current_sensors)
|> assign(:power_sensors, power_sensors)
|> assign(:fan_sensors, fan_sensors)
|> assign(:load_sensors, load_sensors)
|> assign(:storage_sensors, storage_sensors)
|> assign(:count_sensors, count_sensors)
|> assign(:general_counters, general_counters)
|> assign(:firewall_counters, firewall_counters)
|> assign(:wireless_sensors, wireless_sensors)
|> assign(:signal_sensors, signal_sensors)
|> assign(:grouped_transceivers, grouped_transceivers)
|> assign(:available_firmware, available_firmware)
|> assign(:connected_devices, connected_devices)
|> assign(:recent_config_changes, recent_config_changes)
end
@impl true
def render(assigns) do
~H"""
<div id="overview-tab">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Left Column: Device Information & Traffic -->
<div class="space-y-6">
<!-- Device Information -->
<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("Device Information")}
</h3>
</div>
<div class="p-4">
<dl class="space-y-1">
<%= if @snmp_device do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
System Name
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_name || @device.name}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
{t("Resolved IP")}
</dt>
<dd
class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono group/rip cursor-pointer"
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
title={t("Click to copy")}
>
{@device.ip_address}
<.icon
name="hero-clipboard-document"
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/rip:opacity-100 transition-opacity text-gray-400"
/>
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Hardware</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.model || "N/A"}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
Operating System
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<div class="flex items-center justify-end gap-2">
<span>
{cond do
@snmp_device.manufacturer && @snmp_device.firmware_version ->
"#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}"
@snmp_device.manufacturer ->
@snmp_device.manufacturer
true ->
@snmp_device.sys_descr || "N/A"
end}
</span>
<%= if firmware_update_available?(@snmp_device, @available_firmware) do %>
<div class="group/firmware relative inline-flex items-center">
<.icon
name="hero-arrow-up-circle"
class="h-5 w-5 text-blue-500 cursor-help dark:text-blue-400"
/>
<!-- Invisible bridge to prevent gap -->
<div class="absolute left-0 top-0 w-full h-8 z-40"></div>
<!-- Tooltip popup -->
<div class="invisible group-hover/firmware:visible hover:visible absolute left-0 top-7 z-50 w-80 rounded-lg bg-white dark:bg-gray-800 shadow-xl ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-10 p-4">
<div class="flex items-start gap-3">
<.icon
name="hero-arrow-up-circle"
class="h-5 w-5 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
{t("Firmware Update Available")}
</h4>
<div class="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<div>
<span class="font-medium">Current:</span>
<span class="font-mono ml-1">
{@snmp_device.firmware_version}
</span>
</div>
<div>
<span class="font-medium">Latest:</span>
<span class="font-mono ml-1 text-blue-600 dark:text-blue-400">
{@available_firmware.version}
</span>
<%= if @available_firmware.release_date do %>
<span class="text-gray-500 dark:text-gray-400 dark:text-gray-500 dark:text-gray-400">
(released {format_date(@available_firmware.release_date)})
</span>
<% end %>
</div>
</div>
<div class="mt-3 flex gap-2">
<a
href={@available_firmware.download_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium bg-blue-600 text-white hover:bg-blue-700"
>
{t("Download")}
<.icon
name="hero-arrow-top-right-on-square"
class="h-3 w-3"
/>
</a>
<%= if @available_firmware.changelog_url do %>
<a
href={@available_firmware.changelog_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20"
>
{t("Release Notes")}
<.icon
name="hero-arrow-top-right-on-square"
class="h-3 w-3"
/>
</a>
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
</dd>
</div>
<div
:if={@snmp_device.serial_number}
class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"
>
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
Serial Number
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
{@snmp_device.serial_number}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Object ID</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
{@snmp_device.sys_object_id || "N/A"}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Contact</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@snmp_device.sys_contact || "N/A"}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Location</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{format_location(@snmp_device.sys_location)}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Uptime</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{format_uptime(@snmp_device.sys_uptime)}
</dd>
</div>
<% else %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Name</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{@device.name}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
IP Address
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
{@device.ip_address}
</dd>
</div>
<% end %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
Device Added
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{format_device_age(@device.inserted_at)}
</dd>
</div>
<div class="flex justify-between py-1.5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
Last Discovered
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{if @device.last_discovery_at do
format_device_age(@device.last_discovery_at)
else
"Never"
end}
</dd>
</div>
<div class="flex justify-between py-1.5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">Last Polled</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
{if @device.last_snmp_poll_at do
format_device_age(@device.last_snmp_poll_at)
else
"Never"
end}
</dd>
</div>
</dl>
</div>
</div>
<!-- Overall Traffic Chart -->
<%= if @traffic_chart_data do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.link
navigate={~p"/devices/#{@device.id}/graph/traffic"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Overall Traffic")}
</h3>
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
</div>
</.link>
<div class="p-4">
<div
id="traffic-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@traffic_chart_data}
data-unit="bps"
data-auto-scale="true"
data-show-zero-line="true"
class="relative"
style="height: 300px;"
>
<div class="absolute inset-0 flex items-center justify-center" data-loading>
<div class="flex flex-col items-center gap-2">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- ICMP Latency Chart -->
<%= if @latency_chart_data do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.link
navigate={~p"/devices/#{@device.id}/graph/latency"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("ICMP Latency")}
</h3>
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
</div>
</.link>
<div class="p-4">
<div
id="latency-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@latency_chart_data}
data-unit="ms"
data-auto-scale="true"
class="relative"
style="height: 300px;"
>
<div class="absolute inset-0 flex items-center justify-center" data-loading>
<div class="flex flex-col items-center gap-2">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<%!-- Recent Config Changes mini-card --%>
<%= if false && @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" />
{t("Recent Config Changes")}
</h3>
<.link
navigate={~p"/devices/#{@device.id}/config-timeline"}
class="text-xs text-blue-500 hover:underline"
>
View Timeline →
</.link>
</div>
<div class="p-3 space-y-2">
<%= for event <- Enum.take(@recent_config_changes, 5) do %>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class={"w-2 h-2 rounded-full #{config_change_dot_color(event)}"} />
<span class="text-gray-600 dark:text-gray-400">
{Calendar.strftime(event.changed_at, "%b %d %H:%M")}
</span>
<span
:for={s <- Enum.take(event.sections_changed, 3)}
class="badge badge-xs badge-ghost"
>
{s}
</span>
</div>
<span class="text-gray-500 dark:text-gray-400 text-xs">
{event.change_size} lines
</span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Right Column: Graphs and Metrics -->
<div class="space-y-6">
<!-- CPU / Processors -->
<%= if @processor_chart_data || (@processors && length(@processors) > 0) do %>
<% processor_count = if @processors, do: length(@processors), else: 0
avg_load =
if processor_count > 0 do
loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1)
if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil
else
nil
end %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.link
navigate={~p"/devices/#{@device.id}/graph/processors"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
<%= if processor_count > 0 do %>
{processor_count} {if processor_count == 1,
do: "Processor",
else: "Processors"}
<% else %>
{t("Processors")}
<% end %>
</h3>
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
</div>
</.link>
<!-- Show chart if we have time-series data -->
<%= if @processor_chart_data do %>
<div class="p-4">
<div
id="processor-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@processor_chart_data}
data-unit="%"
style="height: 200px;"
>
<canvas></canvas>
</div>
</div>
<% end %>
<!-- Show combined processor load -->
<%= if avg_load do %>
<div class={[
"p-4",
@processor_chart_data && "border-t border-gray-200 dark:border-white/10"
]}>
<div class="flex items-center justify-between gap-4">
<span class="text-sm text-gray-700 dark:text-gray-300">
{t("Average Load")}
</span>
<div class="flex items-center gap-3 flex-1 max-w-md">
<div class="flex-1 bg-gray-200 rounded-full h-3 dark:bg-gray-700">
<div
class={[
"h-3 rounded-full transition-all",
avg_load < 50 && "bg-green-500",
avg_load >= 50 && avg_load < 80 && "bg-yellow-500",
avg_load >= 80 && "bg-red-500"
]}
style={"width: #{min(avg_load, 100)}%"}
>
</div>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white w-12 text-right">
{Float.round(avg_load, 0)}%
</span>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
<!-- Memory Usage Chart -->
<%= if @memory_chart_data do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.link
navigate={~p"/devices/#{@device.id}/graph/memory"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Memory Usage")}
</h3>
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
</div>
</.link>
<div class="p-4">
<div
id="memory-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@memory_chart_data}
style="height: 300px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- Storage Usage -->
<%= if @storage_sensors && length(@storage_sensors) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Storage Usage")}
</h3>
</div>
<div class="p-4">
<dl class="space-y-1">
<%= for sensor <- @storage_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit}
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
<!-- Physical Storage Volumes (from HOST-RESOURCES-MIB) -->
<%= if @storage && length(@storage) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Storage Volumes")}
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@storage)})
</span>
</h3>
</div>
<!-- Storage Volume Chart -->
<%= if @storage_volume_chart_data do %>
<div class="p-4 border-b border-gray-200 dark:border-white/10">
<div
id="storage-volume-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@storage_volume_chart_data}
data-unit="%"
style="height: 200px;"
>
<canvas></canvas>
</div>
</div>
<% end %>
<div class="p-4 space-y-4">
<%= for storage <- @storage do %>
<% usage_percent =
if storage.total_bytes && storage.total_bytes > 0 do
Float.round(storage.used_bytes / storage.total_bytes * 100, 1)
else
0.0
end %>
<.link
navigate={
~p"/devices/#{@device.id}/graph/storage_volume?storage_id=#{storage.id}"
}
class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1 rounded transition-colors"
>
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:underline">
{storage.description || "Storage #{storage.storage_index}"}
</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)}
</span>
</div>
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div
class={[
"h-2 rounded-full transition-all",
usage_percent < 70 && "bg-green-500",
usage_percent >= 70 && usage_percent < 90 && "bg-yellow-500",
usage_percent >= 90 && "bg-red-500"
]}
style={"width: #{min(usage_percent, 100)}%"}
>
</div>
</div>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
{usage_percent}%
</span>
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{String.replace(storage.storage_type || "other", "_", " ")
|> String.capitalize()}
</div>
</.link>
<% end %>
</div>
</div>
<% end %>
<!-- Temperature Sensors -->
<%= if @temperature_sensors && length(@temperature_sensors) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Temperature")}
</h3>
</div>
<div class="p-4">
<dl class="space-y-1">
<%= for sensor <- @temperature_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={
~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit || "°C"}
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
<!-- Voltage Sensors -->
<%= if @voltage_sensors && length(@voltage_sensors) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Voltage")}
</h3>
</div>
<div class="p-4">
<dl class="space-y-1">
<%= for sensor <- @voltage_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit}
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
<!-- SFP Transceivers -->
<%= if @grouped_transceivers && length(@grouped_transceivers) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Transceivers")}
</h3>
</div>
<div class="p-4">
<dl class="space-y-1">
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
<%= for sensor <- sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white font-mono">
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit}
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
<% end %>
</dl>
</div>
</div>
<% end %>
<!-- Count Sensors (DHCP Leases, Connections, etc.) -->
<%= if (@general_counters && length(@general_counters) > 0) || (@firewall_counters && length(@firewall_counters) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Counters")}
</h3>
</div>
<div class="p-4 space-y-4">
<!-- General Counters -->
<%= if @general_counters && length(@general_counters) > 0 do %>
<dl class="space-y-1">
<%= for sensor <- @general_counters do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
{trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
{sensor.sensor_unit}
<% end %>
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
</dl>
<% end %>
<!-- Firewall Counters -->
<%= if @firewall_counters && length(@firewall_counters) > 0 do %>
<div>
<h4 class="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wider">
{t("Firewall")}
</h4>
<dl class="space-y-1">
<%= for sensor <- @firewall_counters do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
{trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
{sensor.sensor_unit}
<% end %>
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Wireless Sensors (Frequency, Power, CCQ, etc.) -->
<%= if @wireless_sensors && length(@wireless_sensors) > 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">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Wireless")}
</h3>
</div>
<div class="p-4">
<dl class="space-y-1">
<%= for sensor <- @wireless_sensors do %>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="shrink-0 text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
</.link>
</dt>
<dd class="flex-1 text-right ml-4 text-sm font-medium text-gray-900 dark:text-white">
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
<%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %>
{trunc(sensor.latest_reading.value)}
<% else %>
{:erlang.float_to_binary(sensor.latest_reading.value * 1.0,
decimals: 1
)}
<% end %>
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
{sensor.sensor_unit}
<% end %>
<% else %>
{t("N/A")}
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Connected Devices --%>
<%= if @connected_devices != [] do %>
<div class="mt-6 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("Connected Devices")}
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@connected_devices)} links)
</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-4 py-3 text-left font-medium">Device</th>
<th class="px-4 py-3 text-left font-medium">IP Address</th>
<th class="px-4 py-3 text-left font-medium">Interface</th>
<th class="px-4 py-3 text-left font-medium">Link Type</th>
<th class="px-4 py-3 text-left font-medium">Confidence</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for link <- @connected_devices do %>
<% peer_device =
cond do
link.target_device && link.target_device.id != @device.id ->
link.target_device
link.source_device && link.source_device.id != @device.id ->
link.source_device
link.target_device ->
link.target_device
true ->
nil
end %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3">
<%= if peer_device do %>
<.link
navigate={~p"/devices/#{peer_device.id}"}
class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{peer_device.name}
</.link>
<% else %>
<div class="text-gray-900 dark:text-white">
{link.discovered_remote_name || link.discovered_remote_mac ||
"Unknown"}
</div>
<% end %>
</td>
<td class="px-4 py-3 font-mono text-sm text-gray-900 dark:text-white">
<%= if peer_device do %>
{peer_device.ip_address}
<% else %>
{link.discovered_remote_ip || "-"}
<% end %>
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
<%= if link.source_interface do %>
{link.source_interface.if_name}
<% else %>
-
<% end %>
</td>
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase",
cond do
link.link_type in ["lldp", "cdp"] ->
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
link.link_type == "mac_match" ->
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
link.link_type == "arp_inference" ->
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
true ->
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
end
]}>
{link.link_type}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
class={[
"h-1.5 rounded-full",
cond do
link.confidence >= 0.9 -> "bg-green-500"
link.confidence >= 0.7 -> "bg-blue-500"
link.confidence >= 0.5 -> "bg-yellow-500"
true -> "bg-red-500"
end
]}
style={"width: #{trunc(link.confidence * 100)}%"}
>
</div>
</div>
<span class="text-xs text-gray-600 dark:text-gray-400">
{trunc(link.confidence * 100)}%
</span>
</div>
</td>
<td class="px-4 py-3">
<%= if peer_device do %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{t("Managed")}
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{t("Discovered")}
</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
"""
end
# Helper functions - delegate to Formatters or implement locally
defp format_date(date), do: Formatters.format_date(date)
defp format_sensor_value(value, divisor), do: Formatters.format_sensor_value(value, divisor)
defp format_location(location), do: Formatters.format_location(location)
defp format_uptime(timeticks), do: Formatters.format_uptime(timeticks)
defp format_device_age(datetime), do: Formatters.format_device_age(datetime)
defp format_bytes(bytes), do: Formatters.format_bytes(bytes)
defp config_change_dot_color(event) do
cond do
event.change_size > 50 -> "bg-red-500"
event.change_size > 20 -> "bg-yellow-500"
true -> "bg-green-500"
end
end
defp firmware_update_available?(nil, _), do: false
defp firmware_update_available?(_, nil), do: false
defp firmware_update_available?(snmp_device, available_firmware) do
current = snmp_device.firmware_version
available = available_firmware.version
# Extract clean version numbers for comparison
current_clean = extract_version_number(current)
available_clean = extract_version_number(available)
current_clean && available_clean &&
VersionComparator.newer(current_clean, available_clean)
end
defp extract_version_number(nil), do: nil
defp extract_version_number(""), do: nil
defp extract_version_number(version_string) when is_binary(version_string) do
# Match semantic version pattern: X.Y or X.Y.Z
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
[version] -> version
_ -> nil
end
end
end