From cac54b46a271ab1ab14bd40a25f17a3ce6af3509 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 5 Jan 2026 10:42:34 -0600 Subject: [PATCH] Implement event logging system and fix sensor display - Add equipment_events table and Event schema for tracking device changes - Implement automatic change detection for interface attributes during SNMP polling - Detects operational status changes (up/down) - Detects admin status changes - Detects speed changes with warning severity for speed drops - Detects MAC address changes - Add Logs tab to equipment detail page with event display - Fix temperature sensor display bug (was showing 4.3C instead of 43C due to double division) - Remove response time tracking from monitoring checks and UI - Move Rediscover Device button to equipment edit page --- lib/towerops/equipment.ex | 25 + lib/towerops/equipment/event.ex | 54 ++ lib/towerops/monitoring/equipment_monitor.ex | 10 +- lib/towerops/snmp/poller_worker.ex | 204 ++++++ lib/towerops_web/live/equipment_live/form.ex | 16 + .../live/equipment_live/form.html.heex | 18 +- lib/towerops_web/live/equipment_live/show.ex | 144 ++-- .../live/equipment_live/show.html.heex | 643 +++++++++++------- ...20260105163301_create_equipment_events.exs | 25 + 9 files changed, 801 insertions(+), 338 deletions(-) create mode 100644 lib/towerops/equipment/event.ex create mode 100644 priv/repo/migrations/20260105163301_create_equipment_events.exs diff --git a/lib/towerops/equipment.ex b/lib/towerops/equipment.ex index 3d182b1f..45574f08 100644 --- a/lib/towerops/equipment.ex +++ b/lib/towerops/equipment.ex @@ -6,6 +6,7 @@ defmodule Towerops.Equipment do import Ecto.Query alias Towerops.Equipment.Equipment, as: EquipmentSchema + alias Towerops.Equipment.Event alias Towerops.Repo @doc """ @@ -135,4 +136,28 @@ defmodule Towerops.Equipment do |> Ecto.Changeset.change(%{last_snmp_poll_at: now}) |> Repo.update() end + + ## Events + + @doc """ + Creates an equipment event. + """ + def create_event(attrs) do + %Event{} + |> Event.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns the list of events for equipment, ordered by most recent first. + """ + def list_equipment_events(equipment_id, limit \\ 100) do + Repo.all( + from(e in Event, + where: e.equipment_id == ^equipment_id, + order_by: [desc: e.occurred_at], + limit: ^limit + ) + ) + end end diff --git a/lib/towerops/equipment/event.ex b/lib/towerops/equipment/event.ex new file mode 100644 index 00000000..161c2176 --- /dev/null +++ b/lib/towerops/equipment/event.ex @@ -0,0 +1,54 @@ +defmodule Towerops.Equipment.Event do + @moduledoc """ + Equipment event schema for tracking changes and incidents. + """ + use Ecto.Schema + + import Ecto.Changeset + + @type severity :: :info | :warning | :critical + + @type event_type :: + :interface_up + | :interface_down + | :interface_speed_change + | :interface_mtu_change + | :interface_mac_change + | :interface_admin_status_change + | :device_discovered + | :device_rediscovered + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "equipment_events" do + field :event_type, :string + field :severity, :string + field :message, :string + field :metadata, :map, default: %{} + field :occurred_at, :utc_datetime + + belongs_to :equipment, Towerops.Equipment.Equipment + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [:equipment_id, :event_type, :severity, :message, :metadata, :occurred_at]) + |> validate_required([:equipment_id, :event_type, :severity, :message, :occurred_at]) + |> validate_inclusion(:event_type, [ + "interface_up", + "interface_down", + "interface_speed_change", + "interface_mtu_change", + "interface_mac_change", + "interface_admin_status_change", + "device_discovered", + "device_rediscovered" + ]) + |> validate_inclusion(:severity, ["info", "warning", "critical"]) + |> foreign_key_constraint(:equipment_id) + end +end diff --git a/lib/towerops/monitoring/equipment_monitor.ex b/lib/towerops/monitoring/equipment_monitor.ex index 76cdfa58..e0b0c4e6 100644 --- a/lib/towerops/monitoring/equipment_monitor.ex +++ b/lib/towerops/monitoring/equipment_monitor.ex @@ -84,17 +84,17 @@ defmodule Towerops.Monitoring.EquipmentMonitor do now = DateTime.truncate(DateTime.utc_now(), :second) - {status, response_time} = + status = case check_result do - {:ok, time} -> {:success, time} - {:error, _reason} -> {:failure, nil} + {:ok, _time} -> :success + {:error, _reason} -> :failure end # Save the check result case Monitoring.create_check(%{ equipment_id: equipment_id, status: status, - response_time_ms: response_time, + response_time_ms: nil, checked_at: now }) do {:ok, _check} -> @@ -121,7 +121,7 @@ defmodule Towerops.Monitoring.EquipmentMonitor do Phoenix.PubSub.broadcast( Towerops.PubSub, "equipment:#{equipment_id}", - {:equipment_status_changed, equipment_id, new_status, response_time} + {:equipment_status_changed, equipment_id, new_status, nil} ) # Only schedule next check if monitoring is enabled diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index 11f00805..a6b90b48 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -118,6 +118,15 @@ defmodule Towerops.Snmp.PollerWorker do "Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" ) end + + try do + check_interface_changes(device.interfaces, equipment, client_opts, now) + rescue + error -> + Logger.error( + "Error checking interface changes for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" + ) + end end # Schedule next poll regardless of whether we polled or skipped @@ -220,6 +229,201 @@ defmodule Towerops.Snmp.PollerWorker do end) end + defp check_interface_changes(interfaces, equipment, client_opts, timestamp) do + Enum.each(interfaces, fn interface -> + case get_interface_attributes(client_opts, interface.if_index) do + {:ok, current_attrs} -> + detect_and_log_changes(interface, current_attrs, equipment.id, timestamp) + + {:error, reason} -> + Logger.debug("Failed to get interface attributes for #{interface.if_name}: #{inspect(reason)}") + end + end) + end + + defp get_interface_attributes(client_opts, if_index) do + oids = [ + # ifSpeed + "1.3.6.1.2.1.2.2.1.5.#{if_index}", + # ifPhysAddress + "1.3.6.1.2.1.2.2.1.6.#{if_index}", + # ifAdminStatus + "1.3.6.1.2.1.2.2.1.7.#{if_index}", + # ifOperStatus + "1.3.6.1.2.1.2.2.1.8.#{if_index}" + ] + + case Client.get_multiple(client_opts, oids) do + {:ok, [speed, phys_addr, admin_status, oper_status]} -> + {:ok, + %{ + if_speed: parse_interface_integer(speed), + if_phys_address: format_mac_address(phys_addr), + if_admin_status: parse_if_status(admin_status), + if_oper_status: parse_if_status(oper_status) + }} + + {:error, reason} = error -> + Logger.debug("Failed to get interface attributes: #{inspect(reason)}") + error + end + end + + defp detect_and_log_changes(interface, current_attrs, equipment_id, timestamp) do + changes = [] + + # Check operational status change + changes = + if interface.if_oper_status == current_attrs.if_oper_status do + changes + else + event = %{ + equipment_id: equipment_id, + event_type: if(current_attrs.if_oper_status == "up", do: "interface_up", else: "interface_down"), + severity: if(current_attrs.if_oper_status == "up", do: "info", else: "warning"), + message: "Interface #{interface.if_name} is now #{current_attrs.if_oper_status}", + metadata: %{ + interface_id: interface.id, + interface_name: interface.if_name, + old_status: interface.if_oper_status, + new_status: current_attrs.if_oper_status + }, + occurred_at: timestamp + } + + [{:event, event} | changes] + end + + # Check admin status change + changes = + if interface.if_admin_status == current_attrs.if_admin_status do + changes + else + event = %{ + equipment_id: equipment_id, + event_type: "interface_admin_status_change", + severity: "info", + message: + "Interface #{interface.if_name} admin status changed from #{interface.if_admin_status} to #{current_attrs.if_admin_status}", + metadata: %{ + interface_id: interface.id, + interface_name: interface.if_name, + old_status: interface.if_admin_status, + new_status: current_attrs.if_admin_status + }, + occurred_at: timestamp + } + + [{:event, event} | changes] + end + + # Check speed change + changes = + if interface.if_speed != current_attrs.if_speed && + current_attrs.if_speed != nil && interface.if_speed != nil do + severity = + if current_attrs.if_speed < interface.if_speed, do: "warning", else: "info" + + event = %{ + equipment_id: equipment_id, + event_type: "interface_speed_change", + severity: severity, + message: + "Interface #{interface.if_name} speed changed from #{format_speed(interface.if_speed)} to #{format_speed(current_attrs.if_speed)}", + metadata: %{ + interface_id: interface.id, + interface_name: interface.if_name, + old_speed: interface.if_speed, + new_speed: current_attrs.if_speed + }, + occurred_at: timestamp + } + + [{:event, event} | changes] + else + changes + end + + # Check MAC address change + changes = + if interface.if_phys_address != current_attrs.if_phys_address && + current_attrs.if_phys_address != nil && interface.if_phys_address != nil do + event = %{ + equipment_id: equipment_id, + event_type: "interface_mac_change", + severity: "warning", + message: + "Interface #{interface.if_name} MAC address changed from #{interface.if_phys_address} to #{current_attrs.if_phys_address}", + metadata: %{ + interface_id: interface.id, + interface_name: interface.if_name, + old_mac: interface.if_phys_address, + new_mac: current_attrs.if_phys_address + }, + occurred_at: timestamp + } + + [{:event, event} | changes] + else + changes + end + + # If there are changes, create events and update interface + if length(changes) > 0 do + # Create all events + Enum.each(changes, fn {:event, event_attrs} -> + case Equipment.create_event(event_attrs) do + {:ok, _event} -> + Logger.info("Created event: #{event_attrs.message}") + + {:error, changeset} -> + Logger.error("Failed to create event: #{inspect(changeset.errors)}") + end + end) + + # Update interface with new attributes + Snmp.update_interface(interface, current_attrs) + end + end + + defp parse_interface_integer(value) when is_integer(value), do: value + defp parse_interface_integer(_), do: nil + + defp parse_if_status(1), do: "up" + defp parse_if_status(2), do: "down" + defp parse_if_status(3), do: "testing" + defp parse_if_status(_), do: "unknown" + + defp format_mac_address(<<>>) when is_binary(<<>>), do: nil + defp format_mac_address(nil), do: nil + + defp format_mac_address(mac) when is_binary(mac) do + mac + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + + defp format_mac_address(_), do: nil + + 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: "Unknown" + defp get_interface_stats(client_opts, if_index) do oids = [ if_in_octets: "1.3.6.1.2.1.2.2.1.10.#{if_index}", diff --git a/lib/towerops_web/live/equipment_live/form.ex b/lib/towerops_web/live/equipment_live/form.ex index 3700ce81..372bdef5 100644 --- a/lib/towerops_web/live/equipment_live/form.ex +++ b/lib/towerops_web/live/equipment_live/form.ex @@ -125,6 +125,22 @@ defmodule ToweropsWeb.EquipmentLive.Form do {:noreply, assign(socket, :snmp_test_result, result)} end + @impl true + def handle_event("trigger_discovery", _params, socket) do + equipment = socket.assigns.equipment + + if equipment.snmp_enabled do + # Run discovery in background + Task.start(fn -> + Snmp.discover_equipment(equipment) + end) + + {:noreply, put_flash(socket, :info, "Discovery started...")} + else + {:noreply, put_flash(socket, :error, "SNMP is not enabled for this equipment")} + end + end + defp save_equipment(socket, :new, equipment_params) do case Equipment.create_equipment(equipment_params) do {:ok, equipment} -> diff --git a/lib/towerops_web/live/equipment_live/form.html.heex b/lib/towerops_web/live/equipment_live/form.html.heex index 2adda04f..5ab4557b 100644 --- a/lib/towerops_web/live/equipment_live/form.html.heex +++ b/lib/towerops_web/live/equipment_live/form.html.heex @@ -102,9 +102,21 @@ - <.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> - Test SNMP Connection - +
+ <.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> + Test SNMP Connection + + + <%= if @live_action == :edit do %> + <.button + type="button" + phx-click="trigger_discovery" + phx-disable-with="Discovering..." + > + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Rediscover Device + + <% end %> +
diff --git a/lib/towerops_web/live/equipment_live/show.ex b/lib/towerops_web/live/equipment_live/show.ex index 525f023d..6fc3a68f 100644 --- a/lib/towerops_web/live/equipment_live/show.ex +++ b/lib/towerops_web/live/equipment_live/show.ex @@ -12,13 +12,18 @@ defmodule ToweropsWeb.EquipmentLive.Show do end @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params(%{"id" => id} = params, _, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}") Process.send_after(self(), :refresh_data, 10_000) end - {:noreply, load_equipment_data(socket, id)} + tab = Map.get(params, "tab", "overview") + + socket + |> load_equipment_data(id) + |> assign(:active_tab, tab) + |> then(&{:noreply, &1}) end @impl true @@ -40,28 +45,13 @@ defmodule ToweropsWeb.EquipmentLive.Show do |> put_flash(:info, "Discovery completed")} end - @impl true - def handle_event("trigger_discovery", _params, socket) do - equipment = socket.assigns.equipment - - if equipment.snmp_enabled do - # Run discovery in background - Task.start(fn -> - Snmp.discover_equipment(equipment) - end) - - {:noreply, put_flash(socket, :info, "Discovery started...")} - else - {:noreply, put_flash(socket, :error, "SNMP is not enabled for this equipment")} - end - end - # Private functions defp load_equipment_data(socket, equipment_id) do equipment = Equipment.get_equipment!(equipment_id) recent_checks = Monitoring.list_equipment_checks(equipment_id, 50) snmp_data = load_snmp_data(equipment_id) + events = Equipment.list_equipment_events(equipment_id, 100) # Calculate metrics for dashboard metrics = calculate_metrics(recent_checks, equipment) @@ -74,6 +64,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do |> assign(:snmp_device, snmp_data.device) |> assign(:snmp_interfaces, snmp_data.interfaces) |> assign(:snmp_sensors, snmp_data.sensors) + |> assign(:events, events) end defp calculate_metrics(checks, _equipment) do @@ -83,28 +74,14 @@ defmodule ToweropsWeb.EquipmentLive.Show do successful_checks = Enum.count(checks, &(&1.status == :success)) uptime_percentage = Float.round(successful_checks / total_checks * 100, 1) - avg_response_time = - checks - |> Enum.filter(&(&1.response_time_ms != nil)) - |> case do - [] -> - 0 - - valid_checks -> - sum = Enum.reduce(valid_checks, 0, &(&1.response_time_ms + &2)) - Float.round(sum / length(valid_checks), 1) - end - %{ uptime_percentage: uptime_percentage, - avg_response_time: avg_response_time, total_checks: total_checks, successful_checks: successful_checks } else %{ uptime_percentage: 0, - avg_response_time: 0, total_checks: 0, successful_checks: 0 } @@ -129,9 +106,9 @@ defmodule ToweropsWeb.EquipmentLive.Show do defp format_speed(_), do: "-" - defp format_sensor_value(value, divisor) when is_number(value) and is_number(divisor) do - result = value / divisor - Float.round(result, 1) + 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: "-" @@ -149,6 +126,54 @@ defmodule ToweropsWeb.EquipmentLive.Show do 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 = div(total_seconds, 604_800) + days = div(rem(total_seconds, 604_800), 86_400) + hours = div(rem(total_seconds, 86_400), 3600) + minutes = div(rem(total_seconds, 3600), 60) + seconds = rem(total_seconds, 60) + + parts = + Enum.reject( + [ + if(weeks > 0, do: "#{weeks} week#{if weeks > 1, do: "s"}"), + if(days > 0, do: "#{days} day#{if days > 1, do: "s"}"), + if(hours > 0, do: "#{hours} hour#{if hours > 1, do: "s"}"), + if(minutes > 0, do: "#{minutes} minute#{if minutes > 1, do: "s"}"), + if(seconds > 0, do: "#{seconds} second#{if seconds > 1, do: "s"}") + ], + &is_nil/1 + ) + + case parts do + [] -> "0 seconds" + parts -> Enum.join(parts, " ") + end + end + + 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_event_type(event_type) do + event_type + |> String.replace("_", " ") + |> String.capitalize() + end + defp load_snmp_data(equipment_id) do case Snmp.get_device_with_associations(equipment_id) do nil -> @@ -179,55 +204,6 @@ defmodule ToweropsWeb.EquipmentLive.Show do # SVG Chart helpers - defp sparkline(checks, width, height) when is_list(checks) do - if Enum.empty?(checks) or length(checks) < 2 do - "" - else - # Get response times in reverse order (oldest to newest for left-to-right) - data = - checks - |> Enum.reverse() - |> Enum.map(fn check -> - if check.response_time_ms, do: check.response_time_ms, else: 0 - end) - - max_value = Enum.max(data, fn -> 1 end) - max_value = if max_value == 0, do: 1, else: max_value - data_count = length(data) - - points = - data - |> Enum.with_index() - |> Enum.map_join(" ", fn {value, index} -> - x = index * (width / (data_count - 1)) - y = height - value / max_value * height - "#{Float.round(x, 2)},#{Float.round(y, 2)}" - end) - - assigns = %{points: points, width: width, height: height, data: data} - - ~H""" - - - <%= for {value, idx} <- Enum.with_index(@data) do %> - 1 end) * @height} - r="2" - class={if value == 0, do: "fill-red-500", else: "fill-blue-500"} - /> - <% end %> - - """ - end - end - defp uptime_gauge(percentage) do assigns = %{percentage: percentage} diff --git a/lib/towerops_web/live/equipment_live/show.html.heex b/lib/towerops_web/live/equipment_live/show.html.heex index 97e40cf6..8965035d 100644 --- a/lib/towerops_web/live/equipment_live/show.html.heex +++ b/lib/towerops_web/live/equipment_live/show.html.heex @@ -3,262 +3,413 @@ current_organization={@current_organization} active_page="equipment" > -
-
-

{@equipment.name}

-

{@equipment.ip_address}

-
-
- <%= if @equipment.snmp_enabled do %> - <.button phx-click="trigger_discovery"> - <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Discover +
+
+
+

{@equipment.name}

+

{@equipment.ip_address}

+
+
+ <.button navigate={ + ~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit" + }> + <.icon name="hero-pencil" class="h-4 w-4" /> Edit - <% end %> - <.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"}> - <.icon name="hero-pencil" class="h-4 w-4" /> Edit - -
-
- -
-
-
Status
-
- - - - {@equipment.status |> to_string() |> String.upcase()} -
- -
-
Uptime
-
- {@metrics.uptime_percentage}% -
-
- -
-
Avg Response
-
- {@metrics.avg_response_time}ms -
-
- -
-
Last Check
-
- - {time_ago(@equipment.last_checked_at)} - -
-
-
- -
- -
-

Response Time

-
- {sparkline(@recent_checks, 400, 80)} -
-
- -
-

- Availability (last {@metrics.total_checks} checks) -

-
- {uptime_gauge(@metrics.uptime_percentage)} -
-
- {@metrics.successful_checks} successful - {@metrics.total_checks - @metrics.successful_checks} failed -
-
-
- <%= if @snmp_device do %> - -
-

- Device Information -

-
- <%= if @snmp_device.manufacturer do %> -
- Manufacturer: - - {@snmp_device.manufacturer} - -
- <% end %> - <%= if @snmp_device.model do %> -
- Model: - - {@snmp_device.model} - -
- <% end %> - <%= if @snmp_device.firmware_version do %> -
- Firmware: - - {@snmp_device.firmware_version} - -
- <% end %> - <%= if @equipment.last_discovery_at do %> -
- Last Discovery: - - {Calendar.strftime(@equipment.last_discovery_at, "%Y-%m-%d %H:%M")} - -
- <% end %> -
- <%= if @snmp_device.sys_descr do %> -
- {@snmp_device.sys_descr} + +
+
+
Status
+
+ + + + {@equipment.status |> to_string() |> String.upcase()} +
- <% end %> +
+ +
+
Uptime
+
+ {@metrics.uptime_percentage}% +
+
+ +
+
Last Check
+
+ + {time_ago(@equipment.last_checked_at)} + +
+
- <% end %> - <%= if @snmp_sensors && length(@snmp_sensors) > 0 do %> - -
-

Sensors

-
- <%= for sensor <- @snmp_sensors do %> -
-
- {sensor.sensor_descr} + +
+ +
+
+ +
+ <%= case @active_tab do %> + <% "overview" -> %> +
+ +
+
+

+ Device Information +

- <%= if sensor.latest_reading do %> -
- - {format_sensor_value(sensor.latest_reading.value, sensor.sensor_divisor)} - - {sensor.sensor_unit} +
+
+ <%= if @snmp_device do %> +
+
System Name
+
+ {@snmp_device.sys_name || @equipment.name} +
+
+ +
+
Resolved IP
+
+ {@equipment.ip_address} +
+
+ +
+
Hardware
+
+ {@snmp_device.model || "N/A"} +
+
+ +
+
Operating System
+
+ {if @snmp_device.manufacturer && @snmp_device.firmware_version do + "#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}" + else + @snmp_device.sys_descr || "N/A" + end} +
+
+ +
+
Object ID
+
+ {@snmp_device.sys_object_id || "N/A"} +
+
+ +
+
Contact
+
+ {@snmp_device.sys_contact || "N/A"} +
+
+ +
+
Location
+
+ {@snmp_device.sys_location || "N/A"} +
+
+ +
+
Uptime
+
+ {format_uptime(@snmp_device.sys_uptime)} +
+
+ <% else %> +
+
Name
+
+ {@equipment.name} +
+
+ +
+
IP Address
+
+ {@equipment.ip_address} +
+
+ <% end %> + +
+
Device Added
+
+ {format_device_age(@equipment.inserted_at)} +
+
+ +
+
Last Discovered
+
+ {if @equipment.last_discovery_at do + format_device_age(@equipment.last_discovery_at) + else + "Never" + end} +
+
+
+
+
+ +
+ +
+
+

