diff --git a/CLAUDE.md b/CLAUDE.md index d8c26594..1cf76d3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -939,4 +939,5 @@ When writing new LiveView code or JavaScript hooks: - Take another snapshot - Compare - memory should not continuously grow - assets are rebuilt automatically on filesystem change -- never try to run npm, it's not included in phoenix \ No newline at end of file +- never try to run npm, it's not included in phoenix +- assets build themselves on file change, you do not need to ever run mix assets.build \ No newline at end of file diff --git a/assets/js/app.ts b/assets/js/app.ts index aacee415..7f32d3e5 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -822,15 +822,23 @@ const NetworkMap = { // MikroTik Port Sync - automatically switches port when SSL checkbox is toggled const MikrotikPortSync = { handleSslChange: null as ((e: Event) => void) | null, + sslCheckbox: null as HTMLInputElement | null, - mounted(this: any) { - const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') + setupListener(this: any) { + // Clean up existing listener first + if (this.sslCheckbox && this.handleSslChange) { + this.sslCheckbox.removeEventListener('change', this.handleSslChange) + } + + const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') as HTMLInputElement const portInput = this.el.querySelector('[name="device[mikrotik_port]"]') as HTMLInputElement if (!sslCheckbox || !portInput) return + this.sslCheckbox = sslCheckbox + this.handleSslChange = () => { - const isChecked = (sslCheckbox as HTMLInputElement).checked + const isChecked = sslCheckbox.checked const currentPort = portInput.value.trim() // Only update port if it's empty or set to one of the default values @@ -844,11 +852,20 @@ const MikrotikPortSync = { sslCheckbox.addEventListener('change', this.handleSslChange) }, + mounted(this: any) { + this.setupListener() + }, + + updated(this: any) { + // Re-attach listener when LiveView updates the DOM (e.g., when mikrotik_enabled changes) + this.setupListener() + }, + destroyed(this: any) { - const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') - if (sslCheckbox && this.handleSslChange) { - sslCheckbox.removeEventListener('change', this.handleSslChange) + if (this.sslCheckbox && this.handleSslChange) { + this.sslCheckbox.removeEventListener('change', this.handleSslChange) this.handleSslChange = null + this.sslCheckbox = null } } } diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index b7d4d774..44d5f666 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -3,465 +3,592 @@ current_scope={@current_scope} >
-
- <.link - navigate={ - if @live_action == :edit, - do: ~p"/devices/#{@device.id}", - else: ~p"/devices" - } - class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" - > - <.icon name="hero-arrow-left" class="h-4 w-4" /> - {if @live_action == :edit, do: "Back to Device", else: "Back to Device List"} - -
+
+
+ <.link + navigate={ + if @live_action == :edit, + do: ~p"/devices/#{@device.id}", + else: ~p"/devices" + } + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> + {if @live_action == :edit, do: "Back to Device", else: "Back to Device List"} + +
-
-
- <.header> - {@page_title} - <:subtitle> +
+
+

+ {@page_title} +

+

{if @live_action == :new, do: "Add new device to monitor", else: "Update device details"} - - -

- - <%= if @live_action == :new do %> - <% percent = - if @device_quota.limit != :unlimited and @device_quota.limit > 0 do - (@device_quota.current / @device_quota.limit * 100) |> trunc() - else - 0 - end %> - - <% badge_class = - cond do - @current_scope.user.is_superuser -> - "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" - - @device_quota.at_limit -> - "bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800" - - percent >= 90 -> - "bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800" - - percent >= 75 -> - "bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800" - - @device_quota.limit == :unlimited -> - "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" - - true -> - "bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800" - end %> - -
- <%= cond do %> - <% @current_scope.user.is_superuser -> %> - {@device_quota.current} devices - <% @device_quota.limit == :unlimited -> %> - {@device_quota.current} devices - <% true -> %> - {@device_quota.current}/{@device_quota.limit} devices - <% end %> +

- <% end %> + + <%= if @live_action == :new do %> + <% percent = + if @device_quota.limit != :unlimited and @device_quota.limit > 0 do + (@device_quota.current / @device_quota.limit * 100) |> trunc() + else + 0 + end %> + + <% badge_class = + cond do + @current_scope.user.is_superuser -> + "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" + + @device_quota.at_limit -> + "bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800" + + percent >= 90 -> + "bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800" + + percent >= 75 -> + "bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800" + + @device_quota.limit == :unlimited -> + "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" + + true -> + "bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800" + end %> + +
+ <%= cond do %> + <% @current_scope.user.is_superuser -> %> + {@device_quota.current} devices + <% @device_quota.limit == :unlimited -> %> + {@device_quota.current} devices + <% true -> %> + {@device_quota.current}/{@device_quota.limit} devices + <% end %> +
+ <% end %> +
- - + <%= if @live_action == :new and not @device_quota.at_limit and @device_quota.limit != :unlimited and not @current_scope.user.is_superuser do %> <% remaining = @device_quota.limit - @device_quota.current %> <%= if remaining <= 3 do %> -
-
- <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" /> -
-

- Approaching device limit -

-

- You have {remaining} {if remaining == 1, do: "slot", else: "slots"} remaining. -

+
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" /> +
+

+ Approaching device limit +

+

+ You have {remaining} {if remaining == 1, do: "slot", else: "slots"} remaining. +

+
<% end %> <% end %> -
- <.form for={@form} id="device-form" phx-change="validate" phx-submit="save"> - <.input - field={@form[:name]} - type="text" - label="Device Name" - placeholder={ - if @monitoring_mode == "snmp_and_icmp", - do: "Leave blank to automatically populate from SNMP device name (sysName)", - else: nil - } - required={@monitoring_mode == "icmp_only"} - /> - - <.input field={@form[:ip_address]} type="text" label="IP Address" required /> - -
-
- <.icon name="hero-exclamation-triangle" class="h-5 w-5 flex-shrink-0" /> -

- A device with this IP address already exists: - <.link - navigate={~p"/devices/#{@duplicate_device.id}"} - class="font-medium underline hover:text-amber-900 dark:hover:text-amber-100" - > - {@duplicate_device.name || @duplicate_device.ip_address} - + <.form for={@form} id="device-form" phx-change="validate" phx-submit="save"> +

+ +
+
+

+ Basic Information +

+

+ Core device identification and location details.

-
-
-
- <.icon name="hero-exclamation-circle" class="h-5 w-5 flex-shrink-0 mt-0.5" /> -
-

Non-routable IP address detected

-

- This IP address (private network or CGNAT range) cannot be reached from the cloud. - To monitor this device, assign it to a - <.link - navigate={~p"/agents"} - class="font-medium underline" +

+
+
+ <.input + field={@form[:name]} + type="text" + label="Device Name" + placeholder={ + if @monitoring_mode == "snmp_and_icmp", + do: "Leave blank to automatically populate from SNMP device name (sysName)", + else: nil + } + required={@monitoring_mode == "icmp_only"} + /> +
+ +
+ <.input field={@form[:ip_address]} type="text" label="IP Address" required /> + +
- remote agent - - deployed on the same network. -

-
-
-
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 flex-shrink-0" /> +

+ A device with this IP address already exists: + <.link + navigate={~p"/devices/#{@duplicate_device.id}"} + class="font-medium underline hover:text-amber-900 dark:hover:text-amber-100" + > + {@duplicate_device.name || @duplicate_device.ip_address} + +

+
+
- <.input - field={@form[:site_id]} - type="select" - label="Site" - required - prompt="Select a site" - options={Enum.map(@available_sites, &{&1.name, &1.id})} - /> - - <.input field={@form[:description]} type="textarea" label="Description" /> - - <.input - field={@form[:check_interval_seconds]} - type="number" - label="Check Interval (seconds)" - min="30" - max="3600" - /> - - <.input field={@form[:monitoring_enabled]} type="checkbox" label="Enable Monitoring" /> - - <%= if @available_agents != [] do %> - <.input - field={@form[:agent_token_id]} - type="select" - label="Remote Agent" - prompt="Inherit from site/organization" - options={ - Enum.map(@available_agents, fn agent -> - label = - if agent.is_cloud_poller, - do: "#{agent.name} (Cloud Poller)", - else: agent.name - - {label, agent.id} - end) - } - /> - <%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %> - <%= case @agent_source do %> - <% :device -> %> -

- <.icon name="hero-exclamation-triangle" class="h-4 w-4" /> - Overriding site/organization defaults - - this device is explicitly assigned -

- <% :site -> %> -

- <.icon name="hero-building-office" class="h-4 w-4" /> Inherited from site: - {@effective_agent_name} -

- <% :organization -> %> -

- <.icon name="hero-building-office-2" class="h-4 w-4" /> - Inherited from organization: {@effective_agent_name} -

- <% :global -> %> -

- <.icon name="hero-globe-alt" class="h-4 w-4" /> - Using global default cloud poller: {@effective_agent_name} -

- <% :none -> %> -

- <.icon name="hero-cloud" class="h-4 w-4" /> No agent assigned - cloud polling -

- <% end %> - <% else %> -

- Assign this device to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults. -

- <% end %> - <% end %> - -
-

Monitoring Configuration

- - -
-
- -
-
- <%= if @monitoring_mode == "snmp_and_icmp" do %> -

- Monitor device availability with ICMP pings and collect detailed information via SNMP - (interfaces, sensors, system info). -

- <% else %> -

Monitor device availability with ICMP pings only (no SNMP discovery).

- <% end %> -
-
+
+ <.icon name="hero-exclamation-circle" class="h-5 w-5 flex-shrink-0 mt-0.5" /> +
+

Non-routable IP address detected

+

+ This IP address (private network or CGNAT range) cannot be reached from the cloud. + To monitor this device, assign it to a + <.link navigate={~p"/agents"} class="font-medium underline"> + remote agent + + deployed on the same network. +

+
+
+
+
-
- <.input - field={@form[:snmp_version]} - type="select" - label="SNMP Version" - prompt="Inherit from site/organization" - options={[{"SNMPv1", "1"}, {"SNMPv2c", "2c"}]} - /> +
+ <.input + field={@form[:site_id]} + type="select" + label="Site" + required + prompt="Select a site" + options={Enum.map(@available_sites, &{&1.name, &1.id})} + /> +
- <.input - field={@form[:snmp_community]} - type="text" - label="Community String" - placeholder="Leave blank to inherit from site/organization" - autocomplete="off" - /> - - <%= if @live_action == :edit and Map.has_key?(assigns, :snmp_config_source) do %> - <%= case @snmp_config_source do %> - <% :device -> %> -

- <.icon name="hero-exclamation-triangle" class="h-4 w-4" /> - Overriding site/organization SNMP defaults -

- <% :site -> %> -

- <.icon name="hero-building-office" class="h-4 w-4" /> - Inherited from site (v{@effective_snmp_version}, community: {@effective_snmp_community}) -

- <% :organization -> %> -

- <.icon name="hero-building-office-2" class="h-4 w-4" /> - Inherited from organization (v{@effective_snmp_version}, community: {@effective_snmp_community}) -

- <% :default -> %> -

- <.icon name="hero-information-circle" class="h-4 w-4" /> - Using default (v2c, no community set - configure at organization or site level) -

- <% end %> - <% end %> - - <.input - field={@form[:snmp_port]} - type="number" - label="SNMP Port" - min="1" - max="65535" - value={@form[:snmp_port].value || 161} - /> - -
-
- <.icon :if={@snmp_test_result.success} name="hero-check-circle" class="w-5 h-5" /> - <.icon :if={!@snmp_test_result.success} name="hero-x-circle" class="w-5 h-5" /> - {@snmp_test_result.message} +
+ <.input field={@form[:description]} type="textarea" label="Description" />
+
+
+ + +
+
+

+ Monitoring Configuration +

+

+ Configure how and how often this device should be monitored. +

+
-
- <.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> - Test SNMP Connection - +
+
+
+ <.input + field={@form[:check_interval_seconds]} + type="number" + label="Check Interval (seconds)" + min="30" + max="3600" + /> +
- <%= if @live_action == :edit do %> - <.button - type="button" - phx-click="trigger_discovery" - phx-disable-with="Discovering..." - > - <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Rediscover Device - +
+ <.input + field={@form[:monitoring_enabled]} + type="checkbox" + label="Enable Monitoring" + /> +
+ + <%= if @available_agents != [] do %> +
+ <.input + field={@form[:agent_token_id]} + type="select" + label="Remote Agent" + prompt="Inherit from site/organization" + options={ + Enum.map(@available_agents, fn agent -> + label = + if agent.is_cloud_poller, + do: "#{agent.name} (Cloud Poller)", + else: agent.name + + {label, agent.id} + end) + } + /> + <%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %> + <%= case @agent_source do %> + <% :device -> %> +

+ <.icon name="hero-exclamation-triangle" class="h-4 w-4" /> + Overriding + site/organization defaults - this device is explicitly assigned +

+ <% :site -> %> +

+ <.icon name="hero-building-office" class="h-4 w-4" /> + Inherited from site: {@effective_agent_name} +

+ <% :organization -> %> +

+ <.icon name="hero-building-office-2" class="h-4 w-4" /> + Inherited from organization: {@effective_agent_name} +

+ <% :global -> %> +

+ <.icon name="hero-globe-alt" class="h-4 w-4" /> + Using global default cloud poller: + {@effective_agent_name} +

+ <% :none -> %> +

+ <.icon name="hero-cloud" class="h-4 w-4" /> + No agent assigned - cloud polling +

+ <% end %> + <% else %> +

+ Assign this device to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults. +

+ <% end %> +
<% end %>
+ + +
+
+

+ SNMP Configuration +

+

+ Choose monitoring mode and configure SNMP settings for detailed device information. +

+
+
+
+ +
+
+ +
+
+ <%= if @monitoring_mode == "snmp_and_icmp" do %> +

+ Monitor device availability with ICMP pings and collect detailed information via SNMP + (interfaces, sensors, system info). +

+ <% else %> +

Monitor device availability with ICMP pings only (no SNMP discovery).

+ <% end %> +
+
+ + +
+
+ <.input + field={@form[:snmp_version]} + type="select" + label="SNMP Version" + prompt="Inherit from site/organization" + options={[{"SNMPv1", "1"}, {"SNMPv2c", "2c"}]} + /> +
+ +
+ <.input + field={@form[:snmp_community]} + type="text" + label="Community String" + placeholder="Leave blank to inherit from site/organization" + autocomplete="off" + /> + + <%= if @live_action == :edit and Map.has_key?(assigns, :snmp_config_source) do %> + <%= case @snmp_config_source do %> + <% :device -> %> +

+ <.icon name="hero-exclamation-triangle" class="h-4 w-4" /> + Overriding site/organization SNMP defaults +

+ <% :site -> %> +

+ <.icon name="hero-building-office" class="h-4 w-4" /> + Inherited from site (v{@effective_snmp_version}, community: {String.slice( + @effective_snmp_community, + 0..1 + )}{"*" + |> String.duplicate( + max(0, String.length(@effective_snmp_community) - 2) + )}) +

+ <% :organization -> %> +

+ <.icon name="hero-building-office-2" class="h-4 w-4" /> + Inherited from organization (v{@effective_snmp_version}, community: {String.slice( + @effective_snmp_community, + 0..1 + )}{"*" + |> String.duplicate( + max(0, String.length(@effective_snmp_community) - 2) + )}) +

+ <% :default -> %> +

+ <.icon name="hero-information-circle" class="h-4 w-4" /> + Using default (v2c, no community set - configure at organization or site level) +

+ <% end %> + <% end %> +
+ +
+ <.input + field={@form[:snmp_port]} + type="number" + label="SNMP Port" + min="1" + max="65535" + value={@form[:snmp_port].value || 161} + /> +
+ +
+
+
+ <.icon + :if={@snmp_test_result.success} + name="hero-check-circle" + class="w-5 h-5" + /> + <.icon + :if={!@snmp_test_result.success} + name="hero-x-circle" + class="w-5 h-5" + /> + {@snmp_test_result.message} +
+
+
+ +
+
+ <.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> + Test SNMP Connection + + + <%= if @live_action == :edit do %> + <.button + type="button" + phx-click="trigger_discovery" + phx-disable-with="Discovering..." + > + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Rediscover Device + + <% end %> +
+
+
+
+
+
+ + <%= if @live_action == :edit and @is_mikrotik_device and @current_scope.user.is_superuser do %>
-

+

MikroTik API Configuration -

-

+ +

Configure MikroTik RouterOS API access. Works alongside SNMP for enhanced device management.

- <%= if Map.has_key?(assigns, :mikrotik_config_source) and @mikrotik_config_source != :device do %> -
-

- Inheriting credentials from {@mikrotik_config_source}. - Username: {@effective_mikrotik_username} -

+
+
+ <%= if Map.has_key?(assigns, :mikrotik_config_source) and @mikrotik_config_source != :device do %> +
+

+ Inheriting credentials from {@mikrotik_config_source}. + Username: {@effective_mikrotik_username} +

+
+ <% end %> + +
+
+ <.input + field={@form[:mikrotik_enabled]} + type="checkbox" + label="Enable MikroTik API" + /> +
+ + <%= if @form[:mikrotik_enabled].value do %> +
+ <.input + field={@form[:mikrotik_username]} + label="Username" + placeholder="Leave blank to inherit from site/organization" + /> +
+ +
+ <.input + field={@form[:mikrotik_password]} + type="password" + label="Password" + placeholder="Leave blank to inherit from site/organization" + /> +
+ +
+ <.input + field={@form[:mikrotik_port]} + type="number" + label="API Port" + placeholder="8729 (SSL) or 8728 (plain)" + /> +
+ +
+ <.input + field={@form[:mikrotik_use_ssl]} + type="checkbox" + label="Use SSL (API-SSL)" + /> +
+ + <%= if @form[:mikrotik_use_ssl].value == false do %> +
+
+

+ 🚨 Critical Security Warning: + Plain API (port 8728) sends credentials unencrypted over the network. + This setting is blocked when using cloud pollers + and should only be used with local agents on trusted networks. +

+
+
+ <% else %> +
+
+

+ ⚠️ Security Warning: + Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible. +

+
+
+ <% end %> + <% end %> +
- <% end %> - - <.input - field={@form[:mikrotik_enabled]} - type="checkbox" - label="Enable MikroTik API" - /> - - <%= if @form[:mikrotik_enabled].value do %> - <.input - field={@form[:mikrotik_username]} - label="Username" - placeholder="Leave blank to inherit from site/organization" - /> - - <.input - field={@form[:mikrotik_password]} - type="password" - label="Password" - placeholder="Leave blank to inherit from site/organization" - /> - - <.input - field={@form[:mikrotik_port]} - type="number" - label="API Port" - placeholder="8729 (SSL) or 8728 (plain)" - /> - - <.input - field={@form[:mikrotik_use_ssl]} - type="checkbox" - label="Use SSL (API-SSL)" - /> - - <%= if @form[:mikrotik_use_ssl].value == false do %> -
-

- 🚨 Critical Security Warning: - Plain API (port 8728) sends credentials unencrypted over the network. - This setting is blocked when using cloud pollers - and should only be used with local agents on trusted networks. -

-
- <% else %> -
-

- ⚠️ Security Warning: - Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible. -

-
- <% end %> - <% end %> +
<% end %> - -
- <.button - phx-disable-with="Saving..." - variant="primary" - disabled={@duplicate_device != nil or @non_routable_ip_error} - > - Save Device - - <.button navigate={~p"/devices"}>Cancel -
- - - <%= if @live_action == :edit do %> -
-

Danger Zone

-

- Once you delete this device, there is no going back. All monitoring history and alerts will be permanently deleted. +

+ + +
+ <.button navigate={~p"/devices"}>Cancel + <.button + phx-disable-with="Saving..." + variant="primary" + disabled={@duplicate_device != nil or @non_routable_ip_error} + > + Save Device + +
+ + + + <%= if @live_action == :edit do %> +
+
+

Danger Zone

+

+ Permanently delete this device and all its data.

- <.button - phx-click="delete" - data-confirm="Are you sure you want to delete this device? All monitoring history and alerts will be permanently deleted." - variant="danger" - > - <.icon name="hero-trash" class="h-4 w-4" /> Delete Device -
- <% end %> -
+ +
+
+

+ Once you delete this device, there is no going back. All monitoring history and alerts will be permanently deleted. +

+ <.button + phx-click="delete" + data-confirm="Are you sure you want to delete this device? All monitoring history and alerts will be permanently deleted." + variant="danger" + > + <.icon name="hero-trash" class="h-4 w-4" /> Delete Device + +
+
+
+ <% end %>