From b1fedcda45f616f7295b49a879caba469796953b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 17 Jan 2026 17:49:53 -0600 Subject: [PATCH] updates --- CLAUDE.md | 49 + assets/js/app.ts | 14 +- .../controllers/api_docs_controller.ex | 31 + lib/towerops_web/controllers/api_docs_html.ex | 8 + .../controllers/api_docs_html/index.html.heex | 1117 +++++++++++++++++ .../api_docs_html/index.html.heex.bak | 1075 ++++++++++++++++ lib/towerops_web/live/device_live/form.ex | 3 +- .../live/device_live/form.html.heex | 448 +++---- lib/towerops_web/router.ex | 1 + lib/towerops_web/telemetry.ex | 36 +- test/towerops_web/telemetry_test.exs | 109 ++ 11 files changed, 2653 insertions(+), 238 deletions(-) create mode 100644 lib/towerops_web/controllers/api_docs_controller.ex create mode 100644 lib/towerops_web/controllers/api_docs_html.ex create mode 100644 lib/towerops_web/controllers/api_docs_html/index.html.heex create mode 100644 lib/towerops_web/controllers/api_docs_html/index.html.heex.bak create mode 100644 test/towerops_web/telemetry_test.exs diff --git a/CLAUDE.md b/CLAUDE.md index 63369e22..fccc2751 100644 --- a/CLAUDE.md +++ b/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 +
<%= raw(~S"""
+curl https://towerops.net/api/v1/sites \
+  -H "Authorization: Bearer {token}"
+""") %>
+``` + +**JSON Example Pattern**: +```heex +
<%= raw(~S"""
+{
+  "id": "uuid",
+  "name": "Site Name"
+}
+""") %>
+``` + +**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: diff --git a/assets/js/app.ts b/assets/js/app.ts index 2d03e9e0..478ce643 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 diff --git a/lib/towerops_web/controllers/api_docs_controller.ex b/lib/towerops_web/controllers/api_docs_controller.ex new file mode 100644 index 00000000..71f18d30 --- /dev/null +++ b/lib/towerops_web/controllers/api_docs_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api_docs_html.ex b/lib/towerops_web/controllers/api_docs_html.ex new file mode 100644 index 00000000..27dde166 --- /dev/null +++ b/lib/towerops_web/controllers/api_docs_html.ex @@ -0,0 +1,8 @@ +defmodule ToweropsWeb.ApiDocsHTML do + @moduledoc """ + This module contains API documentation pages. + """ + use ToweropsWeb, :html + + embed_templates "api_docs_html/*" +end diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex b/lib/towerops_web/controllers/api_docs_html/index.html.heex new file mode 100644 index 00000000..09ffacf8 --- /dev/null +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex @@ -0,0 +1,1117 @@ + + + + +
+
+
+ +
+

+ Towerops API Reference +

+

+ Use the Towerops API to manage your network monitoring infrastructure programmatically. Create and monitor sites, devices, and alerts. +

+
+ + +
+

+ Authentication +

+

+ All API requests require authentication using an API token. Include your token in the Authorization header: +

+
+
<%= raw("""
+curl https://towerops.net/api/v1/sites \\
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+

+ API tokens are created and managed in your organization settings. Each token is scoped to a single organization. +

+
+ + +
+

+ Errors +

+

+ The API uses conventional HTTP response codes to indicate success or failure: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Code + + Description +
200Success
201 + Created successfully +
400 + Bad request - missing or invalid parameters +
401 + Unauthorized - invalid or missing API token +
403 + Forbidden - you don't have access to this resource +
404 + Not found - resource doesn't exist +
422 + Unprocessable entity - validation errors +
+
+
+ +
+ + +
+

+ Sites +

+

+ Sites represent physical locations where your network devices are deployed. Each site can have multiple devices and may have default SNMP configuration. +

+ + +
+

The site model

+
+
+
+
+ id + + string + +
+
+ Unique identifier for the site (UUID). +
+
+
+
+ name + + string + +
+
+ The name of the site. +
+
+
+
+ location + + string + +
+
+ Physical location or address of the site. +
+
+
+
+ snmp_community + + string + +
+
+ Default SNMP community string for devices at this site (optional). +
+
+
+
+ inserted_at + + timestamp + +
+
+ Timestamp when the site was created. +
+
+
+
+
+ +
+ + +
+
+

List all sites

+ + GET + + /api/v1/sites +
+ +
+ +
+

+ Lists all sites for the authenticated organization. +

+
+ + +
+
+
+

Request

+
+
<%= raw("""
+curl -G https://towerops.net/api/v1/sites \\
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "sites": [
+    {
+      "id": "550e8400-e29b-41d4-a716-446655440000",
+      "name": "Main Office",
+      "location": "New York, NY",
+      "snmp_community": "public",
+      "inserted_at": "2026-01-15T19:44:25Z"
+    }
+  ]
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Create a site

+ + POST + + /api/v1/sites +
+ +
+ +
+

+ Creates a new site for the authenticated organization. +

+ +

+ Required parameters +

+
+
+
+ name + + string + +
+
+ The name of the site. +
+
+
+
+ location + + string + +
+
+ Physical location of the site. +
+
+
+ +

+ Optional parameters +

+
+
+
+ snmp_community + + string + +
+
+ Default SNMP community string for devices at this site. +
+
+
+
+ + +
+
+
+

Request

+
+
<%= raw("""
+curl https://towerops.net/api/v1/sites \
+  -H "Authorization: Bearer #{@sample_token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "site": {
+      "name": "Main Office",
+      "location": "New York, NY",
+      "snmp_community": "public"
+    }
+  }'
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "name": "Main Office",
+  "location": "New York, NY",
+  "snmp_community": "public",
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Retrieve a site

+ + GET + + + /api/v1/sites/:id + +
+ +
+
+

+ Retrieves a single site by ID. +

+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl https://towerops.net/api/v1/sites/550e8400-e29b-41d4-a716-446655440000 \
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "name": "Main Office",
+  "location": "New York, NY",
+  "snmp_community": "public",
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Update a site

+ + PATCH + + + /api/v1/sites/:id + +
+ +
+
+

+ Updates an existing site. Only provided fields will be updated. +

+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl -X PATCH https://towerops.net/api/v1/sites/550e8400-e29b-41d4-a716-446655440000 \
+  -H "Authorization: Bearer #{@sample_token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "site": {
+      "name": "Updated Office Name"
+    }
+  }'
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "name": "Updated Office Name",
+  "location": "New York, NY",
+  "snmp_community": "public",
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Delete a site

+ + DELETE + + + /api/v1/sites/:id + +
+ +
+
+

+ Deletes a site. Note: This will also delete all devices associated with the site. +

+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl -X DELETE https://towerops.net/api/v1/sites/550e8400-e29b-41d4-a716-446655440000 \
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "success": true
+}
+""") %>
+
+
+
+
+
+ +
+ + +
+

+ Devices +

+

+ Devices are network equipment monitored by Towerops. Each device belongs to a site and can be monitored via ICMP ping and SNMP. +

+ + +
+

The device model

+
+
+
+
+ id + + string + +
+
+ Unique identifier for the device (UUID). +
+
+
+
+ name + + string + +
+
+ The name of the device. +
+
+
+
+ ip_address + + string + +
+
+ IP address of the device. +
+
+
+
+ site_id + + string + +
+
+ ID of the site this device belongs to. +
+
+
+
+ description + + string + +
+
+ Optional description of the device. +
+
+
+
+ monitoring_enabled + + boolean + +
+
+ Whether monitoring is enabled for this device. +
+
+
+
+ check_interval_seconds + + integer + +
+
+ How often to poll this device (in seconds). +
+
+
+
+ snmp_enabled + + boolean + +
+
+ Whether SNMP monitoring is enabled. +
+
+
+
+ snmp_version + + string + +
+
+ SNMP version to use (e.g., "2c"). +
+
+
+
+ snmp_port + + integer + +
+
+ SNMP port (default: 161). +
+
+
+
+ inserted_at + + timestamp + +
+
+ Timestamp when the device was created. +
+
+
+
+
+ +
+ + +
+
+

List all devices

+ + GET + + + /api/v1/devices + +
+ +
+
+

+ Lists all devices for the authenticated organization. +

+ +

+ Optional parameters +

+
+
+
+ site_id + + string + +
+
+ Filter devices by site ID. +
+
+
+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl -G https://towerops.net/api/v1/devices \
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "devices": [
+    {
+      "id": "650e8400-e29b-41d4-a716-446655440001",
+      "name": "Core Router",
+      "ip_address": "192.168.1.1",
+      "site_id": "550e8400-e29b-41d4-a716-446655440000",
+      "monitoring_enabled": true,
+      "snmp_enabled": true,
+      "inserted_at": "2026-01-15T19:44:25Z"
+    }
+  ]
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Create a device

+ + POST + + + /api/v1/devices + +
+ +
+
+

+ Creates a new device. +

+ +

+ Required parameters +

+
+
+
+ site_id + + string + +
+
+ ID of the site this device belongs to. +
+
+
+
+ name + + string + +
+
+ The name of the device. +
+
+
+
+ ip_address + + string + +
+
+ IP address of the device. +
+
+
+ +

+ Optional parameters +

+
+
+
+ description + + string + +
+
+ Description of the device. +
+
+
+
+ monitoring_enabled + + boolean + +
+
+ Enable monitoring (default: true). +
+
+
+
+ snmp_enabled + + boolean + +
+
+ Enable SNMP monitoring (default: true). +
+
+
+
+ snmp_version + + string + +
+
+ SNMP version (default: "2c"). +
+
+
+
+ snmp_community + + string + +
+
+ SNMP community string (inherits from site/org if not set). +
+
+
+
+ snmp_port + + integer + +
+
+ SNMP port (default: 161). +
+
+
+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl https://towerops.net/api/v1/devices \
+  -H "Authorization: Bearer #{@sample_token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device": {
+      "site_id": "550e8400-e29b-41d4-a716-446655440000",
+      "name": "Core Router",
+      "ip_address": "192.168.1.1",
+      "description": "Main router",
+      "snmp_community": "public"
+    }
+  }'
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "id": "650e8400-e29b-41d4-a716-446655440001",
+  "name": "Core Router",
+  "ip_address": "192.168.1.1",
+  "site_id": "550e8400-e29b-41d4-a716-446655440000",
+  "monitoring_enabled": true,
+  "snmp_enabled": true,
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Retrieve a device

+ + GET + + + /api/v1/devices/:id + +
+ +
+
+

+ Retrieves a single device by ID, including all configuration details. +

+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-446655440001 \
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "id": "650e8400-e29b-41d4-a716-446655440001",
+  "name": "Core Router",
+  "ip_address": "192.168.1.1",
+  "site_id": "550e8400-e29b-41d4-a716-446655440000",
+  "description": "Main router",
+  "monitoring_enabled": true,
+  "check_interval_seconds": 300,
+  "snmp_enabled": true,
+  "snmp_version": "2c",
+  "snmp_port": 161,
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Update a device

+ + PATCH + + + /api/v1/devices/:id + +
+ +
+
+

+ Updates an existing device. Only provided fields will be updated. +

+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl -X PATCH https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-446655440001 \
+  -H "Authorization: Bearer #{@sample_token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device": {
+      "name": "Updated Router Name",
+      "monitoring_enabled": false
+    }
+  }'
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "id": "650e8400-e29b-41d4-a716-446655440001",
+  "name": "Updated Router Name",
+  "ip_address": "192.168.1.1",
+  "site_id": "550e8400-e29b-41d4-a716-446655440000",
+  "description": "Main router",
+  "monitoring_enabled": false,
+  "check_interval_seconds": 300,
+  "snmp_enabled": true,
+  "snmp_version": "2c",
+  "snmp_port": 161,
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+""") %>
+
+
+
+
+ +
+ + +
+
+

Delete a device

+ + DELETE + + + /api/v1/devices/:id + +
+ +
+
+

+ Deletes a device. Note: This will also delete all monitoring data associated with the device. +

+
+ +
+
+
+

Request

+
+
<%= raw("""
+curl -X DELETE https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-446655440001 \
+  -H "Authorization: Bearer #{@sample_token}"
+""") %>
+
+ +
+
+

Response

+
+
<%= raw("""
+{
+  "success": true
+}
+""") %>
+
+
+
+
+
+ + +
+

+ © 2026 Towerops. All rights reserved. +

+
+
+
+
diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex.bak b/lib/towerops_web/controllers/api_docs_html/index.html.heex.bak new file mode 100644 index 00000000..c8026d7b --- /dev/null +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex.bak @@ -0,0 +1,1075 @@ +
+ + + + +
+
+ +
+

+ Towerops API Reference +

+

+ Use the Towerops API to manage your network monitoring infrastructure programmatically. Create and monitor sites, devices, and alerts. +

+
+ + +
+

+ Authentication +

+

+ All API requests require authentication using an API token. Include your token in the Authorization header: +

+
+
curl https://towerops.net/api/v1/sites \
+  -H "Authorization: Bearer YOUR_API_TOKEN"
+
+

+ API tokens are created and managed in your organization settings. Each token is scoped to a single organization. +

+
+ + +
+

+ Errors +

+

+ The API uses conventional HTTP response codes to indicate success or failure: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Code + + Description +
200Success
201 + Created successfully +
400 + Bad request - missing or invalid parameters +
401 + Unauthorized - invalid or missing API token +
403 + Forbidden - you don't have access to this resource +
404 + Not found - resource doesn't exist +
422 + Unprocessable entity - validation errors +
+
+
+ +
+ + +
+

+ Sites +

+

+ Sites represent physical locations where your network devices are deployed. Each site can have multiple devices and may have default SNMP configuration. +

+ + +
+

The site model

+
+
+
+
+ id + + string + +
+
+ Unique identifier for the site (UUID). +
+
+
+
+ name + + string + +
+
+ The name of the site. +
+
+
+
+ location + + string + +
+
+ Physical location or address of the site. +
+
+
+
+ snmp_community + + string + +
+
+ Default SNMP community string for devices at this site (optional). +
+
+
+
+ inserted_at + + timestamp + +
+
+ Timestamp when the site was created. +
+
+
+
+
+ +
+ + +
+
+

List all sites

+ + GET + + /api/v1/sites +
+ +
+ +
+

+ Lists all sites for the authenticated organization. +

+
+ + +
+
+
+

Request

+
+
curl -G https://towerops.net/api/v1/sites \
+  -H "Authorization: Bearer {token}"
+
+ +
+
+

Response

+
+
{
+  "sites": [
+    {
+      "id": "550e8400-e29b-41d4-a716-446655440000",
+      "name": "Main Office",
+      "location": "New York, NY",
+      "snmp_community": "public",
+      "inserted_at": "2026-01-15T19:44:25Z"
+    }
+  ]
+}
+
+
+
+
+ +
+ + +
+
+

Create a site

+ + POST + + /api/v1/sites +
+ +
+ +
+

+ Creates a new site for the authenticated organization. +

+ +

+ Required parameters +

+
+
+
+ name + + string + +
+
+ The name of the site. +
+
+
+
+ location + + string + +
+
+ Physical location of the site. +
+
+
+ +

+ Optional parameters +

+
+
+
+ snmp_community + + string + +
+
+ Default SNMP community string for devices at this site. +
+
+
+
+ + +
+
+
+

Request

+
+
curl https://towerops.net/api/v1/sites \
+  -H "Authorization: Bearer {token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "site": {
+      "name": "Main Office",
+      "location": "New York, NY",
+      "snmp_community": "public"
+    }
+  }'
+
+ +
+
+

Response

+
+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "name": "Main Office",
+  "location": "New York, NY",
+  "snmp_community": "public",
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+
+
+
+
+ +
+ + +
+
+

Retrieve a site

+ + GET + + + /api/v1/sites/:id + +
+ +
+
+

+ Retrieves a single site by ID. +

+
+ +
+
+
+

Request

+
+
curl https://towerops.net/api/v1/sites/550e8400-e29b-41d4-a716-446655440000 \
+  -H "Authorization: Bearer {token}"
+
+ +
+
+

Response

+
+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "name": "Main Office",
+  "location": "New York, NY",
+  "snmp_community": "public",
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+
+
+
+
+ +
+ + +
+
+

Update a site

+ + PATCH + + + /api/v1/sites/:id + +
+ +
+
+

+ Updates an existing site. Only provided fields will be updated. +

+
+ +
+
+
+

Request

+
+
curl -X PATCH https://towerops.net/api/v1/sites/550e8400-e29b-41d4-a716-446655440000 \
+  -H "Authorization: Bearer {token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "site": {
+      "name": "Updated Office Name"
+    }
+  }'
+
+ +
+
+

Response

+
+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "name": "Updated Office Name",
+  "location": "New York, NY",
+  "snmp_community": "public",
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+
+
+
+
+ +
+ + +
+
+

Delete a site

+ + DELETE + + + /api/v1/sites/:id + +
+ +
+
+

+ Deletes a site. Note: This will also delete all devices associated with the site. +

+
+ +
+
+
+

Request

+
+
curl -X DELETE https://towerops.net/api/v1/sites/550e8400-e29b-41d4-a716-446655440000 \
+  -H "Authorization: Bearer {token}"
+
+ +
+
+

Response

+
+
{
+  "success": true
+}
+
+
+
+
+
+ +
+ + +
+

+ Devices +

+

+ Devices are network equipment monitored by Towerops. Each device belongs to a site and can be monitored via ICMP ping and SNMP. +

+ + +
+

The device model

+
+
+
+
+ id + + string + +
+
+ Unique identifier for the device (UUID). +
+
+
+
+ name + + string + +
+
+ The name of the device. +
+
+
+
+ ip_address + + string + +
+
+ IP address of the device. +
+
+
+
+ site_id + + string + +
+
+ ID of the site this device belongs to. +
+
+
+
+ description + + string + +
+
+ Optional description of the device. +
+
+
+
+ monitoring_enabled + + boolean + +
+
+ Whether monitoring is enabled for this device. +
+
+
+
+ check_interval_seconds + + integer + +
+
+ How often to poll this device (in seconds). +
+
+
+
+ snmp_enabled + + boolean + +
+
+ Whether SNMP monitoring is enabled. +
+
+
+
+ snmp_version + + string + +
+
+ SNMP version to use (e.g., "2c"). +
+
+
+
+ snmp_port + + integer + +
+
+ SNMP port (default: 161). +
+
+
+
+ inserted_at + + timestamp + +
+
+ Timestamp when the device was created. +
+
+
+
+
+ +
+ + +
+
+

List all devices

+ + GET + + + /api/v1/devices + +
+ +
+
+

+ Lists all devices for the authenticated organization. +

+ +

+ Optional parameters +

+
+
+
+ site_id + + string + +
+
+ Filter devices by site ID. +
+
+
+
+ +
+
+
+

Request

+
+
curl -G https://towerops.net/api/v1/devices \
+  -H "Authorization: Bearer {token}"
+
+ +
+
+

Response

+
+
{
+  "devices": [
+    {
+      "id": "650e8400-e29b-41d4-a716-446655440001",
+      "name": "Core Router",
+      "ip_address": "192.168.1.1",
+      "site_id": "550e8400-e29b-41d4-a716-446655440000",
+      "monitoring_enabled": true,
+      "snmp_enabled": true,
+      "inserted_at": "2026-01-15T19:44:25Z"
+    }
+  ]
+}
+
+
+
+
+ +
+ + +
+
+

Create a device

+ + POST + + + /api/v1/devices + +
+ +
+
+

+ Creates a new device. +

+ +

+ Required parameters +

+
+
+
+ site_id + + string + +
+
+ ID of the site this device belongs to. +
+
+
+
+ name + + string + +
+
+ The name of the device. +
+
+
+
+ ip_address + + string + +
+
+ IP address of the device. +
+
+
+ +

+ Optional parameters +

+
+
+
+ description + + string + +
+
+ Description of the device. +
+
+
+
+ monitoring_enabled + + boolean + +
+
+ Enable monitoring (default: true). +
+
+
+
+ snmp_enabled + + boolean + +
+
+ Enable SNMP monitoring (default: true). +
+
+
+
+ snmp_version + + string + +
+
+ SNMP version (default: "2c"). +
+
+
+
+ snmp_community + + string + +
+
+ SNMP community string (inherits from site/org if not set). +
+
+
+
+ snmp_port + + integer + +
+
+ SNMP port (default: 161). +
+
+
+
+ +
+
+
+

Request

+
+
curl https://towerops.net/api/v1/devices \
+  -H "Authorization: Bearer {token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device": {
+      "site_id": "550e8400-e29b-41d4-a716-446655440000",
+      "name": "Core Router",
+      "ip_address": "192.168.1.1",
+      "description": "Main router",
+      "snmp_community": "public"
+    }
+  }'
+
+ +
+
+

Response

+
+
{
+  "id": "650e8400-e29b-41d4-a716-446655440001",
+  "name": "Core Router",
+  "ip_address": "192.168.1.1",
+  "site_id": "550e8400-e29b-41d4-a716-446655440000",
+  "monitoring_enabled": true,
+  "snmp_enabled": true,
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+
+
+
+
+ +
+ + +
+
+

Retrieve a device

+ + GET + + + /api/v1/devices/:id + +
+ +
+
+

+ Retrieves a single device by ID, including all configuration details. +

+
+ +
+
+
+

Request

+
+
curl https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-446655440001 \
+  -H "Authorization: Bearer {token}"
+
+ +
+
+

Response

+
+
{
+  "id": "650e8400-e29b-41d4-a716-446655440001",
+  "name": "Core Router",
+  "ip_address": "192.168.1.1",
+  "site_id": "550e8400-e29b-41d4-a716-446655440000",
+  "description": "Main router",
+  "monitoring_enabled": true,
+  "check_interval_seconds": 300,
+  "snmp_enabled": true,
+  "snmp_version": "2c",
+  "snmp_port": 161,
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+
+
+
+
+ +
+ + +
+
+

Update a device

+ + PATCH + + + /api/v1/devices/:id + +
+ +
+
+

+ Updates an existing device. Only provided fields will be updated. +

+
+ +
+
+
+

Request

+
+
curl -X PATCH https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-446655440001 \
+  -H "Authorization: Bearer {token}" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device": {
+      "name": "Updated Router Name",
+      "monitoring_enabled": false
+    }
+  }'
+
+ +
+
+

Response

+
+
{
+  "id": "650e8400-e29b-41d4-a716-446655440001",
+  "name": "Updated Router Name",
+  "ip_address": "192.168.1.1",
+  "site_id": "550e8400-e29b-41d4-a716-446655440000",
+  "description": "Main router",
+  "monitoring_enabled": false,
+  "check_interval_seconds": 300,
+  "snmp_enabled": true,
+  "snmp_version": "2c",
+  "snmp_port": 161,
+  "inserted_at": "2026-01-15T19:44:25Z"
+}
+
+
+
+
+ +
+ + +
+
+

Delete a device

+ + DELETE + + + /api/v1/devices/:id + +
+ +
+
+

+ Deletes a device. Note: This will also delete all monitoring data associated with the device. +

+
+ +
+
+
+

Request

+
+
curl -X DELETE https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-446655440001 \
+  -H "Authorization: Bearer {token}"
+
+ +
+
+

Response

+
+
{
+  "success": true
+}
+
+
+
+
+
+ + +
+

+ © 2026 Towerops. All rights reserved. +

+
+
+
+
diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index ae7799f3..c902ff7c 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -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))} diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index 7c3dc32e..f3f3fe9f 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -3,260 +3,262 @@ current_scope={@current_scope} current_organization={@organization} > -
- <.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"} - -
- - <.header> - {@page_title} - <:subtitle> - {if @live_action == :new, - do: "Add new device to monitor", - else: "Update device details"} - - - -
- <.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 +
+
+ <.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"} + +
- <.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"} + + - <.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 %> +
+ <.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 -> %> -

- <.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} -

- <% :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 %> -
-
+ <.input field={@form[:ip_address]} type="text" label="IP Address" required /> -
+ <.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 -> %> -

+

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

<% :site -> %> -

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

+ <.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 (v{@effective_snmp_version}, community: {@effective_snmp_community}) + Inherited from organization: {@effective_agent_name}

- <% :default -> %> -

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

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 %>
-
- <.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> - Test SNMP Connection - +
+ <.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 - + <.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} +
+
+ +
+ <.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 %> +
-
-
- <.button phx-disable-with="Saving..." variant="primary">Save Device - <.button navigate={~p"/orgs/#{@organization.slug}/devices"}>Cancel -
- +
+ <.button phx-disable-with="Saving..." variant="primary">Save Device + <.button navigate={~p"/orgs/#{@organization.slug}/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 - 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 %> + <%= 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 + 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 %> +
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 7cdaffc1..5d11818e 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/lib/towerops_web/telemetry.ex b/lib/towerops_web/telemetry.ex index c03e0611..3aeaa603 100644 --- a/lib/towerops_web/telemetry.ex +++ b/lib/towerops_web/telemetry.ex @@ -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)) diff --git a/test/towerops_web/telemetry_test.exs b/test/towerops_web/telemetry_test.exs new file mode 100644 index 00000000..878ce7f2 --- /dev/null +++ b/test/towerops_web/telemetry_test.exs @@ -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