+ Availability (last {@metrics.total_checks} checks) +

+
+
+
+ {uptime_gauge(@metrics.uptime_percentage)} +
+
+ {@metrics.successful_checks} successful + {@metrics.total_checks - @metrics.successful_checks} failed +
+
+
+ + <%= if @snmp_sensors && length(@snmp_sensors) > 0 do %> +
+
+

Sensors

+
+
+
+ <%= for sensor <- @snmp_sensors do %> +
+
+ {sensor.sensor_descr} +
+ <%= if sensor.latest_reading do %> +
+ + {format_sensor_value( + sensor.latest_reading.value, + sensor.sensor_divisor + )} + + + {sensor.sensor_unit} + +
+ <% else %> +
No data
+ <% end %> +
+ <% end %> +
+
- <% else %> -
No data
<% end %>
- <% end %> -
-
- <% end %> - <%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %> - -
-

- Network Interfaces -

-
- - - - - - - - - - - - <%= for interface <- @snmp_interfaces do %> - - - - - - - - <% end %> - -
#NameStatusSpeedMAC
{interface.if_index} - {interface.if_name || interface.if_descr} - - - {String.upcase(interface.if_oper_status || "unknown")} - - - {format_speed(interface.if_speed)} - - {interface.if_phys_address || "-"} -
-
-
- <% end %> - -
-

