updates
This commit is contained in:
parent
cca873abc8
commit
b1fedcda45
11 changed files with 2653 additions and 238 deletions
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -228,6 +228,55 @@ If a table has the wrong primary key type, create a fix migration that drops and
|
|||
|
||||
**SNMP Binary Data Handling**: All SNMP values (chassis IDs, port IDs, system names) must be converted to printable strings before saving to the database. Non-printable binaries (like MAC addresses in binary format) should be converted to colon-separated hex format using `String.printable?/1` checks. See `lib/towerops/snmp/neighbor_discovery.ex:sanitize_string_field/1` for the pattern.
|
||||
|
||||
### API Documentation
|
||||
|
||||
The API documentation is available at `/docs/api` and documents all public API endpoints.
|
||||
|
||||
**Location**:
|
||||
- Controller: `lib/towerops_web/controllers/api_docs_controller.ex`
|
||||
- HTML Module: `lib/towerops_web/controllers/api_docs_html.ex`
|
||||
- Template: `lib/towerops_web/controllers/api_docs_html/index.html.heex`
|
||||
- Route: `get "/docs/api", ApiDocsController, :index` in `router.ex`
|
||||
|
||||
**Template Structure**:
|
||||
- Adapted from Tailwind UI Protocol template
|
||||
- Sidebar navigation with section links
|
||||
- Two-column layout: descriptions on left, code examples on right
|
||||
- Sections: Introduction, Authentication, Errors, Sites API, Devices API
|
||||
|
||||
**IMPORTANT: When updating API endpoints**:
|
||||
1. Update the corresponding controller documentation (`@doc` comments in controller)
|
||||
2. Update `/docs/api` template with new endpoint documentation
|
||||
3. Follow the existing pattern:
|
||||
- Add endpoint to navigation sidebar if it's a new resource
|
||||
- Document the endpoint with HTTP method badge (GET/POST/PATCH/DELETE)
|
||||
- Include request example with `curl`
|
||||
- Include JSON response example
|
||||
- Document all parameters (required and optional)
|
||||
4. Wrap all code examples in `<%= raw(~S"""...""") %>` to prevent HEEx from parsing curly braces as Elixir code
|
||||
|
||||
**Code Example Pattern**:
|
||||
```heex
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
curl https://towerops.net/api/v1/sites \
|
||||
-H "Authorization: Bearer {token}"
|
||||
""") %></code></pre>
|
||||
```
|
||||
|
||||
**JSON Example Pattern**:
|
||||
```heex
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Site Name"
|
||||
}
|
||||
""") %></code></pre>
|
||||
```
|
||||
|
||||
**Current Documented Endpoints**:
|
||||
- Sites: GET /api/v1/sites, POST /api/v1/sites, GET /api/v1/sites/:id, PATCH /api/v1/sites/:id, DELETE /api/v1/sites/:id
|
||||
- Devices: GET /api/v1/devices, POST /api/v1/devices, GET /api/v1/devices/:id, PATCH /api/v1/devices/:id, DELETE /api/v1/devices/:id
|
||||
|
||||
### SNMP Polling Architecture
|
||||
|
||||
The application has two separate SNMP collection mechanisms:
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ if (!csrfToken) {
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken },
|
||||
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard },
|
||||
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
@ -360,8 +360,18 @@ const CopyToClipboard = {
|
|||
}
|
||||
}
|
||||
|
||||
// LiveView hook for scrolling to top
|
||||
const ScrollToTop = {
|
||||
mounted() {
|
||||
this.handleEvent("scroll_to_top", () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const Hooks = {
|
||||
CopyToClipboard
|
||||
CopyToClipboard,
|
||||
ScrollToTop
|
||||
}
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
|
|
|
|||
31
lib/towerops_web/controllers/api_docs_controller.ex
Normal file
31
lib/towerops_web/controllers/api_docs_controller.ex
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule ToweropsWeb.ApiDocsController do
|
||||
@moduledoc """
|
||||
Controller for API documentation pages.
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.ApiTokens
|
||||
|
||||
def index(conn, _params) do
|
||||
# Check if user is logged in and has an organization selected
|
||||
sample_token =
|
||||
case conn.assigns[:current_scope] do
|
||||
%{user: user, organization: org} when not is_nil(user) and not is_nil(org) ->
|
||||
# User is logged in with an organization - check for API tokens
|
||||
tokens = ApiTokens.list_organization_api_tokens(org.id)
|
||||
|
||||
if Enum.empty?(tokens) do
|
||||
"your-api-token-here (create one in your organization settings)"
|
||||
else
|
||||
token = List.first(tokens)
|
||||
"your-api-token-here (use: #{token.name})"
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Not logged in or no organization selected
|
||||
"YOUR_API_TOKEN"
|
||||
end
|
||||
|
||||
render(conn, :index, sample_token: sample_token)
|
||||
end
|
||||
end
|
||||
8
lib/towerops_web/controllers/api_docs_html.ex
Normal file
8
lib/towerops_web/controllers/api_docs_html.ex
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
defmodule ToweropsWeb.ApiDocsHTML do
|
||||
@moduledoc """
|
||||
This module contains API documentation pages.
|
||||
"""
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "api_docs_html/*"
|
||||
end
|
||||
1117
lib/towerops_web/controllers/api_docs_html/index.html.heex
Normal file
1117
lib/towerops_web/controllers/api_docs_html/index.html.heex
Normal file
File diff suppressed because it is too large
Load diff
1075
lib/towerops_web/controllers/api_docs_html/index.html.heex.bak
Normal file
1075
lib/towerops_web/controllers/api_docs_html/index.html.heex.bak
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -238,7 +238,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
socket
|
||||
|> put_flash(:info, flash_message)
|
||||
|> assign(:form, to_form(fresh_changeset))
|
||||
|> assign(:snmp_test_result, nil)}
|
||||
|> assign(:snmp_test_result, nil)
|
||||
|> push_event("scroll_to_top", %{})}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
|
|
|
|||
|
|
@ -3,260 +3,262 @@
|
|||
current_scope={@current_scope}
|
||||
current_organization={@organization}
|
||||
>
|
||||
<div class="mb-4">
|
||||
<.link
|
||||
navigate={
|
||||
if @live_action == :edit,
|
||||
do: ~p"/orgs/#{@organization.slug}/devices/#{@device.id}",
|
||||
else: ~p"/orgs/#{@organization.slug}/devices"
|
||||
}
|
||||
class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-4 w-4" />
|
||||
{if @live_action == :edit, do: "Back to Device", else: "Back to Device List"}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{if @live_action == :new,
|
||||
do: "Add new device to monitor",
|
||||
else: "Update device details"}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<.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
|
||||
<div phx-hook="ScrollToTop" id="scroll-container">
|
||||
<div class="mb-4">
|
||||
<.link
|
||||
navigate={
|
||||
if @live_action == :edit,
|
||||
do: ~p"/orgs/#{@organization.slug}/devices/#{@device.id}",
|
||||
else: ~p"/orgs/#{@organization.slug}/devices"
|
||||
}
|
||||
required={@monitoring_mode == "icmp_only"}
|
||||
/>
|
||||
class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-4 w-4" />
|
||||
{if @live_action == :edit, do: "Back to Device", else: "Back to Device List"}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.input field={@form[:ip_address]} type="text" label="IP Address" required />
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{if @live_action == :new,
|
||||
do: "Add new device to monitor",
|
||||
else: "Update device details"}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.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 %>
|
||||
<div class="max-w-2xl">
|
||||
<.form for={@form} id="device-form" phx-change="validate" phx-submit="save">
|
||||
<.input
|
||||
field={@form[:agent_token_id]}
|
||||
type="select"
|
||||
label="Remote Agent"
|
||||
prompt="Inherit from site/organization"
|
||||
options={Enum.map(@available_agents, &{&1.name, &1.id})}
|
||||
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"}
|
||||
/>
|
||||
<%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %>
|
||||
<%= case @agent_source do %>
|
||||
<% :device -> %>
|
||||
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
|
||||
<strong>Overriding</strong> site/organization defaults -
|
||||
this device is explicitly assigned
|
||||
</p>
|
||||
<% :site -> %>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office" class="h-4 w-4" /> Inherited from site:
|
||||
<strong>{@effective_agent_name}</strong>
|
||||
</p>
|
||||
<% :organization -> %>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office-2" class="h-4 w-4" />
|
||||
Inherited from organization: <strong>{@effective_agent_name}</strong>
|
||||
</p>
|
||||
<% :none -> %>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-cloud" class="h-4 w-4" /> No agent assigned - cloud polling
|
||||
</p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Assign this device to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults.
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-8 border-t pt-6">
|
||||
<h3 class="text-lg font-medium mb-4">Monitoring Configuration</h3>
|
||||
|
||||
<!-- Monitoring Mode Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-zinc-200 dark:border-zinc-700">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Monitoring mode">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="switch_monitoring_mode"
|
||||
phx-value-mode="snmp_and_icmp"
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
@monitoring_mode == "snmp_and_icmp" &&
|
||||
"border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400",
|
||||
@monitoring_mode != "snmp_and_icmp" &&
|
||||
"border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
|
||||
]}
|
||||
>
|
||||
SNMP & ICMP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="switch_monitoring_mode"
|
||||
phx-value-mode="icmp_only"
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
@monitoring_mode == "icmp_only" &&
|
||||
"border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400",
|
||||
@monitoring_mode != "icmp_only" &&
|
||||
"border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
|
||||
]}
|
||||
>
|
||||
ICMP Only
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<%= if @monitoring_mode == "snmp_and_icmp" do %>
|
||||
<p>
|
||||
Monitor device availability with ICMP pings and collect detailed information via SNMP
|
||||
(interfaces, sensors, system info).
|
||||
</p>
|
||||
<% else %>
|
||||
<p>Monitor device availability with ICMP pings only (no SNMP discovery).</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.input field={@form[:ip_address]} type="text" label="IP Address" required />
|
||||
|
||||
<div
|
||||
:if={@monitoring_mode == "snmp_and_icmp"}
|
||||
class="mt-4 space-y-4 pl-6 border-l-2 border-blue-200"
|
||||
>
|
||||
<.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[:snmp_version]}
|
||||
field={@form[:agent_token_id]}
|
||||
type="select"
|
||||
label="SNMP Version"
|
||||
label="Remote Agent"
|
||||
prompt="Inherit from site/organization"
|
||||
options={[{"SNMPv1", "1"}, {"SNMPv2c", "2c"}]}
|
||||
options={Enum.map(@available_agents, &{&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 %>
|
||||
<%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %>
|
||||
<%= case @agent_source do %>
|
||||
<% :device -> %>
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
|
||||
<strong>Overriding</strong> site/organization SNMP defaults
|
||||
<strong>Overriding</strong> site/organization defaults -
|
||||
this device is explicitly assigned
|
||||
</p>
|
||||
<% :site -> %>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office" class="h-4 w-4" />
|
||||
Inherited from site (v{@effective_snmp_version}, community: {@effective_snmp_community})
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office" class="h-4 w-4" /> Inherited from site:
|
||||
<strong>{@effective_agent_name}</strong>
|
||||
</p>
|
||||
<% :organization -> %>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office-2" class="h-4 w-4" />
|
||||
Inherited from organization (v{@effective_snmp_version}, community: {@effective_snmp_community})
|
||||
Inherited from organization: <strong>{@effective_agent_name}</strong>
|
||||
</p>
|
||||
<% :default -> %>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||||
Using default (v2c, no community set - configure at organization or site level)
|
||||
<% :none -> %>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-cloud" class="h-4 w-4" /> No agent assigned - cloud polling
|
||||
</p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Assign this device to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults.
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.input
|
||||
field={@form[:snmp_port]}
|
||||
type="number"
|
||||
label="SNMP Port"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={@form[:snmp_port].value || 161}
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@snmp_test_result}
|
||||
class="rounded-md p-3"
|
||||
class={[
|
||||
@snmp_test_result.success && "bg-green-50 text-green-800",
|
||||
!@snmp_test_result.success && "bg-red-50 text-red-800"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<.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" />
|
||||
<span class="text-sm font-medium">{@snmp_test_result.message}</span>
|
||||
<div class="mt-8 border-t pt-6">
|
||||
<h3 class="text-lg font-medium mb-4">Monitoring Configuration</h3>
|
||||
|
||||
<!-- Monitoring Mode Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-zinc-200 dark:border-zinc-700">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Monitoring mode">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="switch_monitoring_mode"
|
||||
phx-value-mode="snmp_and_icmp"
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
@monitoring_mode == "snmp_and_icmp" &&
|
||||
"border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400",
|
||||
@monitoring_mode != "snmp_and_icmp" &&
|
||||
"border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
|
||||
]}
|
||||
>
|
||||
SNMP & ICMP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="switch_monitoring_mode"
|
||||
phx-value-mode="icmp_only"
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
@monitoring_mode == "icmp_only" &&
|
||||
"border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400",
|
||||
@monitoring_mode != "icmp_only" &&
|
||||
"border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
|
||||
]}
|
||||
>
|
||||
ICMP Only
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<%= if @monitoring_mode == "snmp_and_icmp" do %>
|
||||
<p>
|
||||
Monitor device availability with ICMP pings and collect detailed information via SNMP
|
||||
(interfaces, sensors, system info).
|
||||
</p>
|
||||
<% else %>
|
||||
<p>Monitor device availability with ICMP pings only (no SNMP discovery).</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing...">
|
||||
Test SNMP Connection
|
||||
</.button>
|
||||
<div
|
||||
:if={@monitoring_mode == "snmp_and_icmp"}
|
||||
class="mt-4 space-y-4 pl-6 border-l-2 border-blue-200"
|
||||
>
|
||||
<.input
|
||||
field={@form[:snmp_version]}
|
||||
type="select"
|
||||
label="SNMP Version"
|
||||
prompt="Inherit from site/organization"
|
||||
options={[{"SNMPv1", "1"}, {"SNMPv2c", "2c"}]}
|
||||
/>
|
||||
|
||||
<%= 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
|
||||
</.button>
|
||||
<.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 -> %>
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
|
||||
<strong>Overriding</strong> site/organization SNMP defaults
|
||||
</p>
|
||||
<% :site -> %>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office" class="h-4 w-4" />
|
||||
Inherited from site (v{@effective_snmp_version}, community: {@effective_snmp_community})
|
||||
</p>
|
||||
<% :organization -> %>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-building-office-2" class="h-4 w-4" />
|
||||
Inherited from organization (v{@effective_snmp_version}, community: {@effective_snmp_community})
|
||||
</p>
|
||||
<% :default -> %>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 flex items-center gap-1">
|
||||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||||
Using default (v2c, no community set - configure at organization or site level)
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.input
|
||||
field={@form[:snmp_port]}
|
||||
type="number"
|
||||
label="SNMP Port"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={@form[:snmp_port].value || 161}
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@snmp_test_result}
|
||||
class="rounded-md p-3"
|
||||
class={[
|
||||
@snmp_test_result.success && "bg-green-50 text-green-800",
|
||||
!@snmp_test_result.success && "bg-red-50 text-red-800"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<.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" />
|
||||
<span class="text-sm font-medium">{@snmp_test_result.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing...">
|
||||
Test SNMP Connection
|
||||
</.button>
|
||||
|
||||
<%= 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
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<.button phx-disable-with="Saving..." variant="primary">Save Device</.button>
|
||||
<.button navigate={~p"/orgs/#{@organization.slug}/devices"}>Cancel</.button>
|
||||
</div>
|
||||
</.form>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<.button phx-disable-with="Saving..." variant="primary">Save Device</.button>
|
||||
<.button navigate={~p"/orgs/#{@organization.slug}/devices"}>Cancel</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<div class="mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<h3 class="text-lg font-semibold text-red-600 dark:text-red-500 mb-4">Danger Zone</h3>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
|
||||
Once you delete this device, there is no going back. All monitoring history and alerts will be permanently deleted.
|
||||
</p>
|
||||
<.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
|
||||
</.button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @live_action == :edit do %>
|
||||
<div class="mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<h3 class="text-lg font-semibold text-red-600 dark:text-red-500 mb-4">Danger Zone</h3>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
|
||||
Once you delete this device, there is no going back. All monitoring history and alerts will be permanently deleted.
|
||||
</p>
|
||||
<.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
|
||||
</.button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ defmodule ToweropsWeb.Router do
|
|||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
get "/docs/api", ApiDocsController, :index
|
||||
end
|
||||
|
||||
# Agent communication via WebSocket channel at /socket/agent
|
||||
|
|
|
|||
|
|
@ -136,9 +136,13 @@ defmodule ToweropsWeb.Telemetry do
|
|||
"""
|
||||
def publish_exq_stats do
|
||||
# Only run if Exq is available (not in test env)
|
||||
if Application.get_env(:towerops, :env) != :test do
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
:ok
|
||||
# Get stats for each queue
|
||||
|
||||
# Get process stats
|
||||
else
|
||||
try do
|
||||
# Get stats for each queue
|
||||
queues = ["default", "discovery", "polling", "monitoring", "maintenance"]
|
||||
|
||||
for queue <- queues do
|
||||
|
|
@ -151,7 +155,6 @@ defmodule ToweropsWeb.Telemetry do
|
|||
)
|
||||
end
|
||||
|
||||
# Get process stats
|
||||
{:ok, processes} = Exq.Api.processes(Exq)
|
||||
{:ok, busy} = Exq.Api.busy(Exq)
|
||||
|
||||
|
|
@ -166,6 +169,8 @@ defmodule ToweropsWeb.Telemetry do
|
|||
%{value: length(processes)},
|
||||
%{}
|
||||
)
|
||||
|
||||
:ok
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
|
@ -177,24 +182,27 @@ defmodule ToweropsWeb.Telemetry do
|
|||
"""
|
||||
def publish_redis_stats do
|
||||
# Only run if Redis is configured (not in test env)
|
||||
if Application.get_env(:towerops, :env) != :test do
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
:ok
|
||||
|
||||
# Connect to Redis and get INFO
|
||||
|
||||
# Parse INFO response
|
||||
|
||||
# Publish metrics
|
||||
else
|
||||
try do
|
||||
redis_config = Application.get_env(:towerops, :redis, [])
|
||||
host = Keyword.get(redis_config, :host, "localhost")
|
||||
port = Keyword.get(redis_config, :port, 6379)
|
||||
|
||||
# Connect to Redis and get INFO
|
||||
{:ok, conn} = Redix.start_link(host: host, port: port)
|
||||
{:ok, info} = Redix.command(conn, ["INFO", "stats"])
|
||||
{:ok, memory_info} = Redix.command(conn, ["INFO", "memory"])
|
||||
{:ok, clients_info} = Redix.command(conn, ["INFO", "clients"])
|
||||
|
||||
# Parse INFO response
|
||||
stats = parse_redis_info(info)
|
||||
memory_stats = parse_redis_info(memory_info)
|
||||
client_stats = parse_redis_info(clients_info)
|
||||
|
||||
# Publish metrics
|
||||
if total_commands = stats["total_commands_processed"] do
|
||||
:telemetry.execute(
|
||||
[:towerops, :redis, :commands_processed],
|
||||
|
|
@ -220,16 +228,20 @@ defmodule ToweropsWeb.Telemetry do
|
|||
end
|
||||
|
||||
Redix.stop(conn)
|
||||
:ok
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parse Redis INFO command output
|
||||
defp parse_redis_info(info_string) do
|
||||
@doc """
|
||||
Parse Redis INFO command output into a map.
|
||||
"""
|
||||
def parse_redis_info(info_string) do
|
||||
info_string
|
||||
|> String.split("\r\n")
|
||||
|> String.split(~r/\r?\n/)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(String.starts_with?(&1, "#") or &1 == ""))
|
||||
|> Enum.map(&String.split(&1, ":", parts: 2))
|
||||
|> Enum.filter(&(length(&1) == 2))
|
||||
|
|
|
|||
109
test/towerops_web/telemetry_test.exs
Normal file
109
test/towerops_web/telemetry_test.exs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
defmodule ToweropsWeb.TelemetryTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias ToweropsWeb.Telemetry
|
||||
|
||||
describe "metrics/0" do
|
||||
test "returns list of telemetry metrics" do
|
||||
metrics = Telemetry.metrics()
|
||||
|
||||
assert is_list(metrics)
|
||||
refute metrics == []
|
||||
|
||||
# Verify Phoenix metrics exist
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:phoenix, :endpoint, :stop]
|
||||
end)
|
||||
|
||||
# Verify Database metrics exist
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:towerops, :repo, :query, :total_time]
|
||||
end)
|
||||
|
||||
# Verify VM metrics exist
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:vm, :memory, :total]
|
||||
end)
|
||||
|
||||
# Verify Exq metrics exist
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:towerops, :exq, :queue, :size]
|
||||
end)
|
||||
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:towerops, :exq, :processes, :busy]
|
||||
end)
|
||||
|
||||
# Verify Redis metrics exist
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:towerops, :redis, :connected_clients]
|
||||
end)
|
||||
|
||||
assert Enum.any?(metrics, fn metric ->
|
||||
metric.event_name == [:towerops, :redis, :used_memory]
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_redis_info/1" do
|
||||
test "parses Redis INFO output correctly" do
|
||||
info_string = """
|
||||
# Server
|
||||
redis_version:7.0.0
|
||||
# Stats
|
||||
total_commands_processed:1000
|
||||
# Memory
|
||||
used_memory:1048576
|
||||
# Clients
|
||||
connected_clients:5
|
||||
"""
|
||||
|
||||
result = Telemetry.parse_redis_info(info_string)
|
||||
|
||||
assert result["redis_version"] == "7.0.0"
|
||||
assert result["total_commands_processed"] == "1000"
|
||||
assert result["used_memory"] == "1048576"
|
||||
assert result["connected_clients"] == "5"
|
||||
end
|
||||
|
||||
test "ignores comment lines" do
|
||||
info_string = """
|
||||
# Server
|
||||
redis_version:7.0.0
|
||||
"""
|
||||
|
||||
result = Telemetry.parse_redis_info(info_string)
|
||||
|
||||
refute Map.has_key?(result, "# Server")
|
||||
assert result["redis_version"] == "7.0.0"
|
||||
end
|
||||
|
||||
test "handles empty lines" do
|
||||
info_string = """
|
||||
redis_version:7.0.0
|
||||
|
||||
total_commands:100
|
||||
"""
|
||||
|
||||
result = Telemetry.parse_redis_info(info_string)
|
||||
|
||||
assert result["redis_version"] == "7.0.0"
|
||||
assert result["total_commands"] == "100"
|
||||
assert map_size(result) == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "publish_exq_stats/0" do
|
||||
test "does not crash in test environment" do
|
||||
# In test env, this should not execute but should not crash
|
||||
assert :ok = Telemetry.publish_exq_stats()
|
||||
end
|
||||
end
|
||||
|
||||
describe "publish_redis_stats/0" do
|
||||
test "does not crash in test environment" do
|
||||
# In test env, this should not execute but should not crash
|
||||
assert :ok = Telemetry.publish_redis_stats()
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue