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
This commit is contained in:
Graham McIntire 2026-01-05 10:42:34 -06:00
parent 9eca2e7069
commit cac54b46a2
No known key found for this signature in database
9 changed files with 801 additions and 338 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}",

View file

@ -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} ->

View file

@ -102,9 +102,21 @@
</div>
</div>
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing...">
Test SNMP Connection
</.button>
<div class="flex gap-3">
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing...">
Test SNMP Connection
</.button>
<%= 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
</.button>
<% end %>
</div>
</div>
</div>

View file

@ -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"""
<svg viewBox={"0 0 #{@width} #{@height}"} class="w-full h-full">
<polyline
points={@points}
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-blue-500"
/>
<%= for {value, idx} <- Enum.with_index(@data) do %>
<circle
cx={idx * (@width / (length(@data) - 1))}
cy={@height - value / Enum.max(@data, fn -> 1 end) * @height}
r="2"
class={if value == 0, do: "fill-red-500", else: "fill-blue-500"}
/>
<% end %>
</svg>
"""
end
end
defp uptime_gauge(percentage) do
assigns = %{percentage: percentage}

View file

@ -3,262 +3,413 @@
current_organization={@current_organization}
active_page="equipment"
>
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment.name}</h1>
<p class="text-sm text-zinc-600 dark:text-zinc-400 font-mono">{@equipment.ip_address}</p>
</div>
<div class="flex gap-2">
<%= if @equipment.snmp_enabled do %>
<.button phx-click="trigger_discovery">
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Discover
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment.name}</h1>
<p class="text-sm text-zinc-600 dark:text-zinc-400 font-mono">{@equipment.ip_address}</p>
</div>
<div class="flex gap-2">
<.button navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"
}>
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
</.button>
<% end %>
<.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"}>
<.icon name="hero-pencil" class="h-4 w-4" /> Edit
</.button>
</div>
</div>
<!-- Status Overview Bar -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Status</div>
<div class="flex items-center gap-2">
<span class={[
"inline-flex h-3 w-3 rounded-full",
@equipment.status == :up && "bg-green-500",
@equipment.status == :down && "bg-red-500",
@equipment.status == :unknown && "bg-zinc-400"
]}>
</span>
<span class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{@equipment.status |> to_string() |> String.upcase()}
</span>
</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Uptime</div>
<div class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{@metrics.uptime_percentage}%
</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Avg Response</div>
<div class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{@metrics.avg_response_time}ms
</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Last Check</div>
<div class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
<span
title={
if @equipment.last_checked_at, do: "#{@equipment.last_checked_at} UTC", else: nil
}
class="cursor-help"
>
{time_ago(@equipment.last_checked_at)}
</span>
</div>
</div>
</div>
<!-- Graphs Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- Response Time Graph -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold mb-2 text-zinc-900 dark:text-zinc-100">Response Time</h3>
<div class="h-24">
{sparkline(@recent_checks, 400, 80)}
</div>
</div>
<!-- Uptime Gauge -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
Availability (last {@metrics.total_checks} checks)
</h3>
<div class="mt-4">
{uptime_gauge(@metrics.uptime_percentage)}
</div>
<div class="mt-3 flex justify-between text-xs text-zinc-600 dark:text-zinc-400">
<span>{@metrics.successful_checks} successful</span>
<span>{@metrics.total_checks - @metrics.successful_checks} failed</span>
</div>
</div>
</div>
<%= if @snmp_device do %>
<!-- Device Info -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-4">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">
Device Information
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
<%= if @snmp_device.manufacturer do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Manufacturer:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{@snmp_device.manufacturer}
</span>
</div>
<% end %>
<%= if @snmp_device.model do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Model:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{@snmp_device.model}
</span>
</div>
<% end %>
<%= if @snmp_device.firmware_version do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Firmware:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{@snmp_device.firmware_version}
</span>
</div>
<% end %>
<%= if @equipment.last_discovery_at do %>
<div>
<span class="text-zinc-600 dark:text-zinc-400">Last Discovery:</span>
<span class="ml-2 text-zinc-900 dark:text-zinc-100 font-medium">
{Calendar.strftime(@equipment.last_discovery_at, "%Y-%m-%d %H:%M")}
</span>
</div>
<% end %>
</div>
<%= if @snmp_device.sys_descr do %>
<div class="mt-3 text-xs text-zinc-600 dark:text-zinc-400">
{@snmp_device.sys_descr}
<!-- Status Bar -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Status</div>
<div class="flex items-center gap-2">
<span class={[
"inline-flex h-3 w-3 rounded-full",
@equipment.status == :up && "bg-green-500",
@equipment.status == :down && "bg-red-500",
@equipment.status == :unknown && "bg-zinc-400"
]}>
</span>
<span class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{@equipment.status |> to_string() |> String.upcase()}
</span>
</div>
<% end %>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Uptime</div>
<div class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{@metrics.uptime_percentage}%
</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-3 border border-zinc-200 dark:border-zinc-700">
<div class="text-xs text-zinc-600 dark:text-zinc-400 mb-1">Last Check</div>
<div class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
<span
title={
if @equipment.last_checked_at, do: "#{@equipment.last_checked_at} UTC", else: nil
}
class="cursor-help"
>
{time_ago(@equipment.last_checked_at)}
</span>
</div>
</div>
</div>
<% end %>
<%= if @snmp_sensors && length(@snmp_sensors) > 0 do %>
<!-- Sensors -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-4">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">Sensors</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<%= for sensor <- @snmp_sensors do %>
<div class="bg-zinc-50 dark:bg-zinc-900 rounded p-2 border border-zinc-200 dark:border-zinc-700">
<div
class="text-xs text-zinc-600 dark:text-zinc-400 truncate"
title={sensor.sensor_descr}
>
{sensor.sensor_descr}
<!-- Tabs -->
<div class="border-b border-zinc-200 dark:border-zinc-700">
<nav class="-mb-px flex space-x-8">
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=overview"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "overview" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
end
]}
>
Overview
</.link>
<%= if @snmp_device do %>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=ports"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "ports" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
end
]}
>
Ports
</.link>
<% end %>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=logs"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "logs" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
end
]}
>
Logs
</.link>
</nav>
</div>
</div>
<!-- Tab Content -->
<div class="mt-6">
<%= case @active_tab do %>
<% "overview" -> %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Device Information -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Device Information
</h3>
</div>
<%= if sensor.latest_reading do %>
<div class="mt-1 flex items-baseline gap-1">
<span class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{format_sensor_value(sensor.latest_reading.value, sensor.sensor_divisor)}
</span>
<span class="text-xs text-zinc-600 dark:text-zinc-400">{sensor.sensor_unit}</span>
<div class="p-4">
<dl class="space-y-3">
<%= if @snmp_device do %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">System Name</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_name || @equipment.name}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Resolved IP</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@equipment.ip_address}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Hardware</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.model || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Operating System</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{if @snmp_device.manufacturer && @snmp_device.firmware_version do
"#{@snmp_device.manufacturer} #{@snmp_device.firmware_version}"
else
@snmp_device.sys_descr || "N/A"
end}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Object ID</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@snmp_device.sys_object_id || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Contact</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_contact || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Location</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_location || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Uptime</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{format_uptime(@snmp_device.sys_uptime)}
</dd>
</div>
<% else %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Name</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@equipment.name}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">IP Address</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@equipment.ip_address}
</dd>
</div>
<% end %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Device Added</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{format_device_age(@equipment.inserted_at)}
</dd>
</div>
<div class="flex justify-between py-2">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Last Discovered</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{if @equipment.last_discovery_at do
format_device_age(@equipment.last_discovery_at)
else
"Never"
end}
</dd>
</div>
</dl>
</div>
</div>
<!-- Graphs and Metrics -->
<div class="space-y-6">
<!-- Uptime Gauge -->
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Availability (last {@metrics.total_checks} checks)
</h3>
</div>
<div class="p-4">
<div class="mt-2">
{uptime_gauge(@metrics.uptime_percentage)}
</div>
<div class="mt-3 flex justify-between text-xs text-zinc-600 dark:text-zinc-400">
<span>{@metrics.successful_checks} successful</span>
<span>{@metrics.total_checks - @metrics.successful_checks} failed</span>
</div>
</div>
</div>
<!-- Sensors -->
<%= if @snmp_sensors && length(@snmp_sensors) > 0 do %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Sensors</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<%= for sensor <- @snmp_sensors do %>
<div class="bg-zinc-50 dark:bg-zinc-900 rounded p-2 border border-zinc-200 dark:border-zinc-700">
<div
class="text-xs text-zinc-600 dark:text-zinc-400 truncate"
title={sensor.sensor_descr}
>
{sensor.sensor_descr}
</div>
<%= if sensor.latest_reading do %>
<div class="mt-1 flex items-baseline gap-1">
<span class="text-lg font-bold text-zinc-900 dark:text-zinc-100">
{format_sensor_value(
sensor.latest_reading.value,
sensor.sensor_divisor
)}
</span>
<span class="text-xs text-zinc-600 dark:text-zinc-400">
{sensor.sensor_unit}
</span>
</div>
<% else %>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-500">No data</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<% else %>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-500">No data</div>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
<!-- Interfaces -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-4">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">
Network Interfaces
</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead>
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
<th class="px-3 py-2 text-left font-medium">#</th>
<th class="px-3 py-2 text-left font-medium">Name</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">MAC</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for interface <- @snmp_interfaces do %>
<tr class="text-sm">
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">{interface.if_index}</td>
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">
{interface.if_name || interface.if_descr}
</td>
<td class="px-3 py-2">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
interface.if_oper_status == "up" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
interface.if_oper_status == "down" &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
interface.if_oper_status not in ["up", "down"] &&
"bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
]}>
{String.upcase(interface.if_oper_status || "unknown")}
</span>
</td>
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">
{format_speed(interface.if_speed)}
</td>
<td class="px-3 py-2 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{interface.if_phys_address || "-"}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<!-- Recent Checks (Compact) -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold mb-3 text-zinc-900 dark:text-zinc-100">Recent Checks</h3>
<%= if Enum.empty?(Enum.take(@recent_checks, 10)) do %>
<div class="text-sm text-zinc-600 dark:text-zinc-400 text-center py-4">
No monitoring checks yet
</div>
<% else %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead>
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Response</th>
<th class="px-3 py-2 text-left font-medium">Time</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for check <- Enum.take(@recent_checks, 10) do %>
<tr class="text-sm">
<td class="px-3 py-2">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
check.status == :success &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
check.status == :failure &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
]}>
{check.status |> to_string() |> String.upcase()}
</span>
</td>
<td class="px-3 py-2 text-zinc-900 dark:text-zinc-100">
<%= if check.response_time_ms do %>
{check.response_time_ms}ms
<% else %>
-
</div>
<% "ports" -> %>
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Network Interfaces
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
<th class="px-4 py-3 text-left font-medium">#</th>
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-left font-medium">Speed</th>
<th class="px-4 py-3 text-left font-medium">MAC</th>
<th class="px-4 py-3 text-left font-medium">Type</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for interface <- @snmp_interfaces do %>
<tr class="text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900">
<td class="px-4 py-3 text-zinc-900 dark:text-zinc-100">
{interface.if_index}
</td>
<td class="px-4 py-3">
<div class="font-medium text-zinc-900 dark:text-zinc-100">
{interface.if_name || interface.if_descr}
</div>
<%= if interface.if_alias do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{interface.if_alias}
</div>
<% end %>
</td>
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
interface.if_oper_status == "up" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
interface.if_oper_status == "down" &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
interface.if_oper_status not in ["up", "down"] &&
"bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
]}>
{String.upcase(interface.if_oper_status || "unknown")}
</span>
</td>
<td class="px-4 py-3 text-zinc-900 dark:text-zinc-100">
{format_speed(interface.if_speed)}
</td>
<td class="px-4 py-3 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{interface.if_phys_address || "-"}
</td>
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{interface.if_type || "-"}
</td>
</tr>
<% end %>
</td>
<td class="px-3 py-2 text-zinc-600 dark:text-zinc-400">
{Calendar.strftime(check.checked_at, "%H:%M:%S")}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="text-center py-12">
<p class="text-zinc-500 dark:text-zinc-400">No interfaces discovered</p>
</div>
<% end %>
<% "logs" -> %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Event Logs</h3>
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1">
Showing the most recent 100 events
</p>
</div>
<%= if Enum.any?(@events) do %>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for event <- @events do %>
<div class="px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-750">
<div class="flex items-start gap-3">
<!-- Severity Icon -->
<div class={[
"flex-shrink-0 mt-0.5",
event.severity == "info" && "text-blue-500",
event.severity == "warning" && "text-yellow-500",
event.severity == "critical" && "text-red-500"
]}>
<%= 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 %>
</div>
<!-- Event Details -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{event.message}
</p>
<div class="mt-1 flex items-center gap-3 text-xs text-zinc-500 dark:text-zinc-400">
<span>{format_event_type(event.event_type)}</span>
<span>•</span>
<span class="capitalize">{event.severity}</span>
</div>
</div>
<time class="flex-shrink-0 text-xs text-zinc-500 dark:text-zinc-400">
{time_ago(event.occurred_at)}
</time>
</div>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="p-8 text-center">
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-zinc-400" />
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No events yet
</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Events will appear here as interface states change, speeds change, or other monitored changes occur.
</p>
</div>
<% end %>
</div>
<% _ -> %>
<div class="text-center py-12">
<p class="text-zinc-500 dark:text-zinc-400">Tab not found</p>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -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