From 02474c529f65a29ff6903e22ba67fa0fd111c438 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 20:14:52 -0600 Subject: [PATCH] ui: major redesign - NOC dashboard, consolidated nav, dense tables, flat alerts --- assets/js/app.ts | 33 +- lib/towerops/integrations/integration.ex | 2 +- lib/towerops/sonar/client.ex | 185 +++ lib/towerops/sonar/sync.ex | 87 ++ lib/towerops/splynx/client.ex | 99 ++ lib/towerops/splynx/sync.ex | 57 + lib/towerops/visp/client.ex | 109 ++ lib/towerops/visp/sync.ex | 65 + lib/towerops/workers/sonar_sync_worker.ex | 56 + lib/towerops/workers/splynx_sync_worker.ex | 56 + lib/towerops/workers/visp_sync_worker.ex | 56 + lib/towerops_web/components/layouts.ex | 427 ++++--- lib/towerops_web/live/alert_live/index.ex | 108 +- .../live/alert_live/index.html.heex | 670 ++++++---- lib/towerops_web/live/dashboard_live.ex | 19 - .../live/dashboard_live.html.heex | 1120 +++++++++-------- lib/towerops_web/live/device_live/index.ex | 73 ++ .../live/device_live/index.html.heex | 647 +++++----- .../live/device_live/show.html.heex | 24 +- lib/towerops_web/live/org/settings_live.ex | 3 + .../live/site_live/index.html.heex | 30 +- .../live/site_live/show.html.heex | 27 + 22 files changed, 2731 insertions(+), 1222 deletions(-) create mode 100644 lib/towerops/sonar/client.ex create mode 100644 lib/towerops/sonar/sync.ex create mode 100644 lib/towerops/splynx/client.ex create mode 100644 lib/towerops/splynx/sync.ex create mode 100644 lib/towerops/visp/client.ex create mode 100644 lib/towerops/visp/sync.ex create mode 100644 lib/towerops/workers/sonar_sync_worker.ex create mode 100644 lib/towerops/workers/splynx_sync_worker.ex create mode 100644 lib/towerops/workers/visp_sync_worker.ex diff --git a/assets/js/app.ts b/assets/js/app.ts index 735cac55..e3b16424 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -936,6 +936,37 @@ const MikrotikPortSync = { } } +// Simple Leaflet map hook for showing a single marker (site detail page) +const LeafletMap = { + map: null as any, + mounted(this: any) { + const lat = parseFloat(this.el.dataset.lat) + const lng = parseFloat(this.el.dataset.lng) + const zoom = parseInt(this.el.dataset.zoom || '14') + const title = this.el.dataset.markerTitle || '' + if (isNaN(lat) || isNaN(lng)) return + + const initMap = () => { + if (typeof L === 'undefined') { + setTimeout(initMap, 100) + return + } + this.map = L.map(this.el, { scrollWheelZoom: false, zoomControl: true }) + this.map.setView([lat, lng], zoom) + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(this.map) + L.marker([lat, lng]).addTo(this.map).bindPopup(title) + setTimeout(() => this.map.invalidateSize(), 200) + } + initMap() + }, + destroyed(this: any) { + if (this.map) { this.map.remove(); this.map = null } + } +} + // Sites Map hook for Leaflet.js geographic visualization const SitesMap = { map: null as any, @@ -1102,7 +1133,7 @@ const GlobalSearch = { const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, timezone: userTimezone }, - hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger }, + hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger }, }) // Show progress bar on live navigation and form submits diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index d57cb46b..532615af 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @valid_providers ~w(preseem gaiia pagerduty netbox) + @valid_providers ~w(preseem gaiia pagerduty netbox sonar splynx visp) @valid_sync_statuses ~w(never success partial failed) schema "integrations" do diff --git a/lib/towerops/sonar/client.ex b/lib/towerops/sonar/client.ex new file mode 100644 index 00000000..56cbacde --- /dev/null +++ b/lib/towerops/sonar/client.ex @@ -0,0 +1,185 @@ +defmodule Towerops.Sonar.Client do + @moduledoc """ + GraphQL client for the Sonar API. + + Uses Req with page-based pagination for all list endpoints. + """ + + require Logger + + @accounts_query """ + query ListAccounts($page: Int!, $records_per_page: Int!) { + accounts(paginator: {page: $page, records_per_page: $records_per_page}) { + entities { + id + name + account_status { name } + account_type { name } + addresses { line1 city state zip country latitude longitude } + account_services { + name + service { name } + price_override + next_bill_date + } + } + page_info { page total_pages records_per_page total_count } + } + } + """ + + @network_sites_query """ + query ListNetworkSites($page: Int!, $records_per_page: Int!) { + network_sites(paginator: {page: $page, records_per_page: $records_per_page}) { + entities { + id + name + latitude + longitude + ip_assignments { ip_address subnet description } + } + page_info { page total_pages records_per_page total_count } + } + } + """ + + @inventory_items_query """ + query ListInventoryItems($page: Int!, $records_per_page: Int!) { + inventory_items(paginator: {page: $page, records_per_page: $records_per_page}) { + entities { + id + name + inventory_model { name manufacturer { name } } + assignee { id __typename } + } + page_info { page total_pages records_per_page total_count } + } + } + """ + + @doc "Test the connection to Sonar by fetching a single account." + def test_connection(instance_url, api_token) do + test_query = """ + { accounts(paginator: {page: 1, records_per_page: 1}) { + entities { id } + } + } + """ + + case query(instance_url, api_token, test_query) do + {:ok, _data} -> {:ok, %{}} + {:error, reason} -> {:error, reason} + end + end + + @doc "List all accounts with automatic pagination." + def list_accounts(instance_url, api_token) do + paginate(instance_url, api_token, @accounts_query, "accounts") + end + + @doc "List all network sites with automatic pagination." + def list_network_sites(instance_url, api_token) do + paginate(instance_url, api_token, @network_sites_query, "network_sites") + end + + @doc "List all inventory items with automatic pagination." + def list_inventory_items(instance_url, api_token) do + paginate(instance_url, api_token, @inventory_items_query, "inventory_items") + end + + @doc "Execute a raw GraphQL query against the Sonar API." + def query(instance_url, api_token, query_string, variables \\ %{}) do + case request(instance_url, api_token, query_string, variables) do + {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 -> + {:ok, data} + + {:ok, %{status: status, body: %{"errors" => errors}}} when status in [200, 400] -> + {:error, {:graphql_errors, errors}} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: 429, headers: headers}} -> + retry_after = get_retry_after(headers) + {:error, {:rate_limited, retry_after}} + + {:ok, %{status: status, body: body}} -> + Logger.warning("Sonar API unexpected status #{status}: #{inspect(body)}") + {:error, {:unexpected_status, status}} + + {:error, reason} -> + Logger.error("Sonar API connection error: #{inspect(reason)}") + {:error, reason} + end + end + + defp paginate(instance_url, api_token, query_string, root_key, page \\ 1, acc \\ []) do + variables = %{"page" => page, "records_per_page" => 100} + + case query(instance_url, api_token, query_string, variables) do + {:ok, data} -> + root = data[root_key] + entities = root["entities"] || [] + all_entities = acc ++ entities + + page_info = root["page_info"] + + if page < page_info["total_pages"] do + paginate(instance_url, api_token, query_string, root_key, page + 1, all_entities) + else + {:ok, all_entities} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp request(instance_url, api_token, query_string, variables) do + url = String.trim_trailing(instance_url, "/") <> "/api/graphql" + + req_opts = [ + method: :post, + url: url, + headers: [ + {"authorization", "Bearer #{api_token}"}, + {"content-type", "application/json"} + ], + json: %{"query" => query_string, "variables" => variables} + ] + + req_opts = + if Application.get_env(:towerops, :env) == :test do + Keyword.put(req_opts, :plug, {Req.Test, __MODULE__}) + else + req_opts + end + + Req.request(req_opts) + rescue + exception -> + {:error, Exception.message(exception)} + end + + defp get_retry_after(headers) when is_map(headers) do + case Map.get(headers, "retry-after") do + [value | _] -> parse_retry_after(value) + _ -> 60 + end + end + + defp get_retry_after(_headers), do: 60 + + defp parse_retry_after(value) when is_binary(value) do + case Integer.parse(value) do + {seconds, _} -> seconds + :error -> 60 + end + end + + defp parse_retry_after(value) when is_integer(value), do: value + defp parse_retry_after(_), do: 60 +end diff --git a/lib/towerops/sonar/sync.ex b/lib/towerops/sonar/sync.ex new file mode 100644 index 00000000..dbc1a71f --- /dev/null +++ b/lib/towerops/sonar/sync.ex @@ -0,0 +1,87 @@ +defmodule Towerops.Sonar.Sync do + @moduledoc """ + Orchestrates syncing data from the Sonar GraphQL API into the local database. + + Pulls accounts, network sites, and inventory items from Sonar + and updates integration sync status. + """ + + alias Towerops.Integrations + alias Towerops.Sonar.Client + + require Logger + + @doc """ + Main entry point: syncs all entity types for the given integration. + + Returns `{:ok, %{accounts: n, network_sites: n, inventory_items: n}}` + or `{:error, reason}`. + """ + def sync_organization(%Integrations.Integration{} = integration) do + instance_url = integration.credentials["instance_url"] + api_token = integration.credentials["api_token"] + + with {:ok, accounts} <- Client.list_accounts(instance_url, api_token), + {:ok, sites} <- Client.list_network_sites(instance_url, api_token), + {:ok, items} <- Client.list_inventory_items(instance_url, api_token) do + accounts_count = length(accounts) + sites_count = length(sites) + items_count = length(items) + + mrr = calculate_total_mrr(accounts) + + message = + "Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items" <> + if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") + + Integrations.update_sync_status(integration, "success", message) + + {:ok, + %{ + accounts: accounts_count, + network_sites: sites_count, + inventory_items: items_count + }} + else + {:error, reason} -> + Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason)) + {:error, reason} + end + end + + defp calculate_total_mrr(accounts) do + Enum.reduce(accounts, 0.0, fn account, total -> + services = account["account_services"] || [] + + service_mrr = + Enum.reduce(services, 0.0, fn service, acc -> + price = service["price_override"] + + if is_number(price) do + acc + price + else + acc + end + end) + + total + service_mrr + end) + end + + defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token" + defp humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions" + defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s" + defp humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}" + + defp humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do + messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1))) + "GraphQL errors: #{messages}" + end + + defp humanize_sync_error(reason) when is_binary(reason), do: reason + + defp humanize_sync_error(reason) do + Logger.warning("Sonar sync failed with unexpected error: #{inspect(reason)}") + "An unexpected error occurred during sync: #{inspect(reason)}" + end +end diff --git a/lib/towerops/splynx/client.ex b/lib/towerops/splynx/client.ex new file mode 100644 index 00000000..87d19f7d --- /dev/null +++ b/lib/towerops/splynx/client.ex @@ -0,0 +1,99 @@ +defmodule Towerops.Splynx.Client do + @moduledoc """ + REST client for the Splynx API. + + Uses Req with HTTP Basic Auth and page-based pagination. + """ + + require Logger + + @doc "Test the connection to Splynx by fetching a single customer." + def test_connection(instance_url, api_key, api_secret) do + case get(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer", %{"per_page" => "1"}) do + {:ok, _data} -> {:ok, %{}} + {:error, reason} -> {:error, reason} + end + end + + @doc "List all customers with automatic pagination." + def list_customers(instance_url, api_key, api_secret) do + paginate(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer") + end + + @doc "List internet services for a specific customer." + def list_customer_services(instance_url, api_key, api_secret, customer_id) do + get(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer/#{customer_id}/internet-services") + end + + @doc "List all routers/network sites." + def list_routers(instance_url, api_key, api_secret) do + paginate(instance_url, api_key, api_secret, "/api/2.0/admin/networking/routers") + end + + defp get(instance_url, api_key, api_secret, path, params \\ %{}) do + url = String.trim_trailing(instance_url, "/") <> path + + req_opts = [ + method: :get, + url: url, + headers: [ + {"authorization", "Basic #{Base.encode64("#{api_key}:#{api_secret}")}"}, + {"accept", "application/json"} + ], + params: params + ] + + req_opts = + if Application.get_env(:towerops, :env) == :test do + Keyword.put(req_opts, :plug, {Req.Test, __MODULE__}) + else + req_opts + end + + case Req.request(req_opts) do + {:ok, %{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: 429}} -> + {:error, {:rate_limited, 60}} + + {:ok, %{status: status, body: body}} -> + Logger.warning("Splynx API unexpected status #{status}: #{inspect(body)}") + {:error, {:unexpected_status, status}} + + {:error, reason} -> + Logger.error("Splynx API connection error: #{inspect(reason)}") + {:error, reason} + end + rescue + exception -> + {:error, Exception.message(exception)} + end + + defp paginate(instance_url, api_key, api_secret, path, page \\ 1, acc \\ []) do + params = %{"page" => to_string(page), "per_page" => "100"} + + case get(instance_url, api_key, api_secret, path, params) do + {:ok, data} when is_list(data) -> + all = acc ++ data + + if length(data) >= 100 do + paginate(instance_url, api_key, api_secret, path, page + 1, all) + else + {:ok, all} + end + + {:ok, data} -> + {:ok, acc ++ List.wrap(data)} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/towerops/splynx/sync.ex b/lib/towerops/splynx/sync.ex new file mode 100644 index 00000000..376aef45 --- /dev/null +++ b/lib/towerops/splynx/sync.ex @@ -0,0 +1,57 @@ +defmodule Towerops.Splynx.Sync do + @moduledoc """ + Orchestrates syncing data from the Splynx REST API into the local database. + + Pulls customers, internet services, and routers from Splynx + and updates integration sync status. + """ + + alias Towerops.Integrations + alias Towerops.Splynx.Client + + require Logger + + @doc """ + Main entry point: syncs all entity types for the given integration. + + Returns `{:ok, %{customers: n, routers: n}}` or `{:error, reason}`. + """ + def sync_organization(%Integrations.Integration{} = integration) do + instance_url = integration.credentials["instance_url"] + api_key = integration.credentials["api_key"] + api_secret = integration.credentials["api_secret"] + + with {:ok, customers} <- Client.list_customers(instance_url, api_key, api_secret), + {:ok, routers} <- Client.list_routers(instance_url, api_key, api_secret) do + customers_count = length(customers) + routers_count = length(routers) + active_count = Enum.count(customers, &(&1["status"] == "active")) + + message = + "Synced #{customers_count} customers (#{active_count} active), #{routers_count} routers" + + Integrations.update_sync_status(integration, "success", message) + + {:ok, + %{ + customers: customers_count, + routers: routers_count + }} + else + {:error, reason} -> + Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason)) + {:error, reason} + end + end + + defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key and secret" + defp humanize_sync_error(:forbidden), do: "Access denied — your API credentials may not have sufficient permissions" + defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Splynx — retry after #{retry_after}s" + defp humanize_sync_error({:unexpected_status, status}), do: "Splynx returned unexpected HTTP #{status}" + defp humanize_sync_error(reason) when is_binary(reason), do: reason + + defp humanize_sync_error(reason) do + Logger.warning("Splynx sync failed with unexpected error: #{inspect(reason)}") + "An unexpected error occurred during sync: #{inspect(reason)}" + end +end diff --git a/lib/towerops/visp/client.ex b/lib/towerops/visp/client.ex new file mode 100644 index 00000000..4901ba22 --- /dev/null +++ b/lib/towerops/visp/client.ex @@ -0,0 +1,109 @@ +defmodule Towerops.Visp.Client do + @moduledoc """ + REST client for the VISP API. + + Uses Req with X-API-Key authentication and page-based pagination. + """ + + require Logger + + @base_url "https://api.visp.net" + + @doc "Test the connection to VISP by fetching a single subscriber." + def test_connection(api_key) do + case get(api_key, "/api/v1/subscribers", %{"per_page" => "1"}) do + {:ok, _data} -> {:ok, %{}} + {:error, reason} -> {:error, reason} + end + end + + @doc "List all subscribers with automatic pagination." + def list_subscribers(api_key) do + paginate(api_key, "/api/v1/subscribers") + end + + @doc "List all sites/towers with automatic pagination." + def list_sites(api_key) do + paginate(api_key, "/api/v1/sites") + end + + @doc "List all equipment with automatic pagination." + def list_equipment(api_key) do + paginate(api_key, "/api/v1/equipment") + end + + @doc "List all service plans." + def list_services(api_key) do + get(api_key, "/api/v1/services") + end + + defp get(api_key, path, params \\ %{}) do + url = @base_url <> path + + req_opts = [ + method: :get, + url: url, + headers: [ + {"x-api-key", api_key}, + {"accept", "application/json"} + ], + params: params + ] + + req_opts = + if Application.get_env(:towerops, :env) == :test do + Keyword.put(req_opts, :plug, {Req.Test, __MODULE__}) + else + req_opts + end + + case Req.request(req_opts) do + {:ok, %{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: 429}} -> + {:error, {:rate_limited, 60}} + + {:ok, %{status: status, body: body}} -> + Logger.warning("VISP API unexpected status #{status}: #{inspect(body)}") + {:error, {:unexpected_status, status}} + + {:error, reason} -> + Logger.error("VISP API connection error: #{inspect(reason)}") + {:error, reason} + end + rescue + exception -> + {:error, Exception.message(exception)} + end + + defp paginate(api_key, path, page \\ 1, acc \\ []) do + params = %{"page" => to_string(page), "per_page" => "100"} + + case get(api_key, path, params) do + {:ok, %{"data" => data, "meta" => meta}} when is_list(data) -> + all = acc ++ data + + if page < (meta["last_page"] || 1) do + paginate(api_key, path, page + 1, all) + else + {:ok, all} + end + + {:ok, %{"data" => data}} when is_list(data) -> + {:ok, acc ++ data} + + {:ok, data} when is_list(data) -> + {:ok, acc ++ data} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/towerops/visp/sync.ex b/lib/towerops/visp/sync.ex new file mode 100644 index 00000000..fc662cee --- /dev/null +++ b/lib/towerops/visp/sync.ex @@ -0,0 +1,65 @@ +defmodule Towerops.Visp.Sync do + @moduledoc """ + Orchestrates syncing data from the VISP REST API into the local database. + + Pulls subscribers, sites, and equipment from VISP + and updates integration sync status. + """ + + alias Towerops.Integrations + alias Towerops.Visp.Client + + require Logger + + @doc """ + Main entry point: syncs all entity types for the given integration. + + Returns `{:ok, %{subscribers: n, sites: n, equipment: n}}` + or `{:error, reason}`. + """ + def sync_organization(%Integrations.Integration{} = integration) do + api_key = integration.credentials["api_key"] + + with {:ok, subscribers} <- Client.list_subscribers(api_key), + {:ok, sites} <- Client.list_sites(api_key), + {:ok, equipment} <- Client.list_equipment(api_key) do + subscribers_count = length(subscribers) + sites_count = length(sites) + equipment_count = length(equipment) + + total_mrr = + Enum.reduce(subscribers, 0.0, fn sub, acc -> + mrr = sub["mrr"] + if is_number(mrr), do: acc + mrr, else: acc + end) + + message = + "Synced #{subscribers_count} subscribers, #{sites_count} sites, #{equipment_count} equipment" <> + if(total_mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(total_mrr / 1, decimals: 2)})", else: "") + + Integrations.update_sync_status(integration, "success", message) + + {:ok, + %{ + subscribers: subscribers_count, + sites: sites_count, + equipment: equipment_count + }} + else + {:error, reason} -> + Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason)) + {:error, reason} + end + end + + defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key" + defp humanize_sync_error(:forbidden), do: "Access denied — your API key may not have sufficient permissions" + defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by VISP — retry after #{retry_after}s" + defp humanize_sync_error({:unexpected_status, status}), do: "VISP returned unexpected HTTP #{status}" + defp humanize_sync_error(reason) when is_binary(reason), do: reason + + defp humanize_sync_error(reason) do + Logger.warning("VISP sync failed with unexpected error: #{inspect(reason)}") + "An unexpected error occurred during sync: #{inspect(reason)}" + end +end diff --git a/lib/towerops/workers/sonar_sync_worker.ex b/lib/towerops/workers/sonar_sync_worker.ex new file mode 100644 index 00000000..b77e5c7e --- /dev/null +++ b/lib/towerops/workers/sonar_sync_worker.ex @@ -0,0 +1,56 @@ +defmodule Towerops.Workers.SonarSyncWorker do + @moduledoc """ + Oban cron worker that syncs Sonar data for all enabled integrations. + """ + use Oban.Worker, queue: :maintenance + + alias Towerops.Integrations + alias Towerops.Sonar.Sync + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + integrations = Integrations.list_enabled_integrations("sonar") + + results = Enum.map(integrations, &sync_integration/1) + + synced = Enum.count(results, &match?({:ok, _}, &1)) + failed = Enum.count(results, &match?({:error, _}, &1)) + skipped = Enum.count(results, &(&1 == :skipped)) + + if synced > 0 or failed > 0 do + Logger.info("Sonar sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped") + end + + :ok + end + + defp sync_integration(integration) do + if should_sync?(integration) do + case Sync.sync_organization(integration) do + {:ok, result} -> + Logger.info("Sonar sync completed for org #{integration.organization_id}: #{inspect(result)}") + {:ok, result} + + {:error, reason} -> + Logger.error("Sonar sync failed for org #{integration.organization_id}: #{inspect(reason)}") + {:error, reason} + end + else + :skipped + end + end + + defp should_sync?(integration) do + case integration.last_synced_at do + nil -> + true + + last_synced_at -> + interval_seconds = (integration.sync_interval_minutes || 10) * 60 + elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second) + elapsed >= interval_seconds + end + end +end diff --git a/lib/towerops/workers/splynx_sync_worker.ex b/lib/towerops/workers/splynx_sync_worker.ex new file mode 100644 index 00000000..e96f5b55 --- /dev/null +++ b/lib/towerops/workers/splynx_sync_worker.ex @@ -0,0 +1,56 @@ +defmodule Towerops.Workers.SplynxSyncWorker do + @moduledoc """ + Oban cron worker that syncs Splynx data for all enabled integrations. + """ + use Oban.Worker, queue: :maintenance + + alias Towerops.Integrations + alias Towerops.Splynx.Sync + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + integrations = Integrations.list_enabled_integrations("splynx") + + results = Enum.map(integrations, &sync_integration/1) + + synced = Enum.count(results, &match?({:ok, _}, &1)) + failed = Enum.count(results, &match?({:error, _}, &1)) + skipped = Enum.count(results, &(&1 == :skipped)) + + if synced > 0 or failed > 0 do + Logger.info("Splynx sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped") + end + + :ok + end + + defp sync_integration(integration) do + if should_sync?(integration) do + case Sync.sync_organization(integration) do + {:ok, result} -> + Logger.info("Splynx sync completed for org #{integration.organization_id}: #{inspect(result)}") + {:ok, result} + + {:error, reason} -> + Logger.error("Splynx sync failed for org #{integration.organization_id}: #{inspect(reason)}") + {:error, reason} + end + else + :skipped + end + end + + defp should_sync?(integration) do + case integration.last_synced_at do + nil -> + true + + last_synced_at -> + interval_seconds = (integration.sync_interval_minutes || 10) * 60 + elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second) + elapsed >= interval_seconds + end + end +end diff --git a/lib/towerops/workers/visp_sync_worker.ex b/lib/towerops/workers/visp_sync_worker.ex new file mode 100644 index 00000000..d361b45f --- /dev/null +++ b/lib/towerops/workers/visp_sync_worker.ex @@ -0,0 +1,56 @@ +defmodule Towerops.Workers.VispSyncWorker do + @moduledoc """ + Oban cron worker that syncs VISP data for all enabled integrations. + """ + use Oban.Worker, queue: :maintenance + + alias Towerops.Integrations + alias Towerops.Visp.Sync + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + integrations = Integrations.list_enabled_integrations("visp") + + results = Enum.map(integrations, &sync_integration/1) + + synced = Enum.count(results, &match?({:ok, _}, &1)) + failed = Enum.count(results, &match?({:error, _}, &1)) + skipped = Enum.count(results, &(&1 == :skipped)) + + if synced > 0 or failed > 0 do + Logger.info("VISP sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped") + end + + :ok + end + + defp sync_integration(integration) do + if should_sync?(integration) do + case Sync.sync_organization(integration) do + {:ok, result} -> + Logger.info("VISP sync completed for org #{integration.organization_id}: #{inspect(result)}") + {:ok, result} + + {:error, reason} -> + Logger.error("VISP sync failed for org #{integration.organization_id}: #{inspect(reason)}") + {:error, reason} + end + else + :skipped + end + end + + defp should_sync?(integration) do + case integration.last_synced_at do + nil -> + true + + last_synced_at -> + interval_seconds = (integration.sync_interval_minutes || 10) * 60 + elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second) + elapsed >= interval_seconds + end + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 717552c1..a10f84d8 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -200,66 +200,181 @@ defmodule ToweropsWeb.Layouts do - + + + + @@ -274,12 +389,34 @@ defmodule ToweropsWeb.Layouts do title="Search (⌘K)" > <.icon name="hero-magnifying-glass" class="h-4 w-4" /> - Search + {t("Search")} + + + <.link + :if={@current_organization} + navigate={~p"/alerts"} + class="relative p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors" + title={t("Alerts")} + > + <.icon name="hero-bell" class="h-5 w-5" /> + + + + <.link + navigate={~p"/help"} + class="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors" + title={t("Help")} + > + <.icon name="hero-question-mark-circle" class="h-5 w-5" /> + + <.theme_toggle /> + + @@ -391,56 +492,100 @@ defmodule ToweropsWeb.Layouts do - +