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: graham/towerops-web#74
This commit is contained in:
Graham McIntire 2026-03-18 15:13:31 -05:00 committed by graham
parent 272d4bcdc9
commit 9d0c824aa5
4 changed files with 123 additions and 31 deletions

View file

@ -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 ''
}
}
}
} : {})
}
}
})

View file

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

View file

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

View file

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