Recent Checks

- <%= if Enum.empty?(Enum.take(@recent_checks, 10)) do %> -
- No monitoring checks yet -
- <% else %> -
- - - - - - - - - - <%= for check <- Enum.take(@recent_checks, 10) do %> - - - +
StatusResponseTime
- - {check.status |> to_string() |> String.upcase()} - - - <%= if check.response_time_ms do %> - {check.response_time_ms}ms - <% else %> - - + + <% "ports" -> %> + <%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %> +
+
+

+ Network Interfaces +

+
+
+ + + + + + + + + + + + + <%= for interface <- @snmp_interfaces do %> + + + + + + + + <% end %> - - - - <% end %> - -
#NameStatusSpeedMACType
+ {interface.if_index} + +
+ {interface.if_name || interface.if_descr} +
+ <%= if interface.if_alias do %> +
+ {interface.if_alias} +
+ <% end %> +
+ + {String.upcase(interface.if_oper_status || "unknown")} + + + {format_speed(interface.if_speed)} + + {interface.if_phys_address || "-"} + + {interface.if_type || "-"} +
- {Calendar.strftime(check.checked_at, "%H:%M:%S")} -
-
+
+
+
+ <% else %> +
+

No interfaces discovered

+
+ <% end %> + <% "logs" -> %> +
+
+

