diff --git a/assets/js/app.ts b/assets/js/app.ts index 663f2de8..61293452 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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() + } } } diff --git a/lib/towerops/http.ex b/lib/towerops/http.ex index a54d06b7..0230da42 100644 --- a/lib/towerops/http.ex +++ b/lib/towerops/http.ex @@ -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 -> diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex index b136798d..125324a4 100644 --- a/lib/towerops/monitoring.ex +++ b/lib/towerops/monitoring.ex @@ -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) diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index 3647ac8c..3005e4f3 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -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) diff --git a/lib/towerops/pagerduty/client.ex b/lib/towerops/pagerduty/client.ex index f3b7cd75..fd61d8e5 100644 --- a/lib/towerops/pagerduty/client.ex +++ b/lib/towerops/pagerduty/client.ex @@ -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}" diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex index f999a72d..ba489bbb 100644 --- a/lib/towerops/profiles/yaml_profiles.ex +++ b/lib/towerops/profiles/yaml_profiles.ex @@ -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 diff --git a/lib/towerops/uisp/client.ex b/lib/towerops/uisp/client.ex index b0d48e6f..86271fc8 100644 --- a/lib/towerops/uisp/client.ex +++ b/lib/towerops/uisp/client.ex @@ -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 diff --git a/test/towerops/monitoring/executors/dns_executor_test.exs b/test/towerops/monitoring/executors/dns_executor_test.exs index 696a276d..04852984 100644 --- a/test/towerops/monitoring/executors/dns_executor_test.exs +++ b/test/towerops/monitoring/executors/dns_executor_test.exs @@ -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 diff --git a/test/towerops/monitoring/executors/ssl_executor_test.exs b/test/towerops/monitoring/executors/ssl_executor_test.exs index fa47bd2d..96653154 100644 --- a/test/towerops/monitoring/executors/ssl_executor_test.exs +++ b/test/towerops/monitoring/executors/ssl_executor_test.exs @@ -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 diff --git a/test/towerops/monitoring/executors/tcp_executor_test.exs b/test/towerops/monitoring/executors/tcp_executor_test.exs index 6894593e..117851de 100644 --- a/test/towerops/monitoring/executors/tcp_executor_test.exs +++ b/test/towerops/monitoring/executors/tcp_executor_test.exs @@ -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") diff --git a/test/towerops/profiles/yaml_profiles_test.exs b/test/towerops/profiles/yaml_profiles_test.exs index feafb0d4..c04e07a9 100644 --- a/test/towerops/profiles/yaml_profiles_test.exs +++ b/test/towerops/profiles/yaml_profiles_test.exs @@ -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)