From 9d0c824aa537e45f3fcdde644583d2c429bedb60 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 18 Mar 2026 15:13:31 -0500 Subject: [PATCH] add dual-axis graph for dns checks (#74) DNS check graph now shows two datasets: Response Time (ms) on left y-axis and Status (Up/Down) on right y-axis with stepped green fill. Also adds space before units in chart labels. Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/74 --- assets/js/app.ts | 48 ++++++++-- lib/towerops/monitoring.ex | 18 ++++ lib/towerops_web/live/graph_live/show.ex | 87 ++++++++++++++----- .../live/graph_live/show.html.heex | 1 + 4 files changed, 123 insertions(+), 31 deletions(-) diff --git a/assets/js/app.ts b/assets/js/app.ts index 7fa5d476..066bc644 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -100,6 +100,7 @@ const SensorChart: SensorChartHook = { const unit = this.el.dataset.unit ?? '%' const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true' const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true' + const dualAxis = this.el.dataset.dualAxis === 'true' const range = this.el.dataset.range || '24h' const isTrafficChart = unit === 'bps' @@ -127,11 +128,24 @@ const SensorChart: SensorChartHook = { pointHoverRadius: 4, } - // Add fill for traffic charts to create area effect - if (isTrafficChart) { - baseConfig.fill = 'origin' // Fill to the zero line + // Preserve yAxisID from server data for dual-axis charts + if ((dataset as any).yAxisID) { + (baseConfig as any).yAxisID = (dataset as any).yAxisID + } + + // Status dataset (dual axis, right side) uses stepped line with fill + if (dualAxis && (dataset as any).yAxisID === 'y1') { + (baseConfig as any).stepped = 'before' + baseConfig.tension = 0 + baseConfig.fill = 'origin' + baseConfig.borderWidth = 1 + // Use green with low opacity for the status fill area + baseConfig.borderColor = 'rgb(34, 197, 94)' + baseConfig.backgroundColor = 'rgba(34, 197, 94, 0.1)' + } else if (isTrafficChart) { + baseConfig.fill = 'origin' } else { - baseConfig.fill = false // No fill for CPU/memory/storage charts + baseConfig.fill = false } return baseConfig @@ -206,7 +220,7 @@ const SensorChart: SensorChartHook = { if (unit === '') { return Math.round(value) } - return value + unit + return value + ' ' + unit } }, grid: { @@ -340,20 +354,40 @@ const SensorChart: SensorChartHook = { }) }, label: function(context: any) { + // Status dataset on dual-axis chart + if (dualAxis && context.dataset.yAxisID === 'y1') { + return context.dataset.label + ': ' + (context.parsed.y === 1 ? 'Up' : 'Down') + } if (unit === 'bps') { return context.dataset.label + ': ' + formatBps(context.parsed.y) } if (unit === '') { return context.dataset.label + ': ' + Math.round(context.parsed.y) } - return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + unit + return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + ' ' + unit } } } }, scales: { x: xAxisConfig, - y: yAxisConfig + y: yAxisConfig, + ...(dualAxis ? { + y1: { + position: 'right' as const, + min: -0.1, + max: 1.1, + grid: { drawOnChartArea: false }, + ticks: { + stepSize: 1, + callback: function(value: number) { + if (value === 1) return 'Up' + if (value === 0) return 'Down' + return '' + } + } + } + } : {}) } } }) diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex index cc2a08ba..19ef84a9 100644 --- a/lib/towerops/monitoring.ex +++ b/lib/towerops/monitoring.ex @@ -255,6 +255,24 @@ defmodule Towerops.Monitoring do Enum.sort_by(historical_results ++ recent_results, & &1.timestamp, DateTime) end + @doc """ + Gets graph data for a check including status field. + + Returns list of %{timestamp: DateTime.t(), value: float() | nil, status: integer()} sorted by timestamp. + Used for DNS checks that need to show both availability and response time. + """ + def get_check_graph_data_with_status(check_id, from_time, to_time) do + Repo.all( + from(r in CheckResult, + where: r.check_id == ^check_id, + where: r.checked_at >= ^from_time, + where: r.checked_at <= ^to_time, + order_by: [asc: r.checked_at], + select: %{timestamp: r.checked_at, value: r.value, status: r.status} + ) + ) + end + @doc """ Updates check state after a check execution. Handles soft/hard state transitions. diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 0e14a875..920c479d 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -110,12 +110,68 @@ defmodule ToweropsWeb.GraphLive.Show do defp initialize_check_graph_view(socket, device, check, params) do range = Map.get(params, "range", "24h") - - # Load graph data for the check {from_time, to_time} = get_time_range_for_graph(range) - graph_data_points = Monitoring.get_check_graph_data(check.id, from_time, to_time) - # Convert to chart format + {chart_data, unit, auto_scale, dual_axis} = build_check_chart_data(check, from_time, to_time) + {max_value, min_value} = calculate_chart_stats(chart_data) + + socket + |> assign(:device, device) + |> assign(:device_id, device.id) + |> assign(:check, check) + |> assign(:check_id, check.id) + |> assign(:range, range) + |> assign(:page_title, "#{device.name} - #{check.name}") + |> assign(:chart_title, check.name) + |> assign(:chart_data, chart_data) + |> assign(:unit, unit) + |> assign(:auto_scale, auto_scale) + |> assign(:dual_axis, dual_axis) + |> assign(:show_zero_line, false) + |> assign(:max_value, max_value) + |> assign(:min_value, min_value) + |> assign(:is_live_mode, false) + |> assign(:has_agent_assignment, false) + end + + defp build_check_chart_data(%{check_type: "dns"} = check, from_time, to_time) do + points = Monitoring.get_check_graph_data_with_status(check.id, from_time, to_time) + + if Enum.empty?(points) do + {nil, "ms", true, false} + else + response_time_dataset = %{ + label: "Response Time (ms)", + yAxisID: "y", + data: + points + |> Enum.filter(fn p -> p.value != nil end) + |> Enum.map(fn p -> + %{x: DateTime.to_unix(p.timestamp, :millisecond), y: Float.round(p.value, 1)} + end) + } + + status_dataset = %{ + label: "Status", + yAxisID: "y1", + data: + Enum.map(points, fn p -> + %{ + x: DateTime.to_unix(p.timestamp, :millisecond), + y: if(p.status == 0, do: 1, else: 0) + } + end) + } + + chart_data = Jason.encode!(%{datasets: [response_time_dataset, status_dataset]}) + {chart_data, "ms", true, true} + end + end + + defp build_check_chart_data(check, from_time, to_time) do + graph_data_points = Monitoring.get_check_graph_data(check.id, from_time, to_time) + {unit, auto_scale} = get_check_unit_and_scale(check) + chart_data = if Enum.empty?(graph_data_points) do nil @@ -134,26 +190,7 @@ defmodule ToweropsWeb.GraphLive.Show do Jason.encode!(%{datasets: [dataset]}) end - # Get unit and title based on check type and config - {unit, auto_scale} = get_check_unit_and_scale(check) - {max_value, min_value} = calculate_chart_stats(chart_data) - - socket - |> assign(:device, device) - |> assign(:device_id, device.id) - |> assign(:check, check) - |> assign(:check_id, check.id) - |> assign(:range, range) - |> assign(:page_title, "#{device.name} - #{check.name}") - |> assign(:chart_title, check.name) - |> assign(:chart_data, chart_data) - |> assign(:unit, unit) - |> assign(:auto_scale, auto_scale) - |> assign(:show_zero_line, false) - |> assign(:max_value, max_value) - |> assign(:min_value, min_value) - |> assign(:is_live_mode, false) - |> assign(:has_agent_assignment, false) + {chart_data, unit, auto_scale, false} end defp get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()} @@ -284,6 +321,7 @@ defmodule ToweropsWeb.GraphLive.Show do |> assign(:chart_data, chart_data) |> assign(:unit, unit) |> assign(:auto_scale, auto_scale) + |> assign(:dual_axis, false) |> assign(:show_zero_line, show_zero_line) |> assign(:max_value, max_value) |> assign(:min_value, min_value) @@ -306,6 +344,7 @@ defmodule ToweropsWeb.GraphLive.Show do |> assign(:chart_data, Jason.encode!(%{datasets: []})) |> assign(:unit, unit) |> assign(:auto_scale, auto_scale) + |> assign(:dual_axis, false) |> assign(:show_zero_line, sensor_type == "traffic") |> assign(:max_value, nil) |> assign(:min_value, nil) diff --git a/lib/towerops_web/live/graph_live/show.html.heex b/lib/towerops_web/live/graph_live/show.html.heex index b3d41e15..50ef4905 100644 --- a/lib/towerops_web/live/graph_live/show.html.heex +++ b/lib/towerops_web/live/graph_live/show.html.heex @@ -71,6 +71,7 @@ data-unit={@unit} data-auto-scale={to_string(@auto_scale)} data-show-zero-line={to_string(@show_zero_line)} + data-dual-axis={to_string(@dual_axis)} data-range={@range} style="height: 600px;" >