Event Logs

+

+ Showing the most recent 100 events +

+
+ <%= if Enum.any?(@events) do %> +
+ <%= for event <- @events do %> +
+
+ +
+ <%= if event.severity == "info" do %> + <.icon name="hero-information-circle" class="w-5 h-5" /> + <% end %> + <%= if event.severity == "warning" do %> + <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> + <% end %> + <%= if event.severity == "critical" do %> + <.icon name="hero-x-circle" class="w-5 h-5" /> + <% end %> +
+ +
+
+
+

+ {event.message} +

+
+ {format_event_type(event.event_type)} + + {event.severity} +
+
+ +
+
+
+
+ <% end %> +
+ <% else %> +
+ <.icon name="hero-document-text" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No events yet +

+

+ Events will appear here as interface states change, speeds change, or other monitored changes occur. +

+
+ <% end %> +
+ <% _ -> %> +
+

Tab not found

+
<% end %>
diff --git a/priv/repo/migrations/20260105163301_create_equipment_events.exs b/priv/repo/migrations/20260105163301_create_equipment_events.exs new file mode 100644 index 00000000..d3a7016d --- /dev/null +++ b/priv/repo/migrations/20260105163301_create_equipment_events.exs @@ -0,0 +1,25 @@ +defmodule Towerops.Repo.Migrations.CreateEquipmentEvents do + use Ecto.Migration + + def change do + create table(:equipment_events, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :equipment_id, references(:equipment, type: :binary_id, on_delete: :delete_all), + null: false + + add :event_type, :string, null: false + add :severity, :string, null: false + add :message, :text, null: false + add :metadata, :map, default: %{} + add :occurred_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:equipment_events, [:equipment_id]) + create index(:equipment_events, [:equipment_id, :occurred_at]) + create index(:equipment_events, [:event_type]) + create index(:equipment_events, [:occurred_at]) + end +end