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
|
||||
const StatusTitle = {
|
||||
baseTitle: null as string | null,
|
||||
|
||||
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 titleWithoutEmoji = currentTitle.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
|
||||
document.title = emoji ? `${emoji} ${titleWithoutEmoji}` : titleWithoutEmoji
|
||||
// Only update baseTitle if it's not an emoji update (i.e., it's a real navigation)
|
||||
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
|
||||
req_opts
|
||||
|> maybe_attach_test_plug(owner)
|
||||
# |> maybe_disable_retry() # Temporarily disabled to debug PagerDuty tests
|
||||
|> Req.request()
|
||||
rescue
|
||||
error in RuntimeError ->
|
||||
|
|
|
|||
|
|
@ -154,12 +154,35 @@ defmodule Towerops.Monitoring do
|
|||
|
||||
@doc """
|
||||
Creates a check.
|
||||
|
||||
For auto-discovery checks, uses upsert logic to update existing checks
|
||||
instead of creating duplicates.
|
||||
"""
|
||||
def create_check(attrs \\ %{}) do
|
||||
# Use upsert for auto-discovery checks to avoid duplicates
|
||||
result =
|
||||
%Check{}
|
||||
|> Check.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
if Map.get(attrs, :source_type) == "auto_discovery" and Map.has_key?(attrs, :source_id) do
|
||||
# For partial unique indexes, find existing record and update it
|
||||
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
|
||||
{: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(:timeout_ms, greater_than: 0)
|
||||
|> 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(:device_id)
|
||||
|> foreign_key_constraint(:agent_token_id)
|
||||
|
|
|
|||
|
|
@ -81,9 +81,14 @@ defmodule Towerops.PagerDuty.Client do
|
|||
end
|
||||
|
||||
# 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")
|
||||
{:error, :rate_limited}
|
||||
end
|
||||
|
|
@ -132,8 +137,8 @@ defmodule Towerops.PagerDuty.Client do
|
|||
end
|
||||
end
|
||||
|
||||
# Exponential backoff: 1s, 2s, 4s
|
||||
defp backoff_ms(retries), do: to_timeout(second: round(:math.pow(2, retries)))
|
||||
# Exponential backoff: 1s, 2s, 4s (in milliseconds)
|
||||
defp backoff_ms(retries), do: round(:math.pow(2, retries)) * 1000
|
||||
|
||||
defp dedup_key(alert), do: "towerops-alert-#{alert.id}"
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,19 @@ defmodule Towerops.Profiles.YamlProfiles do
|
|||
GenServer.call(__MODULE__, :reload, 30_000)
|
||||
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
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -118,9 +118,12 @@ defmodule Towerops.Uisp.Client do
|
|||
]
|
||||
|
||||
# Only use Req.Test plug in test environment
|
||||
# Also disable retry in tests to avoid 7s timeout (1s + 2s + 4s exponential backoff)
|
||||
opts =
|
||||
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
|
||||
opts
|
||||
end
|
||||
|
|
|
|||
|
|
@ -63,8 +63,9 @@ defmodule Towerops.Monitoring.Executors.DnsExecutorTest do
|
|||
|
||||
test "returns error for timeout with unreachable server" do
|
||||
# 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"}
|
||||
assert {:error, reason} = DnsExecutor.execute(config, 500)
|
||||
assert {:error, reason} = DnsExecutor.execute(config, 100)
|
||||
assert is_binary(reason)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
|
|||
test "returns error for connection failure to non-routable address" do
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
@ -44,13 +45,15 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
|
|||
# but verify the function doesn't crash
|
||||
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
|
||||
|
||||
test "defaults warning_days to 30 when not specified" do
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,9 @@ defmodule Towerops.Monitoring.Executors.TcpExecutorTest do
|
|||
|
||||
test "returns error for connection timeout" do
|
||||
# Use a non-routable IP to force timeout
|
||||
# Reduced timeout to 100ms for faster tests
|
||||
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
|
||||
String.contains?(reason, "failed")
|
||||
|
|
|
|||
|
|
@ -465,21 +465,9 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
end
|
||||
|
||||
describe "options-level divisor parsing" do
|
||||
setup do
|
||||
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")
|
||||
# No setup needed - we're building profiles directly from YAML without files
|
||||
|
||||
on_exit(fn ->
|
||||
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
|
||||
defp write_test_profile(_detection_dir, _discovery_dir, discovery_yaml) do
|
||||
detection_yaml = """
|
||||
os: 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
|
||||
"""
|
||||
|
||||
File.write!(Path.join(detection_dir, "test_divisor_profile.yaml"), detection_yaml)
|
||||
File.write!(Path.join(discovery_dir, "test_divisor_profile.yaml"), discovery_yaml)
|
||||
YamlProfiles.reload()
|
||||
YamlProfiles.get_profile("test_divisor_profile")
|
||||
# Use test helper to build profile directly from YAML without expensive reload
|
||||
# This avoids loading all 786 profiles which takes ~2.5 seconds per test
|
||||
YamlProfiles.build_profile_from_yaml("test_divisor_profile", detection_yaml, discovery_yaml)
|
||||
end
|
||||
|
||||
# Task 4.1 — Scalar sensor options-level divisor
|
||||
test "scalar sensor uses options-level divisor when no per-entry divisor", %{
|
||||
detection_dir: detection_dir,
|
||||
discovery_dir: discovery_dir
|
||||
} do
|
||||
test "scalar sensor uses options-level divisor when no per-entry divisor" do
|
||||
discovery_yaml = """
|
||||
modules:
|
||||
sensors:
|
||||
|
|
@ -509,7 +493,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
descr: "Voltage"
|
||||
"""
|
||||
|
||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
||||
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||
|
||||
assert profile
|
||||
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
||||
|
|
@ -518,10 +502,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
end
|
||||
|
||||
# Task 4.2 — Table sensor options-level divisor
|
||||
test "table sensor uses options-level divisor when no per-entry divisor", %{
|
||||
detection_dir: detection_dir,
|
||||
discovery_dir: discovery_dir
|
||||
} do
|
||||
test "table sensor uses options-level divisor when no per-entry divisor" do
|
||||
discovery_yaml = """
|
||||
modules:
|
||||
sensors:
|
||||
|
|
@ -534,7 +515,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
descr: "Temperature"
|
||||
"""
|
||||
|
||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
||||
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||
|
||||
assert profile
|
||||
sensor = Enum.find(profile.table_sensor_oids, fn s -> s.sensor_type == "temperature" end)
|
||||
|
|
@ -543,10 +524,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
end
|
||||
|
||||
# Task 4.3 — Per-entry divisor overrides options-level
|
||||
test "per-entry divisor overrides options-level divisor", %{
|
||||
detection_dir: detection_dir,
|
||||
discovery_dir: discovery_dir
|
||||
} do
|
||||
test "per-entry divisor overrides options-level divisor" do
|
||||
discovery_yaml = """
|
||||
modules:
|
||||
sensors:
|
||||
|
|
@ -559,7 +537,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
divisor: 100
|
||||
"""
|
||||
|
||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
||||
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||
|
||||
assert profile
|
||||
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
||||
|
|
@ -568,10 +546,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
end
|
||||
|
||||
# Task 4.4 — No divisor at either level defaults to 1
|
||||
test "sensor with no divisor at either level defaults to 1", %{
|
||||
detection_dir: detection_dir,
|
||||
discovery_dir: discovery_dir
|
||||
} do
|
||||
test "sensor with no divisor at either level defaults to 1" do
|
||||
discovery_yaml = """
|
||||
modules:
|
||||
sensors:
|
||||
|
|
@ -581,7 +556,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
|
|||
descr: "Voltage"
|
||||
"""
|
||||
|
||||
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml)
|
||||
profile = write_test_profile(nil, nil, discovery_yaml)
|
||||
|
||||
assert profile
|
||||
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue