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:
parent
9eca2e7069
commit
cac54b46a2
9 changed files with 801 additions and 338 deletions
|
|
@ -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
|
||||
|
|
|
|||
54
lib/towerops/equipment/event.ex
Normal file
54
lib/towerops/equipment/event.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue