devices assigned directly to an org (no site) were excluded from apply_agent_to_all_equipment due to inner join through site table. also fix fallback agent resolution to check device.organization when device.site is nil.
1303 lines
40 KiB
Elixir
1303 lines
40 KiB
Elixir
defmodule ToweropsWeb.DeviceLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Agents
|
|
alias Towerops.Devices
|
|
alias Towerops.Devices.Firmware
|
|
alias Towerops.Devices.MikrotikBackups
|
|
alias Towerops.Devices.VersionComparator
|
|
alias Towerops.Monitoring
|
|
alias Towerops.Repo
|
|
alias Towerops.Snmp
|
|
alias ToweropsWeb.Live.Helpers.AccessControl
|
|
|
|
# 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 mount(_params, _session, socket) do
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(%{"id" => id} = params, _, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Check if device exists and verify organization access
|
|
case AccessControl.verify_device_access(id, organization.id) do
|
|
{:ok, _device} ->
|
|
maybe_subscribe_and_schedule_refresh(socket, id)
|
|
|
|
# If no tab parameter, redirect to add it to the URL
|
|
if Map.has_key?(params, "tab") do
|
|
tab = params["tab"]
|
|
page = params |> Map.get("page", "1") |> String.to_integer()
|
|
|
|
socket =
|
|
socket
|
|
|> load_equipment_data(id)
|
|
|> assign(:active_tab, tab)
|
|
|> apply_backups_pagination(tab, page)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
# Redirect to add default tab to URL
|
|
{:noreply, push_patch(socket, to: ~p"/devices/#{id}?tab=overview")}
|
|
end
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("Device not found"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
|
|
{:error, :unauthorized} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("You don't have access to this device"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:refresh_data, socket) do
|
|
# Check if device still exists before scheduling next refresh
|
|
case Devices.get_device(socket.assigns.device.id) do
|
|
nil ->
|
|
# Device was deleted, don't schedule another refresh
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("Device no longer exists"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
|
|
_device ->
|
|
Process.send_after(self(), :refresh_data, 10_000)
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:discovery_completed, _device_id}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> load_equipment_data(socket.assigns.device.id)
|
|
|> put_flash(:info, t_equipment("Discovery completed"))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:sensors_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:interfaces_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:neighbors_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:arp_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:mac_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:state_sensors_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:processors_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:storage_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:monitoring_check_updated, _device_id}, socket) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:device_event, _event_attrs}, socket) do
|
|
# Device event logged (interface changes, sensor thresholds, etc.)
|
|
# Reload to update events list
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do
|
|
# Reload if this device's agent just connected
|
|
if socket.assigns.agent_info.agent_token_id == agent_token_id do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do
|
|
# Reload if this device's agent just disconnected
|
|
if socket.assigns.agent_info.agent_token_id == agent_token_id do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do
|
|
# Reload if this device's agent sent a heartbeat (important for stale agents coming back)
|
|
if socket.assigns.agent_info.agent_token_id == agent_token_id do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agents_stale, stale_agents}, socket) do
|
|
# Reload if this device's agent went stale
|
|
device_agent_id = socket.assigns.agent_info.agent_token_id
|
|
|
|
if device_agent_id && Enum.any?(stale_agents, &(&1.id == device_agent_id)) do
|
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
|
|
_ =
|
|
if connected?(socket) do
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
|
Process.send_after(self(), :refresh_data, 10_000)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp load_equipment_data(socket, device_id) do
|
|
device = Devices.get_device!(device_id)
|
|
device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
|
|
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
|
|
agent_info = load_agent_info(agent_token_id, agent_source)
|
|
recent_checks = Monitoring.list_devices_checks(device_id, 50)
|
|
snmp_data = load_snmp_data(device_id)
|
|
events = Devices.list_devices_events(device_id, 100)
|
|
neighbors = Snmp.list_neighbors_with_equipment(device_id)
|
|
arp_entries = Snmp.list_arp_entries(device_id)
|
|
mac_addresses = Snmp.list_mac_addresses(device_id)
|
|
vlans = load_vlans(snmp_data.device)
|
|
ip_addresses = load_ip_addresses(snmp_data.device)
|
|
processors = load_processors(snmp_data.device)
|
|
storage = load_storage(snmp_data.device)
|
|
latency_chart_data = load_latency_chart_data(device_id)
|
|
processor_chart_data = load_processor_chart_data(processors)
|
|
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
|
|
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
|
storage_volume_chart_data = load_storage_volume_chart_data(storage)
|
|
traffic_chart_data = load_overall_traffic_chart_data(device_id)
|
|
|
|
# Wireless sensor chart data
|
|
wireless_chart_data =
|
|
load_sensor_chart_data(device_id, [
|
|
"frequency",
|
|
"power",
|
|
"rssi",
|
|
"ccq",
|
|
"clients",
|
|
"distance",
|
|
"noise-floor",
|
|
"quality",
|
|
"rate",
|
|
"utilization"
|
|
])
|
|
|
|
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
|
|
# Separate transceiver sensors from general sensors
|
|
{transceiver_sensors, non_transceiver_sensors} =
|
|
Enum.split_with(snmp_data.sensors, fn sensor ->
|
|
sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp")
|
|
end)
|
|
|
|
temperature_sensors =
|
|
Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
|
|
|
|
voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1)
|
|
|
|
current_sensors = Enum.filter(non_transceiver_sensors, ¤t_sensor?/1)
|
|
|
|
power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1)
|
|
|
|
fan_sensors = Enum.filter(non_transceiver_sensors, &fan_sensor?/1)
|
|
|
|
load_sensors = Enum.filter(non_transceiver_sensors, &load_sensor?/1)
|
|
|
|
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
|
|
|
|
# Include various counter/gauge sensor types in the Counters section
|
|
count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1)
|
|
|
|
# Split count sensors into firewall and general
|
|
{firewall_counters, general_counters} =
|
|
Enum.split_with(count_sensors, &firewall_counter?/1)
|
|
|
|
# Wireless sensors (frequency, power, rssi, ccq, etc.)
|
|
wireless_sensors = Enum.filter(non_transceiver_sensors, &wireless_sensor?/1)
|
|
|
|
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
|
|
signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1)
|
|
|
|
# Group transceiver sensors by transceiver name
|
|
grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
|
|
|
|
# Calculate metrics for dashboard
|
|
metrics = calculate_metrics(recent_checks, device)
|
|
|
|
# Group interfaces by type for the ports tab
|
|
interfaces_by_type = group_interfaces_by_type(snmp_data.interfaces)
|
|
|
|
# Check for available firmware updates
|
|
available_firmware = get_available_firmware(snmp_data.device)
|
|
|
|
# Load MikroTik backups if device has MikroTik API enabled
|
|
mikrotik_backups =
|
|
if device.mikrotik_enabled do
|
|
MikrotikBackups.list_device_backups(device_id, limit: 50)
|
|
else
|
|
[]
|
|
end
|
|
|
|
socket
|
|
|> assign(:page_title, device.name)
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
|
|> assign(:device, device)
|
|
|> assign(:recent_checks, recent_checks)
|
|
|> assign(:metrics, metrics)
|
|
|> assign(:snmp_device, snmp_data.device)
|
|
|> assign(:snmp_interfaces, snmp_data.interfaces)
|
|
|> assign(:interfaces_by_type, interfaces_by_type)
|
|
|> assign(:snmp_sensors, snmp_data.sensors)
|
|
|> assign(:neighbors, neighbors)
|
|
|> assign(:arp_entries, arp_entries)
|
|
|> assign(:mac_addresses, mac_addresses)
|
|
|> assign(:vlans, vlans)
|
|
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|
|
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|
|
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
|
|
|> assign(:processors, processors)
|
|
|> assign(:storage, storage)
|
|
|> assign(:events, events)
|
|
|> 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(:agent_info, agent_info)
|
|
|> assign(:available_firmware, available_firmware)
|
|
|> assign(:mikrotik_backups, mikrotik_backups)
|
|
|> assign(:selected_backup_ids, MapSet.new())
|
|
end
|
|
|
|
defp get_available_firmware(nil), do: nil
|
|
|
|
defp 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
|
|
|
|
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
|
|
# "RouterOS 7.20.6 (Level 6)" -> "7.20.6"
|
|
# "7.21.2" -> "7.21.2"
|
|
current_clean = extract_version_number(current)
|
|
available_clean = extract_version_number(available)
|
|
|
|
current_clean && available_clean && VersionComparator.newer?(current_clean, available_clean)
|
|
end
|
|
|
|
# Extract semantic version number from version string
|
|
# Examples:
|
|
# "RouterOS 7.20.6 (Level 6)" -> "7.20.6"
|
|
# "7.21.2" -> "7.21.2"
|
|
# "v7.14.1" -> "7.14.1"
|
|
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
|
|
|
|
defp format_date(nil), do: ""
|
|
|
|
defp format_date(date) do
|
|
Calendar.strftime(date, "%B %-d, %Y")
|
|
end
|
|
|
|
defp 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
|
|
|
|
defp 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
|
|
|
|
defp 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
|
|
|
|
defp 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
|
|
|
|
defp format_speed(_), do: "-"
|
|
|
|
defp format_bytes(nil), do: "-"
|
|
defp format_bytes(0), do: "0 B"
|
|
|
|
defp 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
|
|
|
|
defp format_bytes(_), do: "-"
|
|
|
|
defp format_sensor_value(value, _divisor) when is_number(value) do
|
|
# Value is already divided by divisor when stored in sensor_reading
|
|
Float.round(value, 1)
|
|
end
|
|
|
|
defp format_sensor_value(_, _), do: "-"
|
|
|
|
defp time_ago(nil), do: "Never"
|
|
|
|
defp 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
|
|
|
|
defp format_uptime(nil), do: "N/A"
|
|
|
|
defp format_uptime(timeticks) when is_integer(timeticks) do
|
|
# Timeticks are in hundredths of a second
|
|
seconds = div(timeticks, 100)
|
|
format_duration(seconds)
|
|
end
|
|
|
|
defp format_uptime(_), do: "N/A"
|
|
|
|
defp 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"),
|
|
format_time_part(seconds, "second")
|
|
],
|
|
&is_nil/1
|
|
)
|
|
|
|
case parts do
|
|
[] -> "0 seconds"
|
|
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"
|
|
|
|
defp format_device_age(nil), do: "N/A"
|
|
|
|
defp format_device_age(datetime) do
|
|
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
|
format_duration(diff) <> " ago"
|
|
end
|
|
|
|
defp format_relative_time(nil), do: "never"
|
|
|
|
defp format_relative_time(datetime) do
|
|
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
|
|
|
cond do
|
|
diff < 60 -> "just now"
|
|
diff < 3600 -> "#{div(diff, 60)} min ago"
|
|
diff < 86_400 -> "#{div(diff, 3600)} hours ago"
|
|
true -> format_duration(diff) <> " ago"
|
|
end
|
|
end
|
|
|
|
defp format_event_type(event_type) do
|
|
event_type
|
|
|> String.replace("_", " ")
|
|
|> String.capitalize()
|
|
end
|
|
|
|
defp 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
|
|
|
|
defp load_vlans(nil), do: []
|
|
defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id)
|
|
|
|
defp load_ip_addresses(nil), do: []
|
|
defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id)
|
|
|
|
defp load_processors(nil), do: []
|
|
defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id)
|
|
|
|
defp load_storage(nil), do: []
|
|
defp load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id)
|
|
|
|
defp load_processor_chart_data([]), do: nil
|
|
|
|
defp 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
|
|
Jason.encode!(%{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
|
|
|
|
defp load_storage_volume_chart_data([]), do: nil
|
|
|
|
defp 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
|
|
Jason.encode!(%{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
|
|
|
|
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
|
|
case Snmp.get_device_with_associations(device_id) do
|
|
nil -> nil
|
|
device -> build_sensor_datasets_json(device, sensor_types)
|
|
end
|
|
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))
|
|
Jason.encode!(%{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
|
|
|
|
defp 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)
|
|
}
|
|
|
|
Jason.encode!(%{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
|
|
|
|
defp load_overall_traffic_chart_data(device_id) do
|
|
case Snmp.get_device_with_associations(device_id) do
|
|
nil -> nil
|
|
device -> build_overall_traffic_chart_json(device)
|
|
end
|
|
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)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Calculate BPS per interface first, then aggregate by timestamp
|
|
# This avoids issues with misaligned timestamps between interfaces
|
|
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
|
|
max((s2.if_in_octets - s1.if_in_octets) * 8 / time_diff, 0)
|
|
else
|
|
0.0
|
|
end
|
|
|
|
out_bps =
|
|
if s2.if_out_octets && s1.if_out_octets do
|
|
max((s2.if_out_octets - s1.if_out_octets) * 8 / time_diff, 0)
|
|
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 build_traffic_json_datasets(all_stats) 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}
|
|
]
|
|
|
|
Jason.encode!(%{datasets: datasets})
|
|
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
|
|
|
|
# Group transceiver sensors by transceiver name
|
|
# Returns list of {name, sensors} tuples sorted by name
|
|
defp 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
|
|
|
|
# Group IP addresses by interface ID for efficient lookup in templates
|
|
defp group_ip_addresses_by_interface(ip_addresses) do
|
|
Enum.group_by(ip_addresses, & &1.snmp_interface_id)
|
|
end
|
|
|
|
# Sensor type classification helpers
|
|
# Trust sensor_type first (set during SNMP discovery based on OID)
|
|
# Only fall back to unit/description matching if sensor_type is missing/unknown
|
|
defp 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
|
|
|
|
defp 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
|
|
|
|
defp wireless_sensor?(sensor) do
|
|
sensor.sensor_type in [
|
|
"frequency",
|
|
"power",
|
|
"rssi",
|
|
"ccq",
|
|
"clients",
|
|
"distance",
|
|
"noise-floor",
|
|
"quality",
|
|
"rate",
|
|
"utilization"
|
|
]
|
|
end
|
|
|
|
# Counter sensors include various count and gauge types
|
|
# (connections, sessions, clients, DHCP leases, etc.)
|
|
defp counter_sensor?(sensor) do
|
|
sensor.sensor_type in [
|
|
"count",
|
|
"pppoe_sessions",
|
|
"connections",
|
|
"clients",
|
|
"leases",
|
|
"sessions"
|
|
]
|
|
end
|
|
|
|
# Firewall counters are connection-related metrics
|
|
defp firewall_counter?(sensor) do
|
|
descr = String.downcase(sensor.sensor_descr || "")
|
|
String.contains?(descr, "connection")
|
|
end
|
|
|
|
# Current sensors (amperes)
|
|
defp 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
|
|
|
|
# Power sensors (watts) - separate from wireless power
|
|
defp 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
|
|
|
|
# Fan speed sensors (RPM)
|
|
defp 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
|
|
|
|
# System load sensors (CPU load, etc.)
|
|
defp 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
|
|
|
|
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
|
|
defp signal_sensor?(sensor) do
|
|
sensor.sensor_type in [
|
|
"snr",
|
|
"rssi",
|
|
"rsrp",
|
|
"rsrq",
|
|
"sinr",
|
|
"ssr",
|
|
"mse",
|
|
"noise",
|
|
"noise-floor",
|
|
"dbm"
|
|
]
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("download_backup", %{"id" => backup_id}, socket) do
|
|
backup = MikrotikBackups.get_backup!(backup_id)
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Verify the backup belongs to a device in the user's organization
|
|
case AccessControl.verify_device_access(backup.device_id, organization.id) do
|
|
{:ok, device} ->
|
|
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
|
|
filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc"
|
|
|
|
{:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))}
|
|
end
|
|
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("compare_selected", _params, socket) do
|
|
device_id = socket.assigns.device.id
|
|
selected = MapSet.to_list(socket.assigns.selected_backup_ids)
|
|
|
|
case selected do
|
|
[backup_a, backup_b] ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_backup_ids, MapSet.new())
|
|
|> push_navigate(to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_a}&backup_b=#{backup_b}")}
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Please select exactly 2 backups to compare"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_selection", _params, socket) do
|
|
{:noreply, assign(socket, :selected_backup_ids, MapSet.new())}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete_backup", %{"id" => backup_id}, socket) do
|
|
import ToweropsWeb.Permissions
|
|
|
|
if owner?(socket) do
|
|
do_delete_backup(backup_id, socket)
|
|
else
|
|
{:noreply, put_flash(socket, :error, t_equipment("Only organization owners can delete backups"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("backup_now", _params, socket) do
|
|
device = socket.assigns.device
|
|
agent_token_id = socket.assigns.agent_info.agent_token_id
|
|
|
|
cond do
|
|
is_nil(agent_token_id) ->
|
|
{:noreply,
|
|
put_flash(socket, :error, t_equipment("Device has no agent assigned. Assign an agent to create backups."))}
|
|
|
|
!device.mikrotik_enabled ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("MikroTik API is not enabled for this device."))}
|
|
|
|
true ->
|
|
trigger_manual_backup(socket, device, agent_token_id)
|
|
end
|
|
end
|
|
|
|
defp trigger_manual_backup(socket, device, agent_token_id) do
|
|
alias Towerops.Agent.AgentJob
|
|
alias Towerops.Agent.MikrotikCommand
|
|
alias Towerops.Agent.MikrotikDevice
|
|
alias Towerops.Devices.BackupRequests
|
|
|
|
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
|
|
mikrotik_config = Devices.get_mikrotik_config(device)
|
|
|
|
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
|
|
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
|
|
|
|
# Build chunked read commands
|
|
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
|
|
chunk_size = 32_768
|
|
num_chunks = 10
|
|
|
|
read_commands =
|
|
for i <- 0..(num_chunks - 1) do
|
|
%MikrotikCommand{
|
|
command: "/file/read",
|
|
args: %{
|
|
"file" => "#{backup_filename}.rsc",
|
|
"offset" => to_string(i * chunk_size),
|
|
"chunk-size" => to_string(chunk_size)
|
|
}
|
|
}
|
|
end
|
|
|
|
job = %AgentJob{
|
|
job_id: job_id,
|
|
job_type: :MIKROTIK,
|
|
device_id: device.id,
|
|
mikrotik_device: %MikrotikDevice{
|
|
ip: device.ip_address,
|
|
username: mikrotik_config.username,
|
|
password: mikrotik_config.password,
|
|
port: mikrotik_config.port,
|
|
ssh_port: mikrotik_config.ssh_port,
|
|
use_ssl: mikrotik_config.use_ssl
|
|
},
|
|
mikrotik_commands:
|
|
[
|
|
# Step 1: Export config to file on router (compact format, no defaults)
|
|
%MikrotikCommand{
|
|
command: "/export",
|
|
args: %{"file" => backup_filename, "compact" => ""}
|
|
}
|
|
] ++
|
|
read_commands ++
|
|
[
|
|
# Final step: Delete the temporary file
|
|
%MikrotikCommand{
|
|
command: "/file/remove",
|
|
args: %{"numbers" => "#{backup_filename}.rsc"}
|
|
}
|
|
]
|
|
}
|
|
|
|
case BackupRequests.create_request(device.id, job_id, "manual") do
|
|
{:ok, _request} ->
|
|
Phoenix.PubSub.broadcast(
|
|
Towerops.PubSub,
|
|
"agent:#{agent_token_id}:backup",
|
|
{:backup_requested, job}
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Manual backup requested. Results will appear shortly."))
|
|
|> load_equipment_data(device.id)}
|
|
|
|
{:error, _reason} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to create backup request. Please try again."))}
|
|
end
|
|
end
|
|
|
|
# Private helper for deleting backups
|
|
defp do_delete_backup(backup_id, socket) do
|
|
backup = MikrotikBackups.get_backup!(backup_id)
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Verify the backup belongs to a device in the user's organization
|
|
case AccessControl.verify_device_access(backup.device_id, organization.id) do
|
|
{:ok, _device} ->
|
|
case MikrotikBackups.delete_backup(backup) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Backup deleted successfully"))
|
|
|> load_equipment_data(socket.assigns.device.id)}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to delete backup"))}
|
|
end
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))}
|
|
end
|
|
end
|
|
|
|
# Apply pagination to backups list if on the backups tab
|
|
defp apply_backups_pagination(socket, "backups", page) when is_map_key(socket.assigns, :mikrotik_backups) do
|
|
per_page = 20
|
|
all_backups = socket.assigns.mikrotik_backups
|
|
total_count = length(all_backups)
|
|
total_pages = ceil(total_count / per_page)
|
|
|
|
# Ensure page is within valid range
|
|
page = max(1, min(page, max(1, total_pages)))
|
|
|
|
# Slice backups for current page
|
|
offset = (page - 1) * per_page
|
|
backups_page = Enum.slice(all_backups, offset, per_page)
|
|
|
|
socket
|
|
|> assign(:mikrotik_backups, backups_page)
|
|
|> assign(:pagination, %{
|
|
page: page,
|
|
per_page: per_page,
|
|
total_count: total_count,
|
|
total_pages: total_pages
|
|
})
|
|
end
|
|
|
|
defp apply_backups_pagination(socket, _tab, _page), do: socket
|
|
end
|