diff --git a/assets/js/app.ts b/assets/js/app.ts index d7ce5900..663f2de8 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -633,6 +633,9 @@ const BetaBannerDismiss = { const NetworkMap = { cy: null as any, _topologyData: null as any, + _zoomInHandler: null as ((e: Event) => void) | null, + _zoomOutHandler: null as ((e: Event) => void) | null, + _fitHandler: null as ((e: Event) => void) | null, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this._topologyData = topologyData @@ -671,6 +674,25 @@ const NetworkMap = { }, destroyed(this: any) { + // Remove DOM 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) + } + if (zoomOut && this._zoomOutHandler) { + zoomOut.removeEventListener('click', this._zoomOutHandler) + } + if (fit && this._fitHandler) { + fit.removeEventListener('click', this._fitHandler) + } + + this._zoomInHandler = null + this._zoomOutHandler = null + this._fitHandler = null + if (this.cy) { // Stop any running layout before destroying to prevent async callbacks // from accessing a destroyed renderer @@ -690,30 +712,33 @@ const NetworkMap = { const fit = document.getElementById('cy-fit') if (zoomIn) { - zoomIn.addEventListener('click', () => { + this._zoomInHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomIn.addEventListener('click', this._zoomInHandler) } if (zoomOut) { - zoomOut.addEventListener('click', () => { + this._zoomOutHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomOut.addEventListener('click', this._zoomOutHandler) } if (fit) { - fit.addEventListener('click', () => { + this._fitHandler = () => { if (!this.cy) return this.cy.fit(undefined, 50) - }) + } + fit.addEventListener('click', this._fitHandler) } }, @@ -1717,14 +1742,24 @@ const SitesMap = {
` } - popupContent += `` - + + // Bind popup and attach event listener after marker is added to map marker.bindPopup(popupContent) + marker.on('popupopen', () => { + const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement + if (button) { + button.addEventListener('click', () => { + this.pushEvent("site_clicked", { site_id: button.dataset.siteId }) + }) + } + }) this.markers.addLayer(marker) bounds.extend([site.latitude, site.longitude]) } @@ -1750,11 +1785,21 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" // Global Search (Cmd+K / Ctrl+K) hook const GlobalSearchTrigger = { - mounted() { - this.el.addEventListener("click", () => { + handleClick: null as ((e: Event) => void) | null, + + mounted(this: any) { + this.handleClick = () => { // Simulate Cmd+K to open global search 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 + } } } @@ -1829,6 +1874,9 @@ const ThemeSelector = { const WeathermapViewer = { cy: null as any, _topologyData: null as any, + _zoomInHandler: null as ((e: Event) => void) | null, + _zoomOutHandler: null as ((e: Event) => void) | null, + _fitHandler: null as ((e: Event) => void) | null, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') @@ -1868,6 +1916,25 @@ const WeathermapViewer = { }, destroyed(this: any) { + // Remove DOM 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) + } + if (zoomOut && this._zoomOutHandler) { + zoomOut.removeEventListener('click', this._zoomOutHandler) + } + if (fit && this._fitHandler) { + fit.removeEventListener('click', this._fitHandler) + } + + this._zoomInHandler = null + this._zoomOutHandler = null + this._fitHandler = null + if (this.cy) { try { this.cy.stop() @@ -2204,30 +2271,33 @@ const WeathermapViewer = { const fit = document.getElementById('cy-fit') if (zoomIn) { - zoomIn.addEventListener('click', () => { + this._zoomInHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomIn.addEventListener('click', this._zoomInHandler) } if (zoomOut) { - zoomOut.addEventListener('click', () => { + this._zoomOutHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) - }) + } + zoomOut.addEventListener('click', this._zoomOutHandler) } if (fit) { - fit.addEventListener('click', () => { + this._fitHandler = () => { if (!this.cy) return this.cy.fit(undefined, 50) - }) + } + fit.addEventListener('click', this._fitHandler) } }, diff --git a/lib/towerops/alerts/storm_detector.ex b/lib/towerops/alerts/storm_detector.ex index 48dbdd25..c9bc1443 100644 --- a/lib/towerops/alerts/storm_detector.ex +++ b/lib/towerops/alerts/storm_detector.ex @@ -150,6 +150,8 @@ defmodule Towerops.Alerts.StormDetector do ) until = DateTime.add(DateTime.utc_now(), state.storm_cooldown_ms, :millisecond) + # Schedule automatic cooldown check so storm mode exits without needing an external poll + Process.send_after(self(), :check_storm_cooldown, state.storm_cooldown_ms) {true, until} else {state.storm_mode, state.storm_mode_until} @@ -270,6 +272,7 @@ defmodule Towerops.Alerts.StormDetector do case List.first(devices) do nil -> Logger.error("Attempted to create site outage alert with no devices for site_id=#{site_id}") + {:error, :no_devices} primary_device -> diff --git a/lib/towerops_web/live/device_live/components/backups_tab.ex b/lib/towerops_web/live/device_live/components/backups_tab.ex new file mode 100644 index 00000000..ff4b5fda --- /dev/null +++ b/lib/towerops_web/live/device_live/components/backups_tab.ex @@ -0,0 +1,315 @@ +defmodule ToweropsWeb.DeviceLive.Components.BackupsTab do + @moduledoc """ + LiveComponent for the device MikroTik backups tab. + + Displays MikroTik configuration backups with download, compare, + and backup-now functionality. + """ + use ToweropsWeb, :live_component + + alias ToweropsWeb.DeviceLive.Helpers.DataLoaders + alias ToweropsWeb.DeviceLive.Helpers.Formatters + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> load_backups_data()} + end + + defp load_backups_data(socket) do + device = socket.assigns.device + mikrotik_backups = DataLoaders.load_mikrotik_backups(device) + + socket + |> assign(:mikrotik_backups, mikrotik_backups) + |> assign(:selected_backup_ids, MapSet.new()) + end + + @impl true + def render(assigns) do + ~H""" +| + <.icon name="hero-check-circle" class="h-4 w-4 mx-auto" /> + | +Date | +Original Size | +Compressed Size | +Ratio | +Source | +Actions | +
|---|---|---|---|---|---|---|
| + = 2 + } + title={ + if MapSet.member?(@selected_backup_ids, backup.id), + do: "Selected for comparison", + else: + if(MapSet.size(@selected_backup_ids) >= 2, + do: "Maximum 2 backups can be selected", + else: "Select to compare" + ) + } + class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700" + /> + | ++ {ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)} + | ++ {format_bytes(backup.config_size_bytes)} + | ++ {format_bytes(backup.compressed_size_bytes)} + | ++ {compression_ratio}% + | ++ + {String.replace(backup.trigger_source, "_", " ") |> String.capitalize()} + + | +
+
+
+ <%= if ToweropsWeb.Permissions.owner?(@current_scope) do %>
+ <.button
+ phx-click="delete_backup"
+ phx-value-id={backup.id}
+ data-confirm={
+ t(
+ "Are you sure you want to delete this backup? This action cannot be undone."
+ )
+ }
+ variant="danger"
+ >
+ <.icon name="hero-trash" class="h-4 w-4" /> Delete
+
+ <% end %>
+
+ |
+
+ Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour( + 7, + @timezone + )}. +
+ <% else %> ++ {t("Assign an agent to enable automatic backups.")} +
+ <% end %> ++ {t("Enable MikroTik API in device settings to enable configuration backups.")} +
++ {t( + "Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually." + )} +
+| + {t("Name")} + | ++ {t("Status")} + | ++ {t("Value")} + | ++ {t("Last Checked")} + | ++ Actions + | +
|---|---|---|---|---|
| + {check.name} + | ++ {render_status_badge(check.current_state)} + | ++ <%= if check.last_check_at do %> + {get_latest_value(check)} + <% else %> + + Pending + + <% end %> + | ++ <%= if check.last_check_at do %> + {format_relative_time(check.last_check_at)} + <% else %> + + Never + + <% end %> + | +
+
+ <.link
+ navigate={
+ ~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h"
+ }
+ class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
+ >
+ {t("Graph")}
+
+ <%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %>
+
+
+ <% end %>
+
+ |
+
| + {t("Plan")} + | ++ {t("Status")} + | ++ {t("MRR")} + | +
|---|---|---|
| + {sub.product_name || "---"} + | ++ + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" + + "suspended" -> + "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400" + end + ]}> + {sub.status || "unknown"} + + | ++ <%= if sub.mrr_amount do %> + ${Decimal.round(sub.mrr_amount, 2)} + + {sub.currency || ""} + + <% else %> + --- + <% end %> + | +
+ {t("No Gaiia inventory item linked to this device.")} +
+| Device | +IP Address | +Interface | +Link Type | +Confidence | +Status | +
|---|---|---|---|---|---|
|
+ <%= if peer_device do %>
+ <.link
+ navigate={~p"/devices/#{peer_device.id}"}
+ class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ {peer_device.name}
+
+ <% else %>
+
+ {link.discovered_remote_name || link.discovered_remote_mac ||
+ "Unknown"}
+
+ <% end %>
+ |
+ + <%= if peer_device do %> + {peer_device.ip_address} + <% else %> + {link.discovered_remote_ip || "-"} + <% end %> + | ++ <%= if link.source_interface do %> + {link.source_interface.if_name} + <% else %> + - + <% end %> + | ++ + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" + + link.link_type == "mac_match" -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + + link.link_type == "arp_inference" -> + "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" + + true -> + "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200" + end + ]}> + {link.link_type} + + | +
+
+
+
+
+
+ {trunc(link.confidence * 100)}%
+
+ = 0.9 -> "bg-green-500"
+ link.confidence >= 0.7 -> "bg-blue-500"
+ link.confidence >= 0.5 -> "bg-yellow-500"
+ true -> "bg-red-500"
+ end
+ ]}
+ style={"width: #{trunc(link.confidence * 100)}%"}
+ >
+
+ |
+ + <%= if peer_device do %> + + {t("Managed")} + + <% else %> + + {t("Discovered")} + + <% end %> + | +
| # | +Name | +Status | +Speed | +IP Address | +MAC | +Capacity | +Utilization | +In | +Out | +Errors | +
|---|---|---|---|---|---|---|---|---|---|---|
| + {interface.if_index} + | +
+ <.link
+ navigate={
+ ~p"/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}"
+ }
+ class="hover:text-blue-600 dark:hover:text-blue-400"
+ >
+
+ {interface.if_name || interface.if_descr}
+
+ <%= if interface.if_alias do %>
+
+ {interface.if_alias}
+
+ <% end %>
+
+ |
+ + + + + {String.upcase(interface.if_oper_status || "unknown")} + + + | ++ {format_speed(interface.if_speed)} + | +
+ <% interface_ips =
+ Map.get(@ip_addresses_by_interface, interface.id, []) %>
+ <%= if Enum.empty?(interface_ips) do %>
+ -
+ <% else %>
+
+ <%= for ip <- interface_ips do %>
+
+ <% end %>
+
+ {ip.ip_address}
+ <%= if ip.prefix_length do %>
+ /{ip.prefix_length}
+ <% end %>
+ <.icon
+ name="hero-clipboard-document"
+ class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/iip:opacity-100 transition-opacity text-gray-400"
+ />
+
+ <% end %>
+ |
+ + + {interface.if_phys_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" + /> + + - + | +
+ <%= if interface.configured_capacity_bps do %>
+
+
+ {format_speed(interface.configured_capacity_bps)}
+
+
+ {capacity_source_label(interface.capacity_source)}
+
+
+
+ <% else %>
+
+ <% end %>
+ |
+
+ <%= if interface.utilization do %>
+
+
+ <% else %>
+ -
+ <% end %>
+
+
+
+ {Float.round(interface.utilization.utilization_pct, 1)}%
+
+
+
+ |
+ + {if interface.latest_stat && interface.latest_stat.if_in_octets, + do: format_bytes(interface.latest_stat.if_in_octets), + else: "-"} + | ++ {if interface.latest_stat && interface.latest_stat.if_out_octets, + do: format_bytes(interface.latest_stat.if_out_octets), + else: "-"} + | ++ <% total_errors = + interface.latest_stat && + (interface.latest_stat.if_in_errors || 0) + + (interface.latest_stat.if_out_errors || 0) + + (interface.latest_stat.if_in_discards || 0) + + (interface.latest_stat.if_out_discards || 0) %> + <%= if total_errors && total_errors > 0 do %> + <.link + navigate={ + ~p"/devices/#{@device.id}/graph/interface_errors?interface_id=#{interface.id}" + } + class="text-red-600 dark:text-red-400 hover:underline" + > + {total_errors} + + <% else %> + - + <% end %> + | +
No interfaces discovered
++ {insight.title} +
++ {insight.description} +
+| + {t("Time")} + | ++ {t("Latency (ms)")} + | ++ {t("P95 Latency")} + | ++ {t("Jitter (ms)")} + | ++ {t("Loss (%)")} + | ++ {t("Throughput")} + | ++ {t("Subscribers")} + | +
|---|---|---|---|---|---|---|
| + {ToweropsWeb.TimeHelpers.format_iso8601( + metric.recorded_at, + @current_scope.timezone, + @current_scope.time_format + )} + | ++ {if metric.avg_latency, + do: Float.round(metric.avg_latency, 1), + else: "---"} + | ++ {if metric.p95_latency, + do: Float.round(metric.p95_latency, 1), + else: "---"} + | ++ {if metric.avg_jitter, + do: Float.round(metric.avg_jitter, 1), + else: "---"} + | ++ {if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"} + | ++ {if metric.avg_throughput, + do: "#{Float.round(metric.avg_throughput, 1)} Mbps", + else: "---"} + | ++ {metric.subscriber_count || "---"} + | +
+ {t("No Preseem data linked to this device.")} +
++ {length(@wireless_clients)} clients — {@wireless_matched_count} subscribers matched + <%= if @wireless_last_updated do %> + · Last updated {format_relative_time(@wireless_last_updated)} + <% end %> +
+| MAC Address | +IP Address | +Subscriber | +Signal | +SNR | +Distance | +TX Rate | +RX Rate | +Uptime | +
|---|---|---|---|---|---|---|---|---|
| + + {client.mac_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" + /> + + | ++ <%= if client.ip_address do %> + + {client.ip_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400" + /> + + <% else %> + — + <% end %> + | ++ <%= if subscriber_link && subscriber_link.gaiia_account do %> + + {subscriber_link.gaiia_account.account_name} + + <% else %> + — + <% end %> + | ++ <%= if client.signal_strength do %> + <.signal_badge value={client.signal_strength} /> + <% else %> + — + <% end %> + | ++ <%= if client.snr do %> + <.snr_badge value={client.snr} /> + <% else %> + — + <% end %> + | ++ <%= if client.distance do %> + {client.distance}m + <% else %> + — + <% end %> + | ++ {format_rate(client.tx_rate)} + | ++ {format_rate(client.rx_rate)} + | ++ <%= if client.uptime_seconds do %> + {format_uptime(client.uptime_seconds * 100)} + <% else %> + — + <% end %> + | +
+ {t("No clients are currently connected to this access point.")} +
+