diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex index eec7fbac..ec71d9b0 100644 --- a/lib/towerops/monitoring.ex +++ b/lib/towerops/monitoring.ex @@ -122,4 +122,34 @@ defmodule Towerops.Monitoring do nil end end + + @doc """ + Gets latency data for graphing ping response times. + + Returns successful monitoring checks with response times for the specified time range. + Failed checks are excluded as they have no latency data. + + ## Options + * `:since` - Only return checks after this datetime + * `:limit` - Maximum number of checks to return (default: 2880) + + ## Examples + + iex> get_latency_data(device_id, since: DateTime.utc_now() |> DateTime.add(-24, :hour), limit: 100) + [%Check{response_time_ms: 50, checked_at: ~U[2025-12-21 12:00:00Z]}, ...] + + """ + def get_latency_data(device_id, opts \\ []) do + since = Keyword.get(opts, :since, DateTime.add(DateTime.utc_now(), -24, :hour)) + limit = Keyword.get(opts, :limit, 2880) + + Check + |> where([c], c.device_id == ^device_id) + |> where([c], c.status == :success) + |> where([c], c.checked_at >= ^since) + |> where([c], not is_nil(c.response_time_ms)) + |> order_by([c], desc: c.checked_at) + |> limit(^limit) + |> Repo.all() + end end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 85b2e65f..6a3069a1 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -80,6 +80,7 @@ defmodule ToweropsWeb.DeviceLive.Show do snmp_data = load_snmp_data(device_id) events = Devices.list_devices_events(device_id, 100) neighbors = Snmp.list_neighbors_with_equipment(device_id) + latency_chart_data = load_latency_chart_data(device_id) cpu_chart_data = load_sensor_chart_data(device_id, ["cpu_load"]) memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"]) storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"]) @@ -110,6 +111,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:snmp_sensors, snmp_data.sensors) |> assign(:neighbors, neighbors) |> assign(:events, events) + |> assign(:latency_chart_data, latency_chart_data) |> assign(:cpu_chart_data, cpu_chart_data) |> assign(:memory_chart_data, memory_chart_data) |> assign(:storage_chart_data, storage_chart_data) @@ -317,6 +319,33 @@ defmodule ToweropsWeb.DeviceLive.Show do } end + defp load_latency_chart_data(device_id) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + checks = + device_id + |> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000) + |> Enum.reverse() + + if Enum.empty?(checks) do + nil + else + dataset = %{ + label: "Ping Latency", + data: Enum.map(checks, &latency_check_to_data_point/1) + } + + Jason.encode!(%{datasets: [dataset]}) + end + end + + defp latency_check_to_data_point(check) do + %{ + x: DateTime.to_unix(check.checked_at, :millisecond), + y: check.response_time_ms + } + end + defp load_overall_traffic_chart_data(device_id) do case Snmp.get_device_with_associations(device_id) do nil -> nil diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 785cbb12..a81e6411 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -211,6 +211,36 @@ + + <%= if @latency_chart_data do %> +
+ <.link + navigate={ + ~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/latency" + } + class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors" + > +
+

+ Ping Latency +

+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" /> +
+ +
+
+ +
+
+
+ <% end %> <%= if @traffic_chart_data do %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 0db6483b..cc0ed1e2 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -3,6 +3,7 @@ defmodule ToweropsWeb.GraphLive.Show do use ToweropsWeb, :live_view alias Towerops.Devices + alias Towerops.Monitoring alias Towerops.Snmp @impl true @@ -64,6 +65,9 @@ defmodule ToweropsWeb.GraphLive.Show do {chart_data, title_suffix} = cond do + sensor_type == "latency" -> + {load_latency_chart_data(device_id, range), nil} + sensor_type == "traffic" && interface_id -> {load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)} @@ -97,6 +101,7 @@ defmodule ToweropsWeb.GraphLive.Show do defp get_sensor_types_for_chart("traffic"), do: nil defp get_sensor_types_for_chart(_), do: [] + defp get_chart_config("latency"), do: {"Ping Latency", "ms", true} defp get_chart_config("processors"), do: {"Processor Usage", "%", false} defp get_chart_config("memory"), do: {"Memory Usage", "%", false} defp get_chart_config("storage"), do: {"Storage Usage", "%", false} @@ -187,6 +192,34 @@ defmodule ToweropsWeb.GraphLive.Show do end end + defp load_latency_chart_data(device_id, range) do + since = get_datetime_from_range(range) + limit = get_limit_for_range(range) + + checks = + device_id + |> Monitoring.get_latency_data(since: since, limit: limit) + |> Enum.reverse() + + if Enum.empty?(checks) do + nil + else + dataset = %{ + label: "Ping Latency", + data: Enum.map(checks, &latency_check_to_chart_point/1) + } + + Jason.encode!(%{datasets: [dataset]}) + end + end + + defp latency_check_to_chart_point(check) do + %{ + x: DateTime.to_unix(check.checked_at, :millisecond), + y: check.response_time_ms + } + end + defp build_traffic_chart_json(device, range) do if Enum.empty?(device.interfaces), do: nil, else: build_traffic_datasets(device, range) end diff --git a/test/towerops/monitoring_test.exs b/test/towerops/monitoring_test.exs index a2b06fbe..1bd2e917 100644 --- a/test/towerops/monitoring_test.exs +++ b/test/towerops/monitoring_test.exs @@ -284,6 +284,135 @@ defmodule Towerops.MonitoringTest do result = Monitoring.get_uptime_percentage(device.id) assert is_nil(result) end + + test "get_latency_data/2 returns latency checks for graphing", %{device: device} do + base_time = ~U[2025-12-21 12:00:00Z] + + # Create successful checks with varying latency + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :success, + response_time_ms: 50, + checked_at: base_time + }) + + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :success, + response_time_ms: 75, + checked_at: DateTime.add(base_time, 60, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :success, + response_time_ms: 100, + checked_at: DateTime.add(base_time, 120, :second) + }) + + # Create a failed check (should be excluded) + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :failure, + response_time_ms: nil, + checked_at: DateTime.add(base_time, 180, :second) + }) + + since = DateTime.add(base_time, -3600, :second) + result = Monitoring.get_latency_data(device.id, since: since, limit: 100) + + assert length(result) == 3 + # Results should be ordered by checked_at descending + assert Enum.at(result, 0).response_time_ms == 100 + assert Enum.at(result, 1).response_time_ms == 75 + assert Enum.at(result, 2).response_time_ms == 50 + end + + test "get_latency_data/2 respects limit option", %{device: device} do + base_time = ~U[2025-12-21 12:00:00Z] + + # Create 5 checks + for i <- 1..5 do + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :success, + response_time_ms: 50 + i, + checked_at: DateTime.add(base_time, i * 60, :second) + }) + end + + since = DateTime.add(base_time, -3600, :second) + result = Monitoring.get_latency_data(device.id, since: since, limit: 3) + + assert length(result) == 3 + end + + test "get_latency_data/2 respects since option", %{device: device} do + base_time = ~U[2025-12-21 12:00:00Z] + + # Create check before cutoff + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :success, + response_time_ms: 50, + checked_at: DateTime.add(base_time, -7200, :second) + }) + + # Create check after cutoff + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :success, + response_time_ms: 75, + checked_at: base_time + }) + + since = DateTime.add(base_time, -3600, :second) + result = Monitoring.get_latency_data(device.id, since: since, limit: 100) + + # Should only include the check after the since timestamp + assert length(result) == 1 + assert hd(result).response_time_ms == 75 + end + + test "get_latency_data/2 returns empty list when no checks exist", %{device: device} do + since = DateTime.add(DateTime.utc_now(), -3600, :second) + result = Monitoring.get_latency_data(device.id, since: since, limit: 100) + + assert result == [] + end + + test "get_latency_data/2 excludes failed checks", %{device: device} do + base_time = ~U[2025-12-21 12:00:00Z] + + # Create only failed checks + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :failure, + response_time_ms: nil, + checked_at: base_time + }) + + {:ok, _} = + Monitoring.create_check(%{ + device_id: device.id, + status: :failure, + response_time_ms: nil, + checked_at: DateTime.add(base_time, 60, :second) + }) + + since = DateTime.add(base_time, -3600, :second) + result = Monitoring.get_latency_data(device.id, since: since, limit: 100) + + assert result == [] + end end describe "ping" do