parent
c4e301a84c
commit
30248504e2
11 changed files with 110 additions and 54 deletions
|
|
@ -1827,12 +1827,39 @@ const GlobalSearch = {
|
||||||
|
|
||||||
// Status Title - Updates page title with status emoji when alerts change
|
// Status Title - Updates page title with status emoji when alerts change
|
||||||
const StatusTitle = {
|
const StatusTitle = {
|
||||||
|
baseTitle: null as string | null,
|
||||||
|
|
||||||
mounted(this: any) {
|
mounted(this: any) {
|
||||||
this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => {
|
// Store the initial base title (without any existing emoji)
|
||||||
|
this.baseTitle = document.title.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
|
||||||
|
|
||||||
|
// Update base title whenever page title changes (from LiveView navigation)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
const currentTitle = document.title
|
const currentTitle = document.title
|
||||||
const titleWithoutEmoji = currentTitle.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
|
// Only update baseTitle if it's not an emoji update (i.e., it's a real navigation)
|
||||||
document.title = emoji ? `${emoji} ${titleWithoutEmoji}` : titleWithoutEmoji
|
if (currentTitle && !currentTitle.startsWith('🔴') && !currentTitle.startsWith('🟡') && !currentTitle.startsWith('🟢')) {
|
||||||
|
this.baseTitle = currentTitle
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
observer.observe(document.querySelector('title')!, {
|
||||||
|
childList: true,
|
||||||
|
characterData: true,
|
||||||
|
subtree: true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => {
|
||||||
|
// Always use the stored baseTitle instead of reading from document.title
|
||||||
|
const baseTitle = this.baseTitle || document.title.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
|
||||||
|
document.title = emoji ? `${emoji} ${baseTitle}` : baseTitle
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed(this: any) {
|
||||||
|
// Clean up observer if it exists
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule Towerops.HTTP do
|
||||||
def request(owner, req_opts) when is_list(req_opts) do
|
def request(owner, req_opts) when is_list(req_opts) do
|
||||||
req_opts
|
req_opts
|
||||||
|> maybe_attach_test_plug(owner)
|
|> maybe_attach_test_plug(owner)
|
||||||
|
# |> maybe_disable_retry() # Temporarily disabled to debug PagerDuty tests
|
||||||
|> Req.request()
|
|> Req.request()
|
||||||
rescue
|
rescue
|
||||||
error in RuntimeError ->
|
error in RuntimeError ->
|
||||||
|
|
|
||||||
|
|
@ -154,12 +154,35 @@ defmodule Towerops.Monitoring do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a check.
|
Creates a check.
|
||||||
|
|
||||||
|
For auto-discovery checks, uses upsert logic to update existing checks
|
||||||
|
instead of creating duplicates.
|
||||||
"""
|
"""
|
||||||
def create_check(attrs \\ %{}) do
|
def create_check(attrs \\ %{}) do
|
||||||
|
# Use upsert for auto-discovery checks to avoid duplicates
|
||||||
result =
|
result =
|
||||||
%Check{}
|
if Map.get(attrs, :source_type) == "auto_discovery" and Map.has_key?(attrs, :source_id) do
|
||||||
|> Check.changeset(attrs)
|
# For partial unique indexes, find existing record and update it
|
||||||
|> Repo.insert()
|
case Repo.get_by(Check,
|
||||||
|
device_id: attrs.device_id,
|
||||||
|
check_type: attrs.check_type,
|
||||||
|
source_id: attrs.source_id
|
||||||
|
) do
|
||||||
|
nil ->
|
||||||
|
%Check{}
|
||||||
|
|> Check.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
existing_check ->
|
||||||
|
existing_check
|
||||||
|
|> Check.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
%Check{}
|
||||||
|
|> Check.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
case result do
|
case result do
|
||||||
{:ok, check} -> broadcast_check_change(check)
|
{:ok, check} -> broadcast_check_change(check)
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,10 @@ defmodule Towerops.Monitoring.Check do
|
||||||
|> validate_number(:max_check_attempts, greater_than: 0)
|
|> validate_number(:max_check_attempts, greater_than: 0)
|
||||||
|> validate_number(:timeout_ms, greater_than: 0)
|
|> validate_number(:timeout_ms, greater_than: 0)
|
||||||
|> unique_constraint(:check_key)
|
|> unique_constraint(:check_key)
|
||||||
|
|> unique_constraint([:device_id, :check_type, :source_id],
|
||||||
|
name: :checks_device_type_source_unique_index,
|
||||||
|
message: "check already exists for this device and source"
|
||||||
|
)
|
||||||
|> foreign_key_constraint(:organization_id)
|
|> foreign_key_constraint(:organization_id)
|
||||||
|> foreign_key_constraint(:device_id)
|
|> foreign_key_constraint(:device_id)
|
||||||
|> foreign_key_constraint(:agent_token_id)
|
|> foreign_key_constraint(:agent_token_id)
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,14 @@ defmodule Towerops.PagerDuty.Client do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Retry up to 3 times on rate limiting with exponential backoff
|
# Retry up to 3 times on rate limiting with exponential backoff
|
||||||
@max_retries 3
|
# In test env, disable retries to speed up tests
|
||||||
|
if Mix.env() == :test do
|
||||||
|
@max_retries 0
|
||||||
|
else
|
||||||
|
@max_retries 3
|
||||||
|
end
|
||||||
|
|
||||||
defp send_event_with_retry(_body, retries) when retries >= @max_retries do
|
defp send_event_with_retry(_body, retries) when retries > @max_retries do
|
||||||
Logger.warning("PagerDuty rate limited after #{@max_retries} retries, giving up")
|
Logger.warning("PagerDuty rate limited after #{@max_retries} retries, giving up")
|
||||||
{:error, :rate_limited}
|
{:error, :rate_limited}
|
||||||
end
|
end
|
||||||
|
|
@ -132,8 +137,8 @@ defmodule Towerops.PagerDuty.Client do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Exponential backoff: 1s, 2s, 4s
|
# Exponential backoff: 1s, 2s, 4s (in milliseconds)
|
||||||
defp backoff_ms(retries), do: to_timeout(second: round(:math.pow(2, retries)))
|
defp backoff_ms(retries), do: round(:math.pow(2, retries)) * 1000
|
||||||
|
|
||||||
defp dedup_key(alert), do: "towerops-alert-#{alert.id}"
|
defp dedup_key(alert), do: "towerops-alert-#{alert.id}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,19 @@ defmodule Towerops.Profiles.YamlProfiles do
|
||||||
GenServer.call(__MODULE__, :reload, 30_000)
|
GenServer.call(__MODULE__, :reload, 30_000)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Test helper: Build a profile directly from YAML content without loading from disk or ETS.
|
||||||
|
This avoids the expensive reload operation that loads all 786 profiles.
|
||||||
|
"""
|
||||||
|
if Mix.env() == :test do
|
||||||
|
def build_profile_from_yaml(name, detection_yaml, discovery_yaml) do
|
||||||
|
with {:ok, detection} <- YamlElixir.read_from_string(detection_yaml || ""),
|
||||||
|
{:ok, discovery} <- YamlElixir.read_from_string(discovery_yaml || "") do
|
||||||
|
build_profile(name, detection, discovery)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# GenServer callbacks
|
# GenServer callbacks
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -118,9 +118,12 @@ defmodule Towerops.Uisp.Client do
|
||||||
]
|
]
|
||||||
|
|
||||||
# Only use Req.Test plug in test environment
|
# Only use Req.Test plug in test environment
|
||||||
|
# Also disable retry in tests to avoid 7s timeout (1s + 2s + 4s exponential backoff)
|
||||||
opts =
|
opts =
|
||||||
if Application.get_env(:towerops, :env) == :test do
|
if Application.get_env(:towerops, :env) == :test do
|
||||||
Keyword.put(opts, :plug, {Req.Test, __MODULE__})
|
opts
|
||||||
|
|> Keyword.put(:plug, {Req.Test, __MODULE__})
|
||||||
|
|> Keyword.put(:retry, false)
|
||||||
else
|
else
|
||||||
opts
|
opts
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,9 @@ 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
|
||||||
|
# Reduced timeout to 100ms for faster tests (still validates timeout behavior)
|
||||||
config = %{"hostname" => "example.com", "server" => "192.0.2.1"}
|
config = %{"hostname" => "example.com", "server" => "192.0.2.1"}
|
||||||
assert {:error, reason} = DnsExecutor.execute(config, 500)
|
assert {:error, reason} = DnsExecutor.execute(config, 100)
|
||||||
assert is_binary(reason)
|
assert is_binary(reason)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ 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, 1_000)
|
# Reduced timeout to 100ms for faster tests
|
||||||
|
assert {:error, reason} = SslExecutor.execute(config, 100)
|
||||||
assert is_binary(reason)
|
assert is_binary(reason)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -44,13 +45,15 @@ 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, 500)
|
# Reduced timeout to 100ms for faster tests
|
||||||
|
assert {:error, _reason} = SslExecutor.execute(config, 100)
|
||||||
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, 500)
|
# Reduced timeout to 100ms for faster tests
|
||||||
|
assert {:error, _reason} = SslExecutor.execute(config, 100)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,9 @@ 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
|
||||||
|
# Reduced timeout to 100ms for faster tests
|
||||||
config = %{"host" => "192.0.2.1", "port" => 80}
|
config = %{"host" => "192.0.2.1", "port" => 80}
|
||||||
assert {:error, reason} = TcpExecutor.execute(config, 500)
|
assert {:error, reason} = TcpExecutor.execute(config, 100)
|
||||||
|
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -465,21 +465,9 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "options-level divisor parsing" do
|
describe "options-level divisor parsing" do
|
||||||
setup do
|
# No setup needed - we're building profiles directly from YAML without files
|
||||||
profiles_path = Path.join(:code.priv_dir(:towerops), "profiles")
|
|
||||||
detection_dir = Path.join(profiles_path, "os_detection")
|
|
||||||
discovery_dir = Path.join(profiles_path, "os_discovery")
|
|
||||||
|
|
||||||
on_exit(fn ->
|
defp write_test_profile(_detection_dir, _discovery_dir, discovery_yaml) do
|
||||||
File.rm(Path.join(detection_dir, "test_divisor_profile.yaml"))
|
|
||||||
File.rm(Path.join(discovery_dir, "test_divisor_profile.yaml"))
|
|
||||||
YamlProfiles.reload()
|
|
||||||
end)
|
|
||||||
|
|
||||||
%{detection_dir: detection_dir, discovery_dir: discovery_dir}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp write_test_profile(detection_dir, discovery_dir, discovery_yaml) do
|
|
||||||
detection_yaml = """
|
detection_yaml = """
|
||||||
os: test_divisor_profile
|
os: test_divisor_profile
|
||||||
text: Test Divisor Profile
|
text: Test Divisor Profile
|
||||||
|
|
@ -487,17 +475,13 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
- sysObjectID: .1.3.6.1.4.1.99999.1
|
- sysObjectID: .1.3.6.1.4.1.99999.1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
File.write!(Path.join(detection_dir, "test_divisor_profile.yaml"), detection_yaml)
|
# Use test helper to build profile directly from YAML without expensive reload
|
||||||
File.write!(Path.join(discovery_dir, "test_divisor_profile.yaml"), discovery_yaml)
|
# This avoids loading all 786 profiles which takes ~2.5 seconds per test
|
||||||
YamlProfiles.reload()
|
YamlProfiles.build_profile_from_yaml("test_divisor_profile", detection_yaml, discovery_yaml)
|
||||||
YamlProfiles.get_profile("test_divisor_profile")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Task 4.1 — Scalar sensor options-level divisor
|
# Task 4.1 — Scalar sensor options-level divisor
|
||||||
test "scalar sensor uses options-level divisor when no per-entry divisor", %{
|
test "scalar sensor uses options-level divisor when no per-entry divisor" do
|
||||||
detection_dir: detection_dir,
|
|
||||||
discovery_dir: discovery_dir
|
|
||||||
} do
|
|
||||||
discovery_yaml = """
|
discovery_yaml = """
|
||||||
modules:
|
modules:
|
||||||
sensors:
|
sensors:
|
||||||
|
|
@ -509,7 +493,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
descr: "Voltage"
|
descr: "Voltage"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||||
|
|
||||||
assert profile
|
assert profile
|
||||||
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
||||||
|
|
@ -518,10 +502,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Task 4.2 — Table sensor options-level divisor
|
# Task 4.2 — Table sensor options-level divisor
|
||||||
test "table sensor uses options-level divisor when no per-entry divisor", %{
|
test "table sensor uses options-level divisor when no per-entry divisor" do
|
||||||
detection_dir: detection_dir,
|
|
||||||
discovery_dir: discovery_dir
|
|
||||||
} do
|
|
||||||
discovery_yaml = """
|
discovery_yaml = """
|
||||||
modules:
|
modules:
|
||||||
sensors:
|
sensors:
|
||||||
|
|
@ -534,7 +515,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
descr: "Temperature"
|
descr: "Temperature"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||||
|
|
||||||
assert profile
|
assert profile
|
||||||
sensor = Enum.find(profile.table_sensor_oids, fn s -> s.sensor_type == "temperature" end)
|
sensor = Enum.find(profile.table_sensor_oids, fn s -> s.sensor_type == "temperature" end)
|
||||||
|
|
@ -543,10 +524,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Task 4.3 — Per-entry divisor overrides options-level
|
# Task 4.3 — Per-entry divisor overrides options-level
|
||||||
test "per-entry divisor overrides options-level divisor", %{
|
test "per-entry divisor overrides options-level divisor" do
|
||||||
detection_dir: detection_dir,
|
|
||||||
discovery_dir: discovery_dir
|
|
||||||
} do
|
|
||||||
discovery_yaml = """
|
discovery_yaml = """
|
||||||
modules:
|
modules:
|
||||||
sensors:
|
sensors:
|
||||||
|
|
@ -559,7 +537,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
divisor: 100
|
divisor: 100
|
||||||
"""
|
"""
|
||||||
|
|
||||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||||
|
|
||||||
assert profile
|
assert profile
|
||||||
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
||||||
|
|
@ -568,10 +546,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Task 4.4 — No divisor at either level defaults to 1
|
# Task 4.4 — No divisor at either level defaults to 1
|
||||||
test "sensor with no divisor at either level defaults to 1", %{
|
test "sensor with no divisor at either level defaults to 1" do
|
||||||
detection_dir: detection_dir,
|
|
||||||
discovery_dir: discovery_dir
|
|
||||||
} do
|
|
||||||
discovery_yaml = """
|
discovery_yaml = """
|
||||||
modules:
|
modules:
|
||||||
sensors:
|
sensors:
|
||||||
|
|
@ -581,7 +556,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
||||||
descr: "Voltage"
|
descr: "Voltage"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||||
|
|
||||||
assert profile
|
assert profile
|
||||||
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue