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"""
{t("Device Information")}
<%= if @snmp_device do %>
-
System Name
-
{@snmp_device.sys_name || @device.name}
-
{t("Resolved IP")}
-
{@device.ip_address}
<.icon
name="hero-clipboard-document"
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/rip:opacity-100 transition-opacity text-gray-400"
/>
- Hardware
-
{@snmp_device.model || "N/A"}
-
Operating System
-
{cond do
@snmp_device.manufacturer && @snmp_device.firmware_version ->
"#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}"
@snmp_device.manufacturer ->
@snmp_device.manufacturer
true ->
@snmp_device.sys_descr || "N/A"
end}
<%= if firmware_update_available?(@snmp_device, @available_firmware) do %>
<.icon
name="hero-arrow-up-circle"
class="h-5 w-5 text-blue-500 cursor-help dark:text-blue-400"
/>
<.icon
name="hero-arrow-up-circle"
class="h-5 w-5 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
{t("Firmware Update Available")}
Current:
{@snmp_device.firmware_version}
Latest:
{@available_firmware.version}
<%= if @available_firmware.release_date do %>
(released {format_date(@available_firmware.release_date)})
<% end %>
<% end %>
-
Serial Number
-
{@snmp_device.serial_number}
- Object ID
-
{@snmp_device.sys_object_id || "N/A"}
- Contact
-
{@snmp_device.sys_contact || "N/A"}
- Location
-
{format_location(@snmp_device.sys_location)}
- Uptime
-
{format_uptime(@snmp_device.sys_uptime)}
<% else %>
- Name
-
{@device.name}
-
IP Address
-
{@device.ip_address}
<% end %>
-
Device Added
-
{format_device_age(@device.inserted_at)}
-
Last Discovered
-
{if @device.last_discovery_at do
format_device_age(@device.last_discovery_at)
else
"Never"
end}
- Last Polled
-
{if @device.last_snmp_poll_at do
format_device_age(@device.last_snmp_poll_at)
else
"Never"
end}
<%= if @traffic_chart_data do %>
<.link
navigate={~p"/devices/#{@device.id}/graph/traffic"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("Overall Traffic")}
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
<% end %>
<%= if @latency_chart_data do %>
<.link
navigate={~p"/devices/#{@device.id}/graph/latency"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("ICMP Latency")}
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
<% end %>
<%!-- Recent Config Changes mini-card --%>
<%= if false && @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) do %>
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" />
{t("Recent Config Changes")}
<.link
navigate={~p"/devices/#{@device.id}/config-timeline"}
class="text-xs text-blue-500 hover:underline"
>
View Timeline →
<%= for event <- Enum.take(@recent_config_changes, 5) do %>
{Calendar.strftime(event.changed_at, "%b %d %H:%M")}
{s}
{event.change_size} lines
<% end %>
<% end %>
<%= if @processor_chart_data || (@processors && length(@processors) > 0) do %>
<% processor_count = if @processors, do: length(@processors), else: 0
avg_load =
if processor_count > 0 do
loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1)
if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil
else
nil
end %>
<.link
navigate={~p"/devices/#{@device.id}/graph/processors"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<%= if processor_count > 0 do %>
{processor_count} {if processor_count == 1,
do: "Processor",
else: "Processors"}
<% else %>
{t("Processors")}
<% end %>
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
<%= if @processor_chart_data do %>
<% end %>
<%= if avg_load do %>
{t("Average Load")}
= 50 && avg_load < 80 && "bg-yellow-500",
avg_load >= 80 && "bg-red-500"
]}
style={"width: #{min(avg_load, 100)}%"}
>
{Float.round(avg_load, 0)}%
<% end %>
<% end %>
<%= if @memory_chart_data do %>
<.link
navigate={~p"/devices/#{@device.id}/graph/memory"}
class="block px-4 py-3 border-b border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("Memory Usage")}
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
<% end %>
<%= if @storage_sensors && length(@storage_sensors) > 0 do %>
{t("Storage Usage")}
<%= for sensor <- @storage_sensors do %>
-
<.link
navigate={~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit}
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<%= if @storage && length(@storage) > 0 do %>
{t("Storage Volumes")}
({length(@storage)})
<%= if @storage_volume_chart_data do %>
<% end %>
<%= for storage <- @storage do %>
<% usage_percent =
if storage.total_bytes && storage.total_bytes > 0 do
Float.round(storage.used_bytes / storage.total_bytes * 100, 1)
else
0.0
end %>
<.link
navigate={
~p"/devices/#{@device.id}/graph/storage_volume?storage_id=#{storage.id}"
}
class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1 rounded transition-colors"
>
{storage.description || "Storage #{storage.storage_index}"}
{format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)}
= 70 && usage_percent < 90 && "bg-yellow-500",
usage_percent >= 90 && "bg-red-500"
]}
style={"width: #{min(usage_percent, 100)}%"}
>
{usage_percent}%
{String.replace(storage.storage_type || "other", "_", " ")
|> String.capitalize()}
<% end %>
<% end %>
<%= if @temperature_sensors && length(@temperature_sensors) > 0 do %>
{t("Temperature")}
<%= for sensor <- @temperature_sensors do %>
-
<.link
navigate={
~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit || "°C"}
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<%= if @voltage_sensors && length(@voltage_sensors) > 0 do %>
{t("Voltage")}
<%= for sensor <- @voltage_sensors do %>
-
<.link
navigate={~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit}
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %>
{t("Transceivers")}
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
<%= for sensor <- sensors do %>
-
<.link
navigate={~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading do %>
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
{sensor.sensor_unit}
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<% end %>
<%= if (@general_counters && length(@general_counters) > 0) || (@firewall_counters && length(@firewall_counters) > 0) do %>
{t("Counters")}
<%= if @general_counters && length(@general_counters) > 0 do %>
<%= for sensor <- @general_counters do %>
-
<.link
navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
{trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
{sensor.sensor_unit}
<% end %>
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<%= if @firewall_counters && length(@firewall_counters) > 0 do %>
{t("Firewall")}
<%= for sensor <- @firewall_counters do %>
-
<.link
navigate={
~p"/devices/#{@device.id}/graph/#{sensor.sensor_type}?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
{trunc(sensor.latest_reading.value)}
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
{sensor.sensor_unit}
<% end %>
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<% end %>
<%= if @wireless_sensors && length(@wireless_sensors) > 0 do %>
{t("Wireless")}
<%= for sensor <- @wireless_sensors do %>
-
<.link
navigate={~p"/devices/#{@device.id}/graph/wireless?sensor_id=#{sensor.id}"}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
-
<%= if sensor.latest_reading && sensor.latest_reading.value do %>
<%= if sensor.sensor_type in ["clients", "ccq", "utilization"] do %>
{trunc(sensor.latest_reading.value)}
<% else %>
{:erlang.float_to_binary(sensor.latest_reading.value * 1.0,
decimals: 1
)}
<% end %>
<%= if sensor.sensor_unit && sensor.sensor_unit != "" do %>
{sensor.sensor_unit}
<% end %>
<% else %>
{t("N/A")}
<% end %>
<% end %>
<% end %>
<%!-- Connected Devices --%>
<%= if @connected_devices != [] do %>
{t("Connected Devices")}
({length(@connected_devices)} links)
| Device |
IP Address |
Interface |
Link Type |
Confidence |
Status |
<%= 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 %>
|
<%= if peer_device do %>
<.link
navigate={~p"/devices/#{peer_device.id}"}
class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{peer_device.name}
<% else %>
{link.discovered_remote_name || link.discovered_remote_mac ||
"Unknown"}
<% end %>
|
<%= if peer_device do %>
{peer_device.ip_address}
<% else %>
{link.discovered_remote_ip || "-"}
<% end %>
|
<%= if link.source_interface do %>
{link.source_interface.if_name}
<% else %>
-
<% end %>
|
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
link.link_type == "mac_match" ->
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
link.link_type == "arp_inference" ->
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
true ->
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
end
]}>
{link.link_type}
|
= 0.9 -> "bg-green-500"
link.confidence >= 0.7 -> "bg-blue-500"
link.confidence >= 0.5 -> "bg-yellow-500"
true -> "bg-red-500"
end
]}
style={"width: #{trunc(link.confidence * 100)}%"}
>
{trunc(link.confidence * 100)}%
|
<%= if peer_device do %>
{t("Managed")}
<% else %>
{t("Discovered")}
<% end %>
|
<% end %>
<% end %>
"""
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