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:
parent
cb9becae4e
commit
f703e61b12
18 changed files with 184 additions and 233 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
|
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
|
||||||
135
assets/js/app.ts
135
assets/js/app.ts
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue