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
1085 lines
52 KiB
Elixir
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
|