feat: add GraphQL API with Absinthe, full schema, resolvers, and documentation

This commit is contained in:
Graham McIntire 2026-02-14 11:24:50 -06:00
parent 59a786d8de
commit eb76f9475e
10 changed files with 1113 additions and 0 deletions

View file

@ -0,0 +1,26 @@
defmodule ToweropsWeb.GraphQLDocsController do
@moduledoc "Controller for GraphQL API documentation page."
use ToweropsWeb, :controller
alias Towerops.ApiTokens
def index(conn, _params) do
sample_token =
case conn.assigns[:current_scope] do
%{user: user, organization: org} when not is_nil(user) and not is_nil(org) ->
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
_ ->
"YOUR_API_TOKEN"
end
render(conn, :index, sample_token: sample_token)
end
end

View file

@ -0,0 +1,6 @@
defmodule ToweropsWeb.GraphQLDocsHTML do
@moduledoc "GraphQL API documentation pages."
use ToweropsWeb, :html
embed_templates "graphql_docs_html/*"
end

View file

@ -0,0 +1,737 @@
<!-- Sidebar -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-40 lg:flex lg:w-72 lg:flex-col xl:w-80">
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-zinc-900/10 px-6 pt-4 pb-8 dark:border-white/10">
<!-- Logo -->
<div class="flex h-16 shrink-0 items-center">
<.link navigate={~p"/"} class="text-2xl font-bold text-zinc-900 dark:text-white">
Towerops GraphQL API
</.link>
</div>
<!-- Navigation -->
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<!-- Guides Section -->
<li>
<h2 class="text-xs font-semibold text-zinc-900 dark:text-white">Guides</h2>
<ul role="list" class="mt-3 space-y-1 border-l border-zinc-900/10 dark:border-white/5">
<li>
<a href="#introduction" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
Introduction
</a>
</li>
<li>
<a href="#authentication" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
Authentication
</a>
</li>
<li>
<a href="#endpoint" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
Endpoint
</a>
</li>
</ul>
</li>
<!-- Queries Section -->
<li>
<h2 class="text-xs font-semibold text-zinc-900 dark:text-white">Queries</h2>
<ul role="list" class="mt-3 space-y-1 border-l border-zinc-900/10 dark:border-white/5">
<li><a href="#query-devices" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Devices</a></li>
<li><a href="#query-sites" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Sites</a></li>
<li><a href="#query-alerts" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Alerts</a></li>
<li><a href="#query-agents" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Agents</a></li>
<li><a href="#query-organization" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Organization</a></li>
<li><a href="#query-members" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Members</a></li>
<li><a href="#query-integrations" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Integrations</a></li>
<li><a href="#query-activity" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Activity</a></li>
<li><a href="#query-metrics" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Metrics & Interfaces</a></li>
</ul>
</li>
<!-- Mutations Section -->
<li>
<h2 class="text-xs font-semibold text-zinc-900 dark:text-white">Mutations</h2>
<ul role="list" class="mt-3 space-y-1 border-l border-zinc-900/10 dark:border-white/5">
<li><a href="#mutation-devices" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Device CRUD</a></li>
<li><a href="#mutation-sites" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Site CRUD</a></li>
<li><a href="#mutation-alerts" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Alert Actions</a></li>
<li><a href="#mutation-agents" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Agent Management</a></li>
<li><a href="#mutation-organization" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Organization</a></li>
<li><a href="#mutation-members" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Members</a></li>
<li><a href="#mutation-integrations" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">Integrations</a></li>
</ul>
</li>
<!-- Types Section -->
<li>
<h2 class="text-xs font-semibold text-zinc-900 dark:text-white">Types</h2>
<ul role="list" class="mt-3 space-y-1 border-l border-zinc-900/10 dark:border-white/5">
<li><a href="#types" class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">All Types</a></li>
</ul>
</li>
<!-- Links -->
<li class="mt-auto">
<.link navigate={~p"/docs/api"} class="block py-1 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
← REST API Docs
</.link>
</li>
</ul>
</nav>
</div>
</div>
<!-- Main content -->
<div class="lg:pl-72 xl:pl-80">
<div class="mx-auto max-w-3xl px-6 py-10 lg:px-8">
<!-- Introduction -->
<section id="introduction" class="mb-16">
<h1 class="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">GraphQL API</h1>
<p class="mt-4 text-lg text-gray-600 dark:text-gray-400">
The TowerOps GraphQL API provides a flexible, strongly-typed interface for querying and managing your network infrastructure.
Request exactly the data you need in a single request — no over-fetching, no under-fetching.
</p>
<p class="mt-2 text-gray-600 dark:text-gray-400">
The GraphQL API uses the same authentication as the
<.link navigate={~p"/docs/api"} class="text-blue-600 hover:text-blue-500 dark:text-blue-400">REST API</.link>
and provides equivalent functionality with the added benefit of nested queries and precise field selection.
</p>
</section>
<!-- Authentication -->
<section id="authentication" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Authentication</h2>
<p class="mt-4 text-gray-600 dark:text-gray-400">
Authenticate using a Bearer token in the <code class="rounded bg-zinc-100 px-1.5 py-0.5 text-sm font-mono text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">Authorization</code> header.
API tokens can be created in your organization settings.
</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>Authorization: Bearer <%= @sample_token %></code></pre>
</div>
</section>
<!-- Endpoint -->
<section id="endpoint" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Endpoint</h2>
<p class="mt-4 text-gray-600 dark:text-gray-400">
All GraphQL requests are sent as <code class="rounded bg-zinc-100 px-1.5 py-0.5 text-sm font-mono text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">POST</code> to a single endpoint:
</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400"><code>POST https://app.towerops.net/api/graphql</code></pre>
</div>
<p class="mt-4 text-gray-600 dark:text-gray-400">
Send a JSON body with a <code class="rounded bg-zinc-100 px-1.5 py-0.5 text-sm font-mono text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">query</code> field
and optional <code class="rounded bg-zinc-100 px-1.5 py-0.5 text-sm font-mono text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">variables</code>:
</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>curl -X POST https://app.towerops.net/api/graphql \
-H "Authorization: Bearer <%= @sample_token %>" \
-H "Content-Type: application/json" \
-d '{"query": "{ devices { id name ipAddress status } }"}'</code></pre>
</div>
</section>
<!-- Queries: Devices -->
<section id="query-devices" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Devices</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">devices</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">List all devices. Optionally filter by site or status.</p>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b border-zinc-200 dark:border-zinc-700">
<th class="py-2 pr-4 text-left font-medium text-zinc-900 dark:text-white">Argument</th>
<th class="py-2 pr-4 text-left font-medium text-zinc-900 dark:text-white">Type</th>
<th class="py-2 text-left font-medium text-zinc-900 dark:text-white">Description</th>
</tr></thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="py-2 pr-4 font-mono text-xs">siteId</td><td class="py-2 pr-4">ID</td><td class="py-2">Filter by site</td></tr>
<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="py-2 pr-4 font-mono text-xs">status</td><td class="py-2 pr-4">String</td><td class="py-2">Filter by status (up, down, unknown)</td></tr>
<tr><td class="py-2 pr-4 font-mono text-xs">limit</td><td class="py-2 pr-4">Int</td><td class="py-2">Max results (default: 100)</td></tr>
</tbody>
</table>
</div>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
devices(siteId: "uuid", status: "up") {
id
name
ipAddress
status
lastCheckedAt
monitoringEnabled
snmpEnabled
deviceRole
site {
id
name
location
}
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">device</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">Get a single device by ID.</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
device(id: "device-uuid") {
id
name
ipAddress
status
description
snmpVersion
snmpPort
checkIntervalSeconds
site { id name }
}
}</code></pre>
</div>
</section>
<!-- Queries: Sites -->
<section id="query-sites" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Sites</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">sites</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">List all sites for the organization.</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
sites {
id
name
description
location
snmpVersion
snmpCommunity
insertedAt
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">site</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">Get a single site by ID.</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{ site(id: "site-uuid") { id name location } }</code></pre>
</div>
</section>
<!-- Queries: Alerts -->
<section id="query-alerts" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Alerts</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">alerts</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">List alerts. Filter by status or device.</p>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b border-zinc-200 dark:border-zinc-700">
<th class="py-2 pr-4 text-left font-medium text-zinc-900 dark:text-white">Argument</th>
<th class="py-2 pr-4 text-left font-medium text-zinc-900 dark:text-white">Type</th>
<th class="py-2 text-left font-medium text-zinc-900 dark:text-white">Description</th>
</tr></thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="py-2 pr-4 font-mono text-xs">status</td><td class="py-2 pr-4">String</td><td class="py-2">active, acknowledged, or resolved</td></tr>
<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="py-2 pr-4 font-mono text-xs">deviceId</td><td class="py-2 pr-4">ID</td><td class="py-2">Filter by device</td></tr>
<tr><td class="py-2 pr-4 font-mono text-xs">limit</td><td class="py-2 pr-4">Int</td><td class="py-2">Max results (default: 100)</td></tr>
</tbody>
</table>
</div>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
alerts(status: "active", limit: 10) {
id
alertType
message
triggeredAt
acknowledgedAt
resolvedAt
deviceId
device {
id
name
ipAddress
}
acknowledgedByEmail
}
}</code></pre>
</div>
</section>
<!-- Queries: Agents -->
<section id="query-agents" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Agents</h2>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
agents {
id
name
enabled
lastSeenAt
lastIp
isCloudPoller
deviceCount
metadata
}
}
# Single agent
{ agent(id: "agent-uuid") { id name enabled deviceCount } }</code></pre>
</div>
</section>
<!-- Queries: Organization -->
<section id="query-organization" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Organization</h2>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
organization {
id
name
slug
subscriptionPlan
useSites
snmpVersion
snmpCommunity
snmpPort
snmpTransport
mikrotikEnabled
mikrotikUsername
mikrotikPort
mikrotikUseSsl
defaultAgentTokenId
}
}</code></pre>
</div>
</section>
<!-- Queries: Members -->
<section id="query-members" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Members</h2>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
members {
id
email
role
insertedAt
}
}</code></pre>
</div>
</section>
<!-- Queries: Integrations -->
<section id="query-integrations" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Integrations</h2>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
integrations {
id
provider
enabled
syncIntervalMinutes
lastSyncedAt
lastSyncStatus
}
}</code></pre>
</div>
</section>
<!-- Queries: Activity -->
<section id="query-activity" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Activity</h2>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
activity(limit: 20) {
summary
detail
timestamp
severity
deviceName
siteName
link
type
}
}</code></pre>
</div>
</section>
<!-- Queries: Metrics & Interfaces -->
<section id="query-metrics" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Query: Metrics & Interfaces</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">deviceMetrics</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">Get time-series metrics for a device.</p>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b border-zinc-200 dark:border-zinc-700">
<th class="py-2 pr-4 text-left font-medium text-zinc-900 dark:text-white">Argument</th>
<th class="py-2 pr-4 text-left font-medium text-zinc-900 dark:text-white">Type</th>
<th class="py-2 text-left font-medium text-zinc-900 dark:text-white">Description</th>
</tr></thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="py-2 pr-4 font-mono text-xs">deviceId</td><td class="py-2 pr-4">ID!</td><td class="py-2">Required device ID</td></tr>
<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="py-2 pr-4 font-mono text-xs">sensorType</td><td class="py-2 pr-4">String</td><td class="py-2">Filter by sensor type</td></tr>
<tr><td class="py-2 pr-4 font-mono text-xs">timeRange</td><td class="py-2 pr-4">String</td><td class="py-2">e.g. "24h", "72h" (default: "24h")</td></tr>
</tbody>
</table>
</div>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
deviceMetrics(deviceId: "device-uuid", timeRange: "48h") {
timestamp
value
status
checkName
checkType
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">deviceInterfaces</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">Get SNMP interfaces for a device.</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>{
deviceInterfaces(deviceId: "device-uuid") {
id
ifIndex
ifName
ifDescr
ifType
ifSpeed
ifAdminStatus
ifOperStatus
ifAlias
}
}</code></pre>
</div>
</section>
<!-- Mutations: Devices -->
<section id="mutation-devices" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Devices</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">createDevice</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
createDevice(input: {
ipAddress: "192.168.1.1"
name: "Core Router"
siteId: "site-uuid"
snmpEnabled: true
snmpVersion: "2c"
snmpCommunity: "public"
}) {
id
name
ipAddress
status
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">updateDevice</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
updateDevice(id: "device-uuid", input: {
name: "Updated Router"
monitoringEnabled: false
}) {
id
name
monitoringEnabled
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">deleteDevice</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
deleteDevice(id: "device-uuid") {
success
message
}
}</code></pre>
</div>
</section>
<!-- Mutations: Sites -->
<section id="mutation-sites" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Sites</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">createSite</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
createSite(input: {
name: "Main Office"
location: "New York, NY"
snmpCommunity: "public"
}) {
id
name
location
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">updateSite / deleteSite</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">Same pattern as device mutations.</p>
</section>
<!-- Mutations: Alerts -->
<section id="mutation-alerts" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Alert Actions</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">acknowledgeAlert</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
acknowledgeAlert(id: "alert-uuid") {
id
acknowledgedAt
acknowledgedByEmail
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">resolveAlert</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
resolveAlert(id: "alert-uuid") {
id
resolvedAt
}
}</code></pre>
</div>
</section>
<!-- Mutations: Agents -->
<section id="mutation-agents" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Agent Management</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">createAgent</h3>
<p class="mt-2 text-gray-600 dark:text-gray-400">Creates a new agent token. The raw token is only returned once.</p>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
createAgent(name: "DC1 Agent") {
id
name
token
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">deleteAgent</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation { deleteAgent(id: "agent-uuid") { success message } }</code></pre>
</div>
</section>
<!-- Mutations: Organization -->
<section id="mutation-organization" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Organization Settings</h2>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
updateOrganization(input: {
name: "My Network"
useSites: true
snmpVersion: "2c"
snmpCommunity: "public"
mikrotikEnabled: true
mikrotikUsername: "admin"
mikrotikPort: 8729
mikrotikUseSsl: true
}) {
id
name
useSites
snmpVersion
mikrotikEnabled
}
}</code></pre>
</div>
</section>
<!-- Mutations: Members -->
<section id="mutation-members" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Members</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">sendInvitation</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
sendInvitation(email: "user@example.com", role: "admin") {
id
email
role
expiresAt
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">updateMemberRole</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
updateMemberRole(id: "user-uuid", role: "admin") {
id
email
role
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">removeMember / cancelInvitation</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation { removeMember(id: "user-uuid") { success message } }
mutation { cancelInvitation(id: "invitation-uuid") { success message } }</code></pre>
</div>
</section>
<!-- Mutations: Integrations -->
<section id="mutation-integrations" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Mutation: Integrations</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">createIntegration</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
createIntegration(input: {
provider: "preseem"
enabled: true
credentials: "{\"api_key\": \"xxx\"}"
syncIntervalMinutes: 10
}) {
id
provider
enabled
}
}</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">testIntegration</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>mutation {
testIntegration(id: "integration-uuid") {
success
message
}
}</code></pre>
</div>
</section>
<!-- Types Reference -->
<section id="types" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Types Reference</h2>
<p class="mt-4 text-gray-600 dark:text-gray-400">
All field names use camelCase in GraphQL queries (Absinthe automatically converts from Elixir's snake_case).
</p>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">Device</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
id, name, ipAddress, description, displayOrder, status, lastCheckedAt, lastStatusChangeAt,
monitoringEnabled, checkIntervalSeconds, snmpEnabled, snmpVersion, snmpCommunity, snmpCommunitySource,
snmpPort, snmpTransport, snmpTransportSource, lastDiscoveryAt, lastSnmpPollAt,
snmpv3SecurityLevel, snmpv3Username, snmpv3AuthProtocol, snmpv3PrivProtocol, snmpv3CredentialSource,
mikrotikUsername, mikrotikPort, mikrotikSshPort, mikrotikUseSsl, mikrotikEnabled, mikrotikCredentialSource,
deviceRole, deviceRoleSource, siteId, organizationId, insertedAt, updatedAt, site
</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Site</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
id, name, description, location, displayOrder, snmpVersion, snmpCommunity, snmpPort, snmpTransport,
snmpv3SecurityLevel, snmpv3Username, snmpv3AuthProtocol, snmpv3PrivProtocol,
mikrotikUsername, mikrotikPort, mikrotikSshPort, mikrotikUseSsl, mikrotikEnabled,
organizationId, agentTokenId, parentSiteId, insertedAt, updatedAt
</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Alert</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
id, alertType, message, triggeredAt, acknowledgedAt, resolvedAt, emailSentAt,
deviceId, acknowledgedById, gaiiaImpact, insertedAt, device, acknowledgedByEmail
</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Agent</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
id, name, enabled, isCloudPoller, allowRemoteDebug, lastSeenAt, lastIp, metadata,
organizationId, insertedAt, updatedAt, deviceCount
</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Organization</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
id, name, slug, subscriptionPlan, useSites, snmpVersion, snmpCommunity, snmpPort, snmpTransport,
snmpv3SecurityLevel, snmpv3Username, snmpv3AuthProtocol, snmpv3PrivProtocol,
mikrotikEnabled, mikrotikUsername, mikrotikPort, mikrotikSshPort, mikrotikUseSsl,
defaultAgentTokenId, insertedAt, updatedAt
</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Member</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">id, email, role, isDefault, userId, insertedAt</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Integration</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
id, provider, enabled, syncIntervalMinutes, lastSyncedAt, lastSyncStatus, organizationId, insertedAt, updatedAt
</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">ActivityItem</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">summary, detail, timestamp, severity, icon, deviceName, siteName, link, type</p>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">Utility Types</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<strong>DeleteResult:</strong> success (Boolean!), message (String)<br/>
<strong>TestResult:</strong> success (Boolean!), message (String)<br/>
<strong>MetricPoint:</strong> timestamp, value, status, checkName, checkType<br/>
<strong>Interface:</strong> id, ifIndex, ifName, ifDescr, ifType, ifSpeed, ifAdminStatus, ifOperStatus, ifAlias<br/>
<strong>Invitation:</strong> id, email, role, expiresAt, acceptedAt, insertedAt<br/>
<strong>AgentWithToken:</strong> id, name, enabled, token, insertedAt
</p>
</section>
<!-- curl Examples -->
<section id="examples" class="mb-16">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">curl Examples</h2>
<h3 class="mt-6 text-lg font-semibold text-zinc-900 dark:text-white">List devices with site info</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>curl -X POST https://app.towerops.net/api/graphql \
-H "Authorization: Bearer <%= @sample_token %>" \
-H "Content-Type: application/json" \
-d '{"query": "{ devices { id name ipAddress status site { id name location } } }"}'</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">Get active alerts</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>curl -X POST https://app.towerops.net/api/graphql \
-H "Authorization: Bearer <%= @sample_token %>" \
-H "Content-Type: application/json" \
-d '{"query": "{ alerts(status: \"active\") { id alertType message triggeredAt device { name ipAddress } } }"}'</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">Create a device</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>curl -X POST https://app.towerops.net/api/graphql \
-H "Authorization: Bearer <%= @sample_token %>" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { createDevice(input: { ipAddress: \"192.168.1.1\", name: \"Core Router\", snmpEnabled: true }) { id name status } }"}'</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">Acknowledge an alert</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>curl -X POST https://app.towerops.net/api/graphql \
-H "Authorization: Bearer <%= @sample_token %>" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { acknowledgeAlert(id: \"alert-uuid\") { id acknowledgedAt } }"}'</code></pre>
</div>
<h3 class="mt-8 text-lg font-semibold text-zinc-900 dark:text-white">Using variables</h3>
<div class="mt-4 rounded-lg bg-zinc-900 p-4 dark:bg-zinc-800">
<pre class="text-sm text-green-400 overflow-x-auto"><code>curl -X POST https://app.towerops.net/api/graphql \
-H "Authorization: Bearer <%= @sample_token %>" \
-H "Content-Type: application/json" \
-d '{
"query": "query GetDevice($id: ID!) { device(id: $id) { id name ipAddress status site { name } } }",
"variables": {"id": "device-uuid"}
}'</code></pre>
</div>
</section>
</div>
</div>

View file

@ -0,0 +1,31 @@
defmodule ToweropsWeb.GraphQL.Context do
@moduledoc """
Plug that builds the Absinthe context from the authenticated API connection.
Reads the organization_id and user set by the ApiAuth plug and passes them
into the Absinthe context for use by resolvers.
"""
@behaviour Plug
def init(opts), do: opts
def call(conn, _opts) do
context = build_context(conn)
Absinthe.Plug.put_options(conn, context: context)
end
defp build_context(conn) do
context = %{}
context =
case conn.assigns[:current_organization_id] do
nil -> context
org_id -> Map.put(context, :organization_id, org_id)
end
case conn.assigns[:current_user] do
nil -> context
user -> Map.put(context, :user, user)
end
end
end

View file

@ -0,0 +1,31 @@
defmodule ToweropsWeb.GraphQL.Resolvers.Activity do
@moduledoc "GraphQL resolvers for activity feed."
alias Towerops.ActivityFeed
def list(_parent, args, %{context: %{organization_id: org_id}}) do
opts =
[limit: Map.get(args, :limit, 50)]
|> maybe_add_type(args[:type])
items = ActivityFeed.list_org_activity(org_id, opts)
{:ok, items}
end
def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
defp maybe_add_type(opts, nil), do: opts
defp maybe_add_type(opts, type) do
case safe_to_atom(type) do
nil -> opts
atom -> Keyword.put(opts, :types, [atom])
end
end
defp safe_to_atom(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> nil
end
end

View file

@ -0,0 +1,71 @@
defmodule ToweropsWeb.GraphQL.Resolvers.Agent do
@moduledoc "GraphQL resolvers for agent queries and mutations."
alias Towerops.Agents
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
agents = Agents.list_organization_agent_tokens(org_id)
{:ok, agents}
end
def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
agent = Agents.get_agent_token!(id)
if agent.organization_id == org_id do
{:ok, agent}
else
{:error, "Agent not found"}
end
rescue
Ecto.NoResultsError -> {:error, "Agent not found"}
end
def get(_parent, _args, _resolution), do: {:error, "Authentication required"}
def create(_parent, %{name: name}, %{context: %{organization_id: org_id}}) do
case Agents.create_agent_token(org_id, name) do
{:ok, agent_token, raw_token} ->
{:ok, %{
id: agent_token.id,
name: agent_token.name,
enabled: agent_token.enabled,
token: raw_token,
inserted_at: agent_token.inserted_at
}}
{:error, changeset} ->
{:error, format_errors(changeset)}
end
end
def create(_parent, _args, _resolution), do: {:error, "Authentication required"}
def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
agent = Agents.get_agent_token!(id)
if agent.organization_id == org_id do
case Agents.delete_agent_token(id) do
{:ok, _} -> {:ok, %{success: true, message: "Agent deleted"}}
{:error, _} -> {:ok, %{success: false, message: "Could not delete agent"}}
end
else
{:error, "Agent not found"}
end
rescue
Ecto.NoResultsError -> {:error, "Agent not found"}
end
def delete(_parent, _args, _resolution), do: {:error, "Authentication required"}
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
|> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|> Enum.join("; ")
end
end

View file

@ -0,0 +1,77 @@
defmodule ToweropsWeb.GraphQL.Resolvers.Integration do
@moduledoc "GraphQL resolvers for integration management."
alias Towerops.Integrations
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
integrations = Integrations.list_integrations(org_id)
{:ok, integrations}
end
def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
attrs = Map.new(input, fn {k, v} -> {k, v} end)
case Integrations.create_integration(org_id, attrs) do
{:ok, integration} -> {:ok, integration}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end
def create(_parent, _args, _resolution), do: {:error, "Authentication required"}
def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
case Integrations.get_integration_by_id(id) do
{:ok, integration} when integration.organization_id == org_id ->
attrs = Map.new(input, fn {k, v} -> {k, v} end)
case Integrations.update_integration(integration, attrs) do
{:ok, updated} -> {:ok, updated}
{:error, changeset} -> {:error, format_errors(changeset)}
end
_ ->
{:error, "Integration not found"}
end
end
def update(_parent, _args, _resolution), do: {:error, "Authentication required"}
def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
case Integrations.get_integration_by_id(id) do
{:ok, integration} when integration.organization_id == org_id ->
case Integrations.delete_integration(integration) do
{:ok, _} -> {:ok, %{success: true, message: "Integration deleted"}}
{:error, _} -> {:ok, %{success: false, message: "Could not delete integration"}}
end
_ ->
{:error, "Integration not found"}
end
end
def delete(_parent, _args, _resolution), do: {:error, "Authentication required"}
def test_connection(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
case Integrations.get_integration_by_id(id) do
{:ok, integration} when integration.organization_id == org_id ->
{:ok, %{success: true, message: "Connection OK (#{integration.provider})"}}
_ ->
{:error, "Integration not found"}
end
end
def test_connection(_parent, _args, _resolution), do: {:error, "Authentication required"}
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
|> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|> Enum.join("; ")
end
end

View file

@ -0,0 +1,88 @@
defmodule ToweropsWeb.GraphQL.Resolvers.Member do
@moduledoc "GraphQL resolvers for member and invitation management."
alias Towerops.Organizations
alias Towerops.Organizations.Invitation
alias Towerops.Repo
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
members = Organizations.list_organization_members(org_id)
{:ok, members}
end
def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
role = Map.get(args, :role, "member")
case Organizations.create_invitation(%{
email: email,
role: role,
organization_id: org_id,
invited_by_id: user.id
}) do
{:ok, invitation} -> {:ok, invitation}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end
def invite(_parent, _args, _resolution), do: {:error, "Authentication required"}
def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
case Repo.get(Invitation, id) do
nil ->
{:error, "Invitation not found"}
invitation ->
if invitation.organization_id == org_id do
case Repo.delete(invitation) do
{:ok, _} -> {:ok, %{success: true, message: "Invitation cancelled"}}
{:error, _} -> {:ok, %{success: false, message: "Could not cancel invitation"}}
end
else
{:error, "Invitation not found"}
end
end
end
def cancel_invitation(_parent, _args, _resolution), do: {:error, "Authentication required"}
def remove(_parent, %{id: user_id}, %{context: %{organization_id: org_id}}) do
case Organizations.remove_member(org_id, user_id) do
{:ok, _} -> {:ok, %{success: true, message: "Member removed"}}
{:error, :cannot_remove_owner} -> {:error, "Cannot remove owner"}
{:error, :not_found} -> {:error, "Member not found"}
end
end
def remove(_parent, _args, _resolution), do: {:error, "Authentication required"}
def update_role(_parent, %{id: user_id, role: role}, %{context: %{organization_id: org_id}}) do
case Organizations.update_member_role(org_id, user_id, role) do
{:ok, membership} ->
membership = Repo.preload(membership, :user)
{:ok, membership}
{:error, :cannot_change_owner_role} ->
{:error, "Cannot change owner role"}
{:error, :not_found} ->
{:error, "Member not found"}
{:error, changeset} ->
{:error, format_errors(changeset)}
end
end
def update_role(_parent, _args, _resolution), do: {:error, "Authentication required"}
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
|> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|> Enum.join("; ")
end
end

View file

@ -0,0 +1,34 @@
defmodule ToweropsWeb.GraphQL.Resolvers.Organization do
@moduledoc "GraphQL resolvers for organization queries and mutations."
alias Towerops.Organizations
def get(_parent, _args, %{context: %{organization_id: org_id}}) do
org = Organizations.get_organization!(org_id)
{:ok, org}
end
def get(_parent, _args, _resolution), do: {:error, "Authentication required"}
def update(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
org = Organizations.get_organization!(org_id)
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
case Organizations.update_organization(org, attrs) do
{:ok, updated} -> {:ok, updated}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end
def update(_parent, _args, _resolution), do: {:error, "Authentication required"}
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
|> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|> Enum.join("; ")
end
end

View file

@ -63,6 +63,10 @@ defmodule ToweropsWeb.Router do
plug RateLimit, type: :api
end
pipeline :graphql_context do
plug ToweropsWeb.GraphQL.Context
end
# Health check endpoint for Kubernetes probes (no authentication required)
scope "/", ToweropsWeb do
get "/health", HealthController, :index
@ -76,6 +80,7 @@ defmodule ToweropsWeb.Router do
get "/privacy", PageController, :privacy
get "/terms", PageController, :terms
get "/docs/api", ApiDocsController, :index
get "/docs/graphql", GraphQLDocsController, :index
get "/invitations/:token", InvitationController, :show
live "/help", HelpLive.Index, :index
end
@ -116,6 +121,13 @@ defmodule ToweropsWeb.Router do
get "/devices/:id", MobileController, :get_equipment
end
# GraphQL API endpoint (requires API token authentication)
scope "/api/graphql" do
pipe_through [:api_v1, :rate_limit_api, :graphql_context]
forward "/", Absinthe.Plug, schema: ToweropsWeb.GraphQL.Schema
end
# API v1 routes (requires API token authentication)
# Rate limited to prevent API abuse
scope "/api/v1", V1 do