fix: code quality improvements and test reliability (#139)

Various code quality improvements and fixes:

- Settings: return error instead of auto-creating unknown settings
- PagerDuty: proper backoff timing with to_timeout()
- Gaiia sync: remove unnecessary transaction wrapper
- HTTP/UISP: simplify test plug setup
- Agent channel: tagged tuple config for check types
- SNMP: fix Task.yield/shutdown pattern, remove stale preload
- Discovery: simplify error handling
- Tests: bump executor timeouts for CI reliability
- Dockerfile: remove unnecessary --force compile
- Renovate/CI: config cleanup

Split from #135.

Reviewed-on: graham/towerops-web#139
This commit is contained in:
Graham McIntire 2026-03-24 09:12:59 -05:00 committed by graham
parent cb9becae4e
commit f703e61b12
18 changed files with 184 additions and 233 deletions

View file

@ -15,7 +15,6 @@ concurrency:
jobs: jobs:
deploy-staging: deploy-staging:
name: Deploy to Dokku Staging name: Deploy to Dokku Staging
if: ${{ !startsWith(github.head_ref, 'renovate/') }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout code - name: Checkout code

View file

@ -1 +1 @@
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json /nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json

View file

@ -28,7 +28,6 @@ import Chart from "../vendor/chart"
import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart" import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart"
import cytoscape from "../vendor/cytoscape" import cytoscape from "../vendor/cytoscape"
import type { SensorChartHook } from "./types/liveview" import type { SensorChartHook } from "./types/liveview"
import { buildChartDatasets } from "./chart_dataset_config.mjs"
import { DeviceListReorder } from "./device_list_reorder" import { DeviceListReorder } from "./device_list_reorder"
import { initCookieConsent } from "./cookie_consent" import { initCookieConsent } from "./cookie_consent"
@ -105,7 +104,52 @@ const SensorChart: SensorChartHook = {
const range = this.el.dataset.range || '24h' const range = this.el.dataset.range || '24h'
const isTrafficChart = unit === 'bps' const isTrafficChart = unit === 'bps'
const datasets = buildChartDatasets(data, { isTrafficChart, dualAxis }) as ChartDataset[] // Generate colors for each dataset
const colors = [
'rgb(59, 130, 246)', // blue
'rgb(239, 68, 68)', // red
'rgb(34, 197, 94)', // green
'rgb(234, 179, 8)', // yellow
'rgb(168, 85, 247)', // purple
'rgb(236, 72, 153)', // pink
'rgb(14, 165, 233)', // sky
'rgb(249, 115, 22)', // orange
]
const datasets: ChartDataset[] = data.datasets.map((dataset, index) => {
const color = colors[index % colors.length]
const baseConfig: ChartDataset = {
...dataset,
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
borderWidth: 2,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
}
// 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
}
return baseConfig
})
// Format bits per second into human-readable units // Format bits per second into human-readable units
const formatBps = (bps: number): string => { const formatBps = (bps: number): string => {
@ -271,8 +315,7 @@ const SensorChart: SensorChartHook = {
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
} }
}, },
maxTicksLimit: 9, maxTicksLimit: 12
includeBounds: false
}, },
grid: { grid: {
display: false display: false
@ -578,10 +621,6 @@ const BetaBannerDismiss = {
const NetworkMap = { const NetworkMap = {
cy: null as any, cy: null as any,
_topologyData: null as any, _topologyData: null as any,
_zoomInHandler: null as (() => void) | null,
_zoomOutHandler: null as (() => void) | null,
_fitHandler: null as (() => void) | null,
mounted(this: any) { mounted(this: any) {
const topologyData = JSON.parse(this.el.dataset.topology || '{}') const topologyData = JSON.parse(this.el.dataset.topology || '{}')
this._topologyData = topologyData this._topologyData = topologyData
@ -620,24 +659,6 @@ const NetworkMap = {
}, },
destroyed(this: any) { destroyed(this: any) {
// Clean up event listeners
const zoomIn = document.getElementById('cy-zoom-in')
const zoomOut = document.getElementById('cy-zoom-out')
const fit = document.getElementById('cy-fit')
if (zoomIn && this._zoomInHandler) {
zoomIn.removeEventListener('click', this._zoomInHandler)
this._zoomInHandler = null
}
if (zoomOut && this._zoomOutHandler) {
zoomOut.removeEventListener('click', this._zoomOutHandler)
this._zoomOutHandler = null
}
if (fit && this._fitHandler) {
fit.removeEventListener('click', this._fitHandler)
this._fitHandler = null
}
if (this.cy) { if (this.cy) {
// Stop any running layout before destroying to prevent async callbacks // Stop any running layout before destroying to prevent async callbacks
// from accessing a destroyed renderer // from accessing a destroyed renderer
@ -657,33 +678,30 @@ const NetworkMap = {
const fit = document.getElementById('cy-fit') const fit = document.getElementById('cy-fit')
if (zoomIn) { if (zoomIn) {
this._zoomInHandler = () => { zoomIn.addEventListener('click', () => {
if (!this.cy) return if (!this.cy) return
this.cy.zoom({ this.cy.zoom({
level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()), level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()),
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
}) })
} })
zoomIn.addEventListener('click', this._zoomInHandler)
} }
if (zoomOut) { if (zoomOut) {
this._zoomOutHandler = () => { zoomOut.addEventListener('click', () => {
if (!this.cy) return if (!this.cy) return
this.cy.zoom({ this.cy.zoom({
level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()), level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()),
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
}) })
} })
zoomOut.addEventListener('click', this._zoomOutHandler)
} }
if (fit) { if (fit) {
this._fitHandler = () => { fit.addEventListener('click', () => {
if (!this.cy) return if (!this.cy) return
this.cy.fit(undefined, 50) this.cy.fit(undefined, 50)
} })
fit.addEventListener('click', this._fitHandler)
} }
}, },
@ -1720,21 +1738,11 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
// Global Search (Cmd+K / Ctrl+K) hook // Global Search (Cmd+K / Ctrl+K) hook
const GlobalSearchTrigger = { const GlobalSearchTrigger = {
handleClick: null as ((e: Event) => void) | null, mounted() {
this.el.addEventListener("click", () => {
mounted(this: any) {
this.handleClick = () => {
// Simulate Cmd+K to open global search // Simulate Cmd+K to open global search
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })) document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }))
} })
this.el.addEventListener("click", this.handleClick)
},
destroyed(this: any) {
if (this.handleClick) {
this.el.removeEventListener("click", this.handleClick)
this.handleClick = null
}
} }
} }
@ -1774,44 +1782,23 @@ const StatusTitle = {
// Sidebar collapse - toggle class on <html> (which LiveView never patches) // Sidebar collapse - toggle class on <html> (which LiveView never patches)
// so the state persists across all navigations without flicker. // so the state persists across all navigations without flicker.
const SidebarCollapse = { const SidebarCollapse = {
handleClick: null as ((e: Event) => void) | null,
mounted(this: any) { mounted(this: any) {
this.handleClick = (e: Event) => { this.el.addEventListener('click', (e: Event) => {
if ((e.target as HTMLElement).closest('[data-sidebar-toggle]')) { if ((e.target as HTMLElement).closest('[data-sidebar-toggle]')) {
const html = document.documentElement const html = document.documentElement
html.classList.toggle('sidebar-collapsed') html.classList.toggle('sidebar-collapsed')
localStorage.setItem('sidebarCollapsed', String(html.classList.contains('sidebar-collapsed'))) localStorage.setItem('sidebarCollapsed', String(html.classList.contains('sidebar-collapsed')))
} }
} })
this.el.addEventListener('click', this.handleClick)
},
destroyed(this: any) {
if (this.handleClick) {
this.el.removeEventListener('click', this.handleClick)
this.handleClick = null
}
} }
} }
const ThemeSelector = { const ThemeSelector = {
handleThemeChange: null as (() => void) | null, mounted() {
mounted(this: any) {
this.updateActive() this.updateActive()
this.handleThemeChange = () => this.updateActive() window.addEventListener("phx:set-theme", () => this.updateActive())
window.addEventListener("phx:set-theme", this.handleThemeChange)
}, },
updateActive() {
destroyed(this: any) {
if (this.handleThemeChange) {
window.removeEventListener("phx:set-theme", this.handleThemeChange)
this.handleThemeChange = null
}
},
updateActive(this: any) {
const current = localStorage.getItem("phx:theme") || "system" const current = localStorage.getItem("phx:theme") || "system"
this.el.querySelectorAll<HTMLElement>("[data-phx-theme]").forEach((btn) => { this.el.querySelectorAll<HTMLElement>("[data-phx-theme]").forEach((btn) => {
const isActive = btn.dataset.phxTheme === current const isActive = btn.dataset.phxTheme === current

View file

@ -80,9 +80,9 @@ RUN cd c_src && make
COPY lib lib COPY lib lib
# Compile the release (--force prevents stale BEAM files from schema changes) # Compile the release
RUN --mount=type=cache,target=/root/.mix \ RUN --mount=type=cache,target=/root/.mix \
mix compile --force mix compile
COPY assets assets COPY assets assets

View file

@ -11,7 +11,6 @@ defmodule Towerops.Gaiia.Sync do
alias Towerops.Gaiia.Client alias Towerops.Gaiia.Client
alias Towerops.Gaiia.SubscriberMatching alias Towerops.Gaiia.SubscriberMatching
alias Towerops.Integrations alias Towerops.Integrations
alias Towerops.Repo
require Logger require Logger
@ -58,24 +57,13 @@ defmodule Towerops.Gaiia.Sync do
end end
defp upsert_nodes(org_id, nodes, type) do defp upsert_nodes(org_id, nodes, type) do
case Repo.transaction(fn -> do_upsert_nodes(org_id, nodes, type) end) do
{:ok, count} ->
count
{:error, reason} ->
Logger.error("Gaiia #{type} upsert transaction failed: #{inspect(reason)}")
0
end
end
defp do_upsert_nodes(org_id, nodes, type) do
Enum.reduce(nodes, 0, fn node, count -> Enum.reduce(nodes, 0, fn node, count ->
case upsert_node(org_id, node, type) do case upsert_node(org_id, node, type) do
{:ok, _} -> {:ok, _} ->
count + 1 count + 1
{:error, cs} -> {:error, changeset} ->
Logger.warning("Failed to upsert Gaiia #{type}: #{inspect(cs.errors)}") Logger.warning("Failed to upsert Gaiia #{type}: #{inspect(changeset.errors)}")
count count
end end
end) end)

View file

@ -36,10 +36,7 @@ defmodule Towerops.HTTP do
defp maybe_attach_test_plug(req_opts, owner) do defp maybe_attach_test_plug(req_opts, owner) do
if Application.get_env(:towerops, :env) == :test and !Keyword.has_key?(req_opts, :plug) do if Application.get_env(:towerops, :env) == :test and !Keyword.has_key?(req_opts, :plug) do
req_opts Keyword.put(req_opts, :plug, {Req.Test, owner})
|> Keyword.put(:plug, {Req.Test, owner})
|> Keyword.put_new(:retry, false)
|> Keyword.put_new(:receive_timeout, 1000)
else else
req_opts req_opts
end end

View file

@ -101,7 +101,7 @@ defmodule Towerops.PagerDuty.Client do
retry_after = extract_retry_after(response, retries) retry_after = extract_retry_after(response, retries)
Logger.info("PagerDuty rate limited, retrying in #{retry_after}ms (attempt #{retries + 1}/#{@max_retries})") Logger.info("PagerDuty rate limited, retrying in #{retry_after}ms (attempt #{retries + 1}/#{@max_retries})")
sleep(retry_after) Process.sleep(retry_after)
send_event_with_retry(body, retries + 1) send_event_with_retry(body, retries + 1)
{:ok, %{status: status}} -> {:ok, %{status: status}} ->
@ -132,22 +132,8 @@ defmodule Towerops.PagerDuty.Client do
end end
end end
# Exponential backoff: 1s, 2s, 4s (configurable for tests) # Exponential backoff: 1s, 2s, 4s
defp backoff_ms(retries) do defp backoff_ms(retries), do: to_timeout(second: round(:math.pow(2, retries)))
# In test env, use 1ms sleep to speed up tests
multiplier = if Application.get_env(:towerops, :env) == :test, do: 1, else: 1000
round(:math.pow(2, retries)) * multiplier
end
# Allow mocking sleep in tests
defp sleep(ms) do
# In test env, use minimal sleep
if Application.get_env(:towerops, :env) == :test do
Process.sleep(1)
else
Process.sleep(ms)
end
end
defp dedup_key(alert), do: "towerops-alert-#{alert.id}" defp dedup_key(alert), do: "towerops-alert-#{alert.id}"

View file

@ -60,13 +60,11 @@ defmodule Towerops.Snmp.DeferredDiscovery do
task = Task.async(fn -> check_fn.() end) task = Task.async(fn -> check_fn.() end)
case Task.yield(task, timeout) do case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, result} -> {:ok, result} ->
result result
nil -> nil ->
# Task timed out - kill it and use default
Task.shutdown(task, :brutal_kill)
Logger.warning("Slow check timed out after #{timeout}ms, using default value") Logger.warning("Slow check timed out after #{timeout}ms, using default value")
{:ok, default_value} {:ok, default_value}
end end

View file

@ -233,21 +233,19 @@ defmodule Towerops.Snmp.Discovery do
[] []
end end
# Secondary discovery: VLANs, IPs, processors, storage # Secondary discovery: VLANs, IPs, processors, storage (already fall back to [] on timeout)
Logger.info("Discovering VLANs...", device_id: device.id) Logger.info("Discovering VLANs...", device_id: device.id)
vlans = safe_discover(fn -> discover_vlans_with_timeout(client_opts, profile, timeouts) end, "VLAN", device.id) {:ok, vlans} = discover_vlans_with_timeout(client_opts, profile, timeouts)
Logger.info("Discovering IP addresses...", device_id: device.id) Logger.info("Discovering IP addresses...", device_id: device.id)
ip_addresses = safe_discover(fn -> discover_ip_addresses_with_timeout(client_opts, timeouts) end, "IP", device.id) {:ok, ip_addresses} = discover_ip_addresses_with_timeout(client_opts, timeouts)
_ = log_ip_discovery_results(device, ip_addresses) _ = log_ip_discovery_results(device, ip_addresses)
Logger.info("Discovering processors...", device_id: device.id) Logger.info("Discovering processors...", device_id: device.id)
{:ok, processors} = discover_processors_with_timeout(client_opts, timeouts)
processors =
safe_discover(fn -> discover_processors_with_timeout(client_opts, timeouts) end, "Processor", device.id)
Logger.info("Discovering storage...", device_id: device.id) Logger.info("Discovering storage...", device_id: device.id)
storage = safe_discover(fn -> discover_storage_with_timeout(client_opts, timeouts) end, "Storage", device.id) {:ok, storage} = discover_storage_with_timeout(client_opts, timeouts)
Logger.info("Saving discovery results (including IP addresses and processors)...", device_id: device.id) Logger.info("Saving discovery results (including IP addresses and processors)...", device_id: device.id)
@ -854,8 +852,8 @@ defmodule Towerops.Snmp.Discovery do
Repo.transaction( Repo.transaction(
fn -> fn ->
# Upsert Device # Upsert Device
case upsert_device(device, sanitized_device_info) do snmp_device = upsert_device(device, sanitized_device_info)
{:ok, snmp_device} ->
# Sync interfaces, sensors, and VLANs (preserving historical data) # Sync interfaces, sensors, and VLANs (preserving historical data)
synced_interfaces = sync_interfaces(snmp_device, interfaces) synced_interfaces = sync_interfaces(snmp_device, interfaces)
_ = sync_sensors(snmp_device, sensors) _ = sync_sensors(snmp_device, sensors)
@ -873,11 +871,6 @@ defmodule Towerops.Snmp.Discovery do
# Return device with interfaces loaded and device_id added # Return device with interfaces loaded and device_id added
# Use snmp_device.device_id which references the Equipment table # Use snmp_device.device_id which references the Equipment table
device_with_interfaces device_with_interfaces
{:error, changeset} ->
Logger.error("Failed to upsert SNMP device: #{inspect(changeset.errors)}")
Repo.rollback({:upsert_failed, changeset})
end
end, end,
timeout: 60_000 timeout: 60_000
) )
@ -919,39 +912,47 @@ defmodule Towerops.Snmp.Discovery do
|> Map.put(:last_discovery_at, last_discovery_at) |> Map.put(:last_discovery_at, last_discovery_at)
end end
@spec upsert_device(DeviceSchema.t(), map()) :: {:ok, Device.t()} | {:error, Ecto.Changeset.t()} @spec upsert_device(DeviceSchema.t(), map()) :: Device.t()
defp upsert_device(device, device_attrs) do defp upsert_device(device, device_attrs) do
Logger.info("Upserting SNMP device for #{device.name}") Logger.info("Upserting SNMP device for #{device.name}")
existing_device = Repo.get_by(Device, device_id: device.id) case Repo.get_by(Device, device_id: device.id) do
old_version = existing_device && existing_device.firmware_version nil ->
new_version = device_attrs[:firmware_version] snmp_device =
%Device{}
|> Device.changeset(device_attrs)
|> Repo.insert!()
changeset = Device.changeset(existing_device || %Device{}, device_attrs) # Log initial firmware version (no old version)
if snmp_device.firmware_version do
result = Firmware.log_firmware_change(
Repo.insert_or_update(changeset, snmp_device.id,
on_conflict: {:replace_all_except, [:id, :inserted_at]}, nil,
conflict_target: :device_id snmp_device.firmware_version
) )
case result do
{:ok, snmp_device} ->
cond do
is_nil(existing_device) && snmp_device.firmware_version ->
Firmware.log_firmware_change(snmp_device.id, nil, snmp_device.firmware_version)
version_changed?(old_version, new_version) ->
Firmware.log_firmware_change(snmp_device.id, old_version, new_version)
true ->
:ok
end end
{:ok, snmp_device} snmp_device
{:error, _changeset} = error -> existing_device ->
error old_version = existing_device.firmware_version
new_version = device_attrs[:firmware_version]
snmp_device =
existing_device
|> Device.changeset(device_attrs)
|> Repo.update!()
# Detect and log firmware version changes
if version_changed?(old_version, new_version) do
Firmware.log_firmware_change(
snmp_device.id,
old_version,
new_version
)
end
snmp_device
end end
end end
@ -1508,18 +1509,4 @@ defmodule Towerops.Snmp.Discovery do
List.last(group) List.last(group)
end) end)
end end
defp safe_discover(discovery_fn, resource_type, device_id) do
case discovery_fn.() do
{:ok, result} ->
result
{:error, :timeout} ->
[]
{:error, reason} ->
Logger.warning("#{resource_type} discovery failed: #{inspect(reason)}", device_id: device_id)
[]
end
end
end end

View file

@ -120,10 +120,7 @@ defmodule Towerops.Uisp.Client do
# Only use Req.Test plug in test environment # Only use Req.Test plug in test environment
opts = opts =
if Application.get_env(:towerops, :env) == :test do if Application.get_env(:towerops, :env) == :test do
opts Keyword.put(opts, :plug, {Req.Test, __MODULE__})
|> Keyword.put(:plug, {Req.Test, __MODULE__})
|> Keyword.put(:retry, false)
|> Keyword.put(:receive_timeout, 1000)
else else
opts opts
end end

View file

@ -910,46 +910,54 @@ defmodule ToweropsWeb.AgentChannel do
defp check_type_config("http", config) do defp check_type_config("http", config) do
[ [
http: %HttpCheckConfig{ config:
{:http,
%HttpCheckConfig{
url: config["url"] || "", url: config["url"] || "",
method: config["method"] || "GET", method: config["method"] || "GET",
expected_status: config["expected_status"] || 200, expected_status: config["expected_status"] || 200,
verify_ssl: config["verify_ssl"] != false, verify_ssl: config["verify_ssl"] != false,
regex: config["regex"] || "", regex: config["regex"] || "",
follow_redirects: config["follow_redirects"] != false follow_redirects: config["follow_redirects"] != false
} }}
] ]
end end
defp check_type_config("tcp", config) do defp check_type_config("tcp", config) do
[ [
tcp: %TcpCheckConfig{ config:
{:tcp,
%TcpCheckConfig{
host: config["host"] || "", host: config["host"] || "",
port: config["port"] || 0, port: config["port"] || 0,
send: config["send"] || "", send: config["send"] || "",
expect: config["expect"] || "" expect: config["expect"] || ""
} }}
] ]
end end
defp check_type_config("dns", config) do defp check_type_config("dns", config) do
[ [
dns: %DnsCheckConfig{ config:
{:dns,
%DnsCheckConfig{
hostname: config["hostname"] || "", hostname: config["hostname"] || "",
server: config["server"] || "", server: config["server"] || "",
record_type: config["record_type"] || "A", record_type: config["record_type"] || "A",
expected: config["expected"] || "" expected: config["expected"] || ""
} }}
] ]
end end
defp check_type_config("ssl", config) do defp check_type_config("ssl", config) do
[ [
ssl: %SslCheckConfig{ config:
{:ssl,
%SslCheckConfig{
host: config["host"] || "", host: config["host"] || "",
port: config["port"] || 443, port: config["port"] || 443,
warning_days: config["warning_days"] || 30 warning_days: config["warning_days"] || 30
} }}
] ]
end end

View file

@ -86,7 +86,7 @@
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"}, "styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.23.1", "19cbb5d675f272e9df958cbab1e24cb8911038df2b39efda81721b7f7ce7e281", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3193813b462d6dd519e907c680df04988c47bae372b4159e0c4c9f1c42dffea3"}, "swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"}, "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},

View file

@ -17,7 +17,6 @@
"prConcurrentLimit": 10, "prConcurrentLimit": 10,
"prCreation": "immediate", "prCreation": "immediate",
"automerge": false, "automerge": false,
"rebaseWhen": "behind-base-branch",
"rangeStrategy": "bump", "rangeStrategy": "bump",
"separateMajorMinor": true, "separateMajorMinor": true,
"separateMinorPatch": false, "separateMinorPatch": false,

View file

@ -64,7 +64,7 @@ defmodule Towerops.Monitoring.Executors.DnsExecutorTest do
test "returns error for timeout with unreachable server" do test "returns error for timeout with unreachable server" do
# Use a non-routable IP as DNS server to force timeout # Use a non-routable IP as DNS server to force timeout
config = %{"hostname" => "example.com", "server" => "192.0.2.1"} config = %{"hostname" => "example.com", "server" => "192.0.2.1"}
assert {:error, reason} = DnsExecutor.execute(config, 200) assert {:error, reason} = DnsExecutor.execute(config, 500)
assert is_binary(reason) assert is_binary(reason)
end end
end end

View file

@ -7,7 +7,7 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do
test "returns success with valid ping output" do test "returns success with valid ping output" do
config = %{"host" => "127.0.0.1", "count" => 1} config = %{"host" => "127.0.0.1", "count" => 1}
case PingExecutor.execute(config, 100) do case PingExecutor.execute(config, 10_000) do
{:ok, response_time, output} -> {:ok, response_time, output} ->
assert is_number(response_time) assert is_number(response_time)
assert response_time >= 0 assert response_time >= 0
@ -21,10 +21,10 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do
end end
test "defaults count to 3 when not provided" do test "defaults count to 3 when not provided" do
config = %{"host" => "127.0.0.1", "count" => 1} config = %{"host" => "127.0.0.1"}
# Use count=1 to speed up test # Should use default count of 3 — just verify it doesn't crash
result = PingExecutor.execute(config, 100) result = PingExecutor.execute(config, 10_000)
assert match?({:ok, _, _}, result) or match?({:error, _}, result) assert match?({:ok, _, _}, result) or match?({:error, _}, result)
end end
@ -32,7 +32,7 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do
# RFC 5737 TEST-NET address — should be unreachable # RFC 5737 TEST-NET address — should be unreachable
config = %{"host" => "192.0.2.1", "count" => 1} config = %{"host" => "192.0.2.1", "count" => 1}
case PingExecutor.execute(config, 200) do case PingExecutor.execute(config, 6000) do
{:error, reason} -> {:error, reason} ->
assert is_binary(reason) assert is_binary(reason)
@ -45,14 +45,14 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do
test "returns error for invalid host" do test "returns error for invalid host" do
config = %{"host" => "definitely-not-a-real-host-12345.invalid", "count" => 1} config = %{"host" => "definitely-not-a-real-host-12345.invalid", "count" => 1}
assert {:error, reason} = PingExecutor.execute(config, 200) assert {:error, reason} = PingExecutor.execute(config, 6000)
assert is_binary(reason) assert is_binary(reason)
end end
test "sanitizes host to prevent command injection" do test "sanitizes host to prevent command injection" do
config = %{"host" => "127.0.0.1; rm -rf /", "count" => 1} config = %{"host" => "127.0.0.1; rm -rf /", "count" => 1}
assert {:error, reason} = PingExecutor.execute(config, 100) assert {:error, reason} = PingExecutor.execute(config, 5000)
assert reason =~ "Invalid host" assert reason =~ "Invalid host"
end end
@ -60,7 +60,7 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do
config = %{"host" => "127.0.0.1", "count" => -1} config = %{"host" => "127.0.0.1", "count" => -1}
# Should clamp or reject negative count # Should clamp or reject negative count
result = PingExecutor.execute(config, 100) result = PingExecutor.execute(config, 10_000)
assert match?({:ok, _, _}, result) or match?({:error, _}, result) assert match?({:ok, _, _}, result) or match?({:error, _}, result)
end end
end end

View file

@ -17,7 +17,7 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
test "returns error for connection failure to non-routable address" do test "returns error for connection failure to non-routable address" do
config = %{"host" => "192.0.2.1", "port" => 443, "warning_days" => 30} config = %{"host" => "192.0.2.1", "port" => 443, "warning_days" => 30}
assert {:error, reason} = SslExecutor.execute(config, 300) assert {:error, reason} = SslExecutor.execute(config, 1_000)
assert is_binary(reason) assert is_binary(reason)
end end
@ -29,14 +29,14 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
config = %{"host" => "127.0.0.1", "port" => port, "warning_days" => 30} config = %{"host" => "127.0.0.1", "port" => port, "warning_days" => 30}
assert {:error, reason} = SslExecutor.execute(config, 500) assert {:error, reason} = SslExecutor.execute(config, 2_000)
assert is_binary(reason) assert is_binary(reason)
end end
test "returns error for missing host key" do test "returns error for missing host key" do
config = %{"port" => 443, "warning_days" => 30} config = %{"port" => 443, "warning_days" => 30}
assert {:error, "Exception:" <> _} = SslExecutor.execute(config, 1_000) assert {:error, "Exception:" <> _} = SslExecutor.execute(config, 5_000)
end end
test "defaults port to 443 when not specified" do test "defaults port to 443 when not specified" do
@ -44,13 +44,13 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
# but verify the function doesn't crash # but verify the function doesn't crash
config = %{"host" => "192.0.2.1", "warning_days" => 30} config = %{"host" => "192.0.2.1", "warning_days" => 30}
assert {:error, _reason} = SslExecutor.execute(config, 200) assert {:error, _reason} = SslExecutor.execute(config, 500)
end end
test "defaults warning_days to 30 when not specified" do test "defaults warning_days to 30 when not specified" do
config = %{"host" => "192.0.2.1", "port" => 443} config = %{"host" => "192.0.2.1", "port" => 443}
assert {:error, _reason} = SslExecutor.execute(config, 200) assert {:error, _reason} = SslExecutor.execute(config, 500)
end end
end end

View file

@ -66,7 +66,7 @@ defmodule Towerops.Monitoring.Executors.TcpExecutorTest do
test "returns error for connection timeout" do test "returns error for connection timeout" do
# Use a non-routable IP to force timeout # Use a non-routable IP to force timeout
config = %{"host" => "192.0.2.1", "port" => 80} config = %{"host" => "192.0.2.1", "port" => 80}
assert {:error, reason} = TcpExecutor.execute(config, 200) assert {:error, reason} = TcpExecutor.execute(config, 500)
assert String.contains?(reason, "timeout") or String.contains?(reason, "unreachable") or assert String.contains?(reason, "timeout") or String.contains?(reason, "unreachable") or
String.contains?(reason, "failed") String.contains?(reason, "failed")

View file

@ -4,9 +4,17 @@ defmodule Towerops.SettingsTest do
alias Towerops.Settings alias Towerops.Settings
alias Towerops.Settings.ApplicationSetting alias Towerops.Settings.ApplicationSetting
# Ensure the migration-seeded setting exists (CI may have stale test DB)
setup do
unless Settings.get_setting_record("global_default_cloud_poller_id") do
{:ok, _} = create_setting("global_default_cloud_poller_id", nil, "uuid")
end
:ok
end
describe "get_setting/1" do describe "get_setting/1" do
test "returns parsed value for existing setting" do test "returns parsed value for existing setting" do
{:ok, _} = create_setting("global_default_cloud_poller_id", nil, "uuid")
assert Settings.get_setting("global_default_cloud_poller_id") == nil assert Settings.get_setting("global_default_cloud_poller_id") == nil
end end
@ -75,7 +83,6 @@ defmodule Towerops.SettingsTest do
describe "get_setting_record/1" do describe "get_setting_record/1" do
test "returns the raw ApplicationSetting record" do test "returns the raw ApplicationSetting record" do
{:ok, _} = create_setting("global_default_cloud_poller_id", nil, "uuid")
record = Settings.get_setting_record("global_default_cloud_poller_id") record = Settings.get_setting_record("global_default_cloud_poller_id")
assert %ApplicationSetting{} = record assert %ApplicationSetting{} = record
assert record.key == "global_default_cloud_poller_id" assert record.key == "global_default_cloud_poller_id"
@ -89,7 +96,6 @@ defmodule Towerops.SettingsTest do
describe "update_setting/2" do describe "update_setting/2" do
test "updates an existing setting value" do test "updates an existing setting value" do
{:ok, _} = create_setting("global_default_cloud_poller_id", nil, "uuid")
agent_token_id = Ecto.UUID.generate() agent_token_id = Ecto.UUID.generate()
{:ok, updated} = Settings.update_setting("global_default_cloud_poller_id", agent_token_id) {:ok, updated} = Settings.update_setting("global_default_cloud_poller_id", agent_token_id)
@ -98,7 +104,6 @@ defmodule Towerops.SettingsTest do
end end
test "updates setting to nil" do test "updates setting to nil" do
{:ok, _} = create_setting("global_default_cloud_poller_id", nil, "uuid")
agent_token_id = Ecto.UUID.generate() agent_token_id = Ecto.UUID.generate()
{:ok, _} = Settings.update_setting("global_default_cloud_poller_id", agent_token_id) {:ok, _} = Settings.update_setting("global_default_cloud_poller_id", agent_token_id)
{:ok, updated} = Settings.update_setting("global_default_cloud_poller_id", nil) {:ok, updated} = Settings.update_setting("global_default_cloud_poller_id", nil)