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"""
-
- """
- 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
-
-
-
-
-
- | # |
- Name |
- Status |
- Speed |
- MAC |
-
-
-
- <%= for interface <- @snmp_interfaces do %>
-
- | {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 %>
-
-
-
-
- <% end %>
-
-
-
Recent Checks
- <%= if Enum.empty?(Enum.take(@recent_checks, 10)) do %>
-
- No monitoring checks yet
-
- <% else %>
-
-
-
-
- | Status |
- Response |
- Time |
-
-
-
- <%= for check <- Enum.take(@recent_checks, 10) do %>
-
- |
-
- {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
+
+
+
+
+
+
+ | # |
+ Name |
+ Status |
+ Speed |
+ MAC |
+ Type |
+
+
+
+ <%= for interface <- @snmp_interfaces do %>
+
+ |
+ {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 || "-"}
+ |
+
<% end %>
-
-
- {Calendar.strftime(check.checked_at, "%H:%M:%S")}
- |
-
- <% end %>
-
-
-
+ |
+
+
+
+ <% 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 %>
+
+ <% _ -> %>
+
<% 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