This commit is contained in:
Graham McIntire 2026-01-17 17:49:53 -06:00
parent cca873abc8
commit b1fedcda45
No known key found for this signature in database
11 changed files with 2653 additions and 238 deletions

View file

@ -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:

View file

@ -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

View 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

View 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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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))}

View file

@ -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>

View file

@ -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

View file

@ -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))

View 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