more tests
This commit is contained in:
parent
99414459d3
commit
c41b479ed8
13 changed files with 1004 additions and 237 deletions
|
|
@ -169,7 +169,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
<!-- Desktop navigation -->
|
||||
<div :if={@current_organization} class="hidden md:ml-8 md:flex md:space-x-6">
|
||||
<.nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}"}
|
||||
navigate={~p"/"}
|
||||
active={@active_page == "dashboard"}
|
||||
>
|
||||
Dashboard
|
||||
|
|
@ -196,13 +196,13 @@ defmodule ToweropsWeb.Layouts do
|
|||
</span>
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
navigate={~p"/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
>
|
||||
Alerts
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents"}
|
||||
navigate={~p"/agents"}
|
||||
active={@active_page == "agents"}
|
||||
>
|
||||
Agents
|
||||
|
|
@ -296,7 +296,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
<div :if={@current_organization} class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="space-y-1 px-2 pb-3 pt-2 border-t border-gray-200 dark:border-white/10">
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}"}
|
||||
navigate={~p"/"}
|
||||
active={@active_page == "dashboard"}
|
||||
>
|
||||
<.icon name="hero-home" class="size-5" /> Dashboard
|
||||
|
|
@ -314,13 +314,13 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.icon name="hero-server" class="size-5" /> Devices
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
navigate={~p"/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
>
|
||||
<.icon name="hero-bell" class="size-5" /> Alerts
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents"}
|
||||
navigate={~p"/agents"}
|
||||
active={@active_page == "agents"}
|
||||
>
|
||||
<.icon name="hero-cpu-chip" class="size-5" /> Agents
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
<li>
|
||||
<a
|
||||
href="#introduction"
|
||||
class="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"
|
||||
data-nav-link="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>
|
||||
|
|
@ -26,7 +27,8 @@
|
|||
<li>
|
||||
<a
|
||||
href="#authentication"
|
||||
class="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"
|
||||
data-nav-link="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>
|
||||
|
|
@ -34,7 +36,8 @@
|
|||
<li>
|
||||
<a
|
||||
href="#errors"
|
||||
class="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"
|
||||
data-nav-link="errors"
|
||||
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"
|
||||
>
|
||||
Errors
|
||||
</a>
|
||||
|
|
@ -49,7 +52,8 @@
|
|||
<li>
|
||||
<a
|
||||
href="#sites"
|
||||
class="block py-1 pl-4 pr-3 text-sm text-emerald-500 font-medium border-l-2 border-emerald-500 -ml-px"
|
||||
data-nav-link="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>
|
||||
|
|
@ -57,7 +61,8 @@
|
|||
<li>
|
||||
<a
|
||||
href="#devices"
|
||||
class="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"
|
||||
data-nav-link="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>
|
||||
|
|
@ -1115,3 +1120,57 @@ curl -X DELETE https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-44665
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update active navigation link based on scroll position
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const sections = document.querySelectorAll('section[id]');
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
|
||||
const activeClasses = ['text-emerald-500', 'dark:text-emerald-400', 'font-medium', 'border-l-2', 'border-emerald-500', '-ml-px'];
|
||||
const inactiveClasses = ['text-gray-600', 'dark:text-gray-400'];
|
||||
|
||||
function setActiveLink(id) {
|
||||
navLinks.forEach(link => {
|
||||
const linkId = link.getAttribute('data-nav-link');
|
||||
if (linkId === id) {
|
||||
// Remove inactive classes
|
||||
inactiveClasses.forEach(cls => link.classList.remove(cls));
|
||||
// Add active classes
|
||||
activeClasses.forEach(cls => link.classList.add(cls));
|
||||
} else {
|
||||
// Remove active classes
|
||||
activeClasses.forEach(cls => link.classList.remove(cls));
|
||||
// Add inactive classes
|
||||
inactiveClasses.forEach(cls => link.classList.add(cls));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Use IntersectionObserver to track visible sections
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveLink(entry.target.id);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '-20% 0px -70% 0px', // Trigger when section is near top
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
sections.forEach(section => observer.observe(section));
|
||||
|
||||
// Also update on hash change (when clicking nav links)
|
||||
window.addEventListener('hashchange', function() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
setActiveLink(hash);
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial active state
|
||||
const initialHash = window.location.hash.slice(1) || 'introduction';
|
||||
setActiveLink(initialHash);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
|||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Agent updated successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.current_organization.slug}/agents/#{agent_token.id}")}
|
||||
|> push_navigate(to: ~p"/agents/#{agent_token.id}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents/#{@agent_token.id}"}
|
||||
navigate={~p"/agents/#{@agent_token.id}"}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<span class="hero-arrow-left h-4 w-4 inline"></span> Back to agent
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-4">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents/#{@agent_token.id}"}
|
||||
navigate={~p"/agents/#{@agent_token.id}"}
|
||||
class="text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
<:col :let={agent} label="Name">
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents/#{agent.id}"}
|
||||
navigate={~p"/agents/#{agent.id}"}
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{agent.name}
|
||||
|
|
@ -180,7 +180,7 @@
|
|||
<:col :let={agent} label="Name">
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents/#{agent.id}"}
|
||||
navigate={~p"/agents/#{agent.id}"}
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{agent.name}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@
|
|||
<:subtitle>Agent Details and Health</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents/#{@agent_token.id}/edit"}
|
||||
navigate={~p"/agents/#{@agent_token.id}/edit"}
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
<.icon name="hero-pencil" class="h-5 w-5" /> Edit Agent
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/agents"}
|
||||
navigate={~p"/agents"}
|
||||
class="text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" /> Back to Agents
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<div class="mb-6">
|
||||
<div class="inline-flex rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<.link
|
||||
patch={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
patch={~p"/alerts"}
|
||||
class={[
|
||||
"px-4 py-2 text-sm font-medium rounded-l-lg",
|
||||
@filter == "active" &&
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
Active Alerts
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/orgs/#{@current_organization.slug}/alerts?filter=all"}
|
||||
patch={~p"/alerts?filter=all"}
|
||||
class={[
|
||||
"px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10",
|
||||
@filter == "all" &&
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
</.link>
|
||||
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
navigate={~p"/alerts"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Alerts</h3>
|
||||
|
|
@ -138,7 +138,7 @@
|
|||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Active Alerts</h2>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
navigate={~p"/alerts"}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
View All Alerts →
|
||||
|
|
@ -198,7 +198,7 @@
|
|||
<span>Manage Device</span>
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
navigate={~p"/alerts"}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:border-white/10 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<.icon name="hero-bell" class="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@
|
|||
This IP address (private network or CGNAT range) cannot be reached from the cloud.
|
||||
To monitor this device, assign it to a
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@organization.slug}/agents"}
|
||||
navigate={~p"/agents"}
|
||||
class="font-medium underline"
|
||||
>
|
||||
remote agent
|
||||
|
|
|
|||
|
|
@ -24,8 +24,27 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, assign(socket, :live_action, :index)}
|
||||
def handle_params(params, _url, socket) do
|
||||
tab = Map.get(params, "tab", "existing")
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params, tab)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params, tab) do
|
||||
organization = socket.assigns.current_organization
|
||||
|
||||
case tab do
|
||||
"discovered" ->
|
||||
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
|
||||
|
||||
socket
|
||||
|> assign(:active_tab, "discovered")
|
||||
|> assign(:discovered_devices, discovered)
|
||||
|
||||
_ ->
|
||||
socket
|
||||
|> assign(:active_tab, "existing")
|
||||
|> assign(:discovered_devices, [])
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -160,4 +179,15 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
unknown: Map.get(status_counts, :unknown, 0)
|
||||
}
|
||||
end
|
||||
|
||||
defp device_type_icon(:router), do: "hero-signal"
|
||||
defp device_type_icon(:switch), do: "hero-squares-2x2"
|
||||
defp device_type_icon(:wireless), do: "hero-wifi"
|
||||
defp device_type_icon(:server), do: "hero-server"
|
||||
defp device_type_icon(:workstation), do: "hero-computer-desktop"
|
||||
defp device_type_icon(_), do: "hero-question-mark-circle"
|
||||
|
||||
defp device_type_label(type) do
|
||||
type |> to_string() |> String.capitalize()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -50,217 +50,383 @@
|
|||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
<%= if !@has_sites do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon
|
||||
name="hero-building-office"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Create a site first
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Before adding device, you need to create at least one site location.
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Sites help you organize your devices by physical location.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/sites/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @grouped_devices == [] do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No devices</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Get started by adding your first device.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/devices/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> New Device
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="relative min-w-full" phx-hook="DeviceListReorder" id="device-list">
|
||||
<thead class="bg-white dark:bg-gray-900">
|
||||
<tr>
|
||||
<th
|
||||
:if={@reorder_mode}
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-2 w-8 sm:pl-3"
|
||||
>
|
||||
<span class="sr-only">Drag handle</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class={[
|
||||
"py-3.5 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:text-white",
|
||||
@reorder_mode && "pl-2",
|
||||
!@reorder_mode && "pl-4"
|
||||
]}
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
IP Address
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
Last Checked
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900">
|
||||
<%= for {{site, devices, stats}, index} <- Enum.with_index(@grouped_devices) do %>
|
||||
<tr
|
||||
class={[
|
||||
"border-t site-header",
|
||||
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
|
||||
"dark:border-white/10"
|
||||
]}
|
||||
data-site-id={site.id}
|
||||
data-site-position={index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
>
|
||||
<td
|
||||
:if={@reorder_mode}
|
||||
class="py-2 pl-4 pr-2 sm:pl-3 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Drag to reorder site"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
<th
|
||||
scope="colgroup"
|
||||
colspan="4"
|
||||
class={[
|
||||
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
|
||||
!@reorder_mode && "pl-4 sm:pl-3"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/sites/#{site.id}"}
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{site.name}
|
||||
</.link>
|
||||
<span class="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{stats.total} {if stats.total == 1, do: "device", else: "devices"}
|
||||
</span>
|
||||
<span
|
||||
:if={stats.up > 0}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
>
|
||||
{stats.up} up
|
||||
</span>
|
||||
<span
|
||||
:if={stats.down > 0}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
>
|
||||
{stats.down} down
|
||||
</span>
|
||||
</div>
|
||||
<p :if={site.location} class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<.icon name="hero-map-pin" class="inline h-3 w-3" /> {site.location}
|
||||
</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<%= for {device, device_index} <- Enum.with_index(devices) do %>
|
||||
<tr
|
||||
class={[
|
||||
"border-t device-row",
|
||||
if(device_index == 0,
|
||||
do: "border-gray-300 dark:border-white/15",
|
||||
else: "border-gray-200 dark:border-white/10"
|
||||
)
|
||||
]}
|
||||
data-device-id={device.id}
|
||||
data-site-id={site.id}
|
||||
data-device-position={device_index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
>
|
||||
<td :if={@reorder_mode} class="py-4 pl-4 pr-2 sm:pl-3">
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Drag to reorder device"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class={[
|
||||
"py-4 pr-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50",
|
||||
@reorder_mode && "pl-2",
|
||||
!@reorder_mode && "pl-4 sm:pl-3"
|
||||
]}
|
||||
>
|
||||
{device.name}
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
{device.ip_address}
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class="px-3 py-4 text-sm whitespace-nowrap cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
device.status == :up &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
device.status == :down &&
|
||||
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
device.status == :unknown &&
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
]}>
|
||||
{device.status |> to_string() |> String.upcase()}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<.timestamp datetime={device.last_checked_at} timezone={@timezone} />
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-white/10 mb-6">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<.link
|
||||
patch={~p"/devices?tab=existing"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "existing" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
Existing Devices
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
patch={~p"/devices?tab=discovered"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "discovered" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
Discovered Devices
|
||||
<%= if @discovered_devices != [] do %>
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{length(@discovered_devices)}
|
||||
</span>
|
||||
<% end %>
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<%= case @active_tab do %>
|
||||
<% "existing" -> %>
|
||||
<%= if !@has_sites do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon
|
||||
name="hero-building-office"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Create a site first
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Before adding device, you need to create at least one site location.
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Sites help you organize your devices by physical location.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/sites/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if @grouped_devices == [] do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No devices</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Get started by adding your first device.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/devices/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> New Device
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="relative min-w-full" phx-hook="DeviceListReorder" id="device-list">
|
||||
<thead class="bg-white dark:bg-gray-900">
|
||||
<tr>
|
||||
<th
|
||||
:if={@reorder_mode}
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-2 w-8 sm:pl-3"
|
||||
>
|
||||
<span class="sr-only">Drag handle</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class={[
|
||||
"py-3.5 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:text-white",
|
||||
@reorder_mode && "pl-2",
|
||||
!@reorder_mode && "pl-4"
|
||||
]}
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
IP Address
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
Last Checked
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900">
|
||||
<%= for {{site, devices, stats}, index} <- Enum.with_index(@grouped_devices) do %>
|
||||
<tr
|
||||
class={[
|
||||
"border-t site-header",
|
||||
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
|
||||
"dark:border-white/10"
|
||||
]}
|
||||
data-site-id={site.id}
|
||||
data-site-position={index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
>
|
||||
<td
|
||||
:if={@reorder_mode}
|
||||
class="py-2 pl-4 pr-2 sm:pl-3 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Drag to reorder site"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
<th
|
||||
scope="colgroup"
|
||||
colspan="4"
|
||||
class={[
|
||||
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
|
||||
!@reorder_mode && "pl-4 sm:pl-3"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/sites/#{site.id}"}
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{site.name}
|
||||
</.link>
|
||||
<span class="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{stats.total} {if stats.total == 1, do: "device", else: "devices"}
|
||||
</span>
|
||||
<span
|
||||
:if={stats.up > 0}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
>
|
||||
{stats.up} up
|
||||
</span>
|
||||
<span
|
||||
:if={stats.down > 0}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
>
|
||||
{stats.down} down
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
:if={site.location}
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<.icon name="hero-map-pin" class="inline h-3 w-3" /> {site.location}
|
||||
</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<%= for {device, device_index} <- Enum.with_index(devices) do %>
|
||||
<tr
|
||||
class={[
|
||||
"border-t device-row",
|
||||
if(device_index == 0,
|
||||
do: "border-gray-300 dark:border-white/15",
|
||||
else: "border-gray-200 dark:border-white/10"
|
||||
)
|
||||
]}
|
||||
data-device-id={device.id}
|
||||
data-site-id={site.id}
|
||||
data-device-position={device_index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
>
|
||||
<td :if={@reorder_mode} class="py-4 pl-4 pr-2 sm:pl-3">
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Drag to reorder device"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class={[
|
||||
"py-4 pr-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50",
|
||||
@reorder_mode && "pl-2",
|
||||
!@reorder_mode && "pl-4 sm:pl-3"
|
||||
]}
|
||||
>
|
||||
{device.name}
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
{device.ip_address}
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class="px-3 py-4 text-sm whitespace-nowrap cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
device.status == :up &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
device.status == :down &&
|
||||
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
device.status == :unknown &&
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
]}>
|
||||
{device.status |> to_string() |> String.upcase()}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
phx-click={JS.navigate(~p"/devices/#{device.id}")}
|
||||
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<.timestamp datetime={device.last_checked_at} timezone={@timezone} />
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% "discovered" -> %>
|
||||
<%= if @discovered_devices == [] do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon
|
||||
name="hero-magnifying-glass"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
No discovered devices
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Devices will appear here as they are discovered via LLDP/CDP.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<.table
|
||||
id="discovered-devices"
|
||||
rows={@discovered_devices}
|
||||
>
|
||||
<:col :let={discovered} label="Identifier">
|
||||
<%= case discovered.identifier.type do %>
|
||||
<% :mac -> %>
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
MAC
|
||||
</span>
|
||||
<span class="ml-2 font-mono text-sm text-gray-900 dark:text-white">
|
||||
{discovered.identifier.value}
|
||||
</span>
|
||||
</div>
|
||||
<% :ip -> %>
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
IP
|
||||
</span>
|
||||
<span class="ml-2 font-mono text-sm text-gray-900 dark:text-white">
|
||||
{discovered.identifier.value}
|
||||
</span>
|
||||
</div>
|
||||
<% :hostname -> %>
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
HOST
|
||||
</span>
|
||||
<span class="ml-2 text-sm text-gray-900 dark:text-white">
|
||||
{discovered.identifier.value}
|
||||
</span>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
<span class="text-sm text-gray-500">Unknown</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label="Hostname">
|
||||
<%= if discovered.hostname do %>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{discovered.hostname}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-sm text-gray-400">-</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label="Type">
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon
|
||||
name={device_type_icon(discovered.device_type)}
|
||||
class="h-4 w-4 text-gray-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
{device_type_label(discovered.device_type)}
|
||||
</span>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label="Manufacturer">
|
||||
<%= if discovered.manufacturer do %>
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
{discovered.manufacturer}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-sm text-gray-400">-</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label="Discovered By">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= if length(discovered.discovered_by) == 1 do %>
|
||||
<% [first] = discovered.discovered_by %>
|
||||
<.link
|
||||
navigate={~p"/devices/#{first.device_id}"}
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{first.device_name}
|
||||
</.link>
|
||||
<span class="text-xs text-gray-500"> ({first.interface})</span>
|
||||
<% else %>
|
||||
<span>{length(discovered.discovered_by)} devices</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label="Last Seen">
|
||||
<.timestamp
|
||||
datetime={discovered.last_seen}
|
||||
timezone={@timezone}
|
||||
class="text-sm text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label="Actions">
|
||||
<.button
|
||||
phx-click="add_discovered_device"
|
||||
phx-value-identifier={Jason.encode!(discovered.identifier)}
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3" /> Add Device
|
||||
</.button>
|
||||
</:col>
|
||||
</.table>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@
|
|||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
No agents configured yet.
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@organization.slug}/agents"}
|
||||
navigate={~p"/agents"}
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Create an agent
|
||||
|
|
|
|||
512
test/snmpkit/snmp_mgr_integration_test.exs
Normal file
512
test/snmpkit/snmp_mgr_integration_test.exs
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
defmodule SnmpKit.SnmpMgrIntegrationTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias SnmpKit.SnmpMgr
|
||||
|
||||
@moduletag :integration
|
||||
@moduletag :snmpkit
|
||||
|
||||
describe "SnmpMgr get/3" do
|
||||
test "successfully retrieves sysUpTime from localhost simulator" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.3.0"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:ok, value} ->
|
||||
assert is_tuple(value)
|
||||
{type, _data} = value
|
||||
assert type == :timeticks
|
||||
|
||||
{:error, :timeout} ->
|
||||
# Simulator not running, skip test
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
flunk("Unexpected error: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
test "handles string OID format" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
result = SnmpMgr.get(target, oid, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, :timeout}, result)
|
||||
end
|
||||
|
||||
test "handles list OID format" do
|
||||
target = "127.0.0.1"
|
||||
oid = [1, 3, 6, 1, 2, 1, 1, 1, 0]
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
result = SnmpMgr.get(target, oid, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, :timeout}, result)
|
||||
end
|
||||
|
||||
test "returns error for invalid OID" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.99.99.99.99"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:error, _reason} -> :ok
|
||||
{:ok, _} -> flunk("Expected error for invalid OID")
|
||||
end
|
||||
end
|
||||
|
||||
test "handles timeout gracefully" do
|
||||
target = "192.0.2.1"
|
||||
# RFC 5737 TEST-NET-1 address that should not route
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
opts = [community: "public", version: :v2c, port: 161, timeout: 1000]
|
||||
|
||||
assert {:error, :timeout} = SnmpMgr.get(target, oid, opts)
|
||||
end
|
||||
|
||||
test "returns error for wrong community string" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
opts = [community: "wrong_community", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
# Simulator may allow any community
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr walk/3" do
|
||||
test "successfully walks interface table" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1.2"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 10_000]
|
||||
|
||||
case SnmpMgr.walk(target, oid, opts) do
|
||||
{:ok, results} ->
|
||||
assert is_list(results)
|
||||
|
||||
case results do
|
||||
[first | _rest] ->
|
||||
# Verify structure of results
|
||||
assert is_map(first)
|
||||
assert Map.has_key?(first, :oid)
|
||||
assert Map.has_key?(first, :value)
|
||||
|
||||
[] ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, :timeout} ->
|
||||
# Simulator not running
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
# Other errors are acceptable (no such object, etc.)
|
||||
assert is_atom(reason)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns empty list for non-existent subtree" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.99.99.99"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.walk(target, oid, opts) do
|
||||
{:ok, results} ->
|
||||
assert is_list(results)
|
||||
assert results == []
|
||||
|
||||
{:error, _reason} ->
|
||||
# Error is also acceptable
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
test "handles walk timeout" do
|
||||
target = "192.0.2.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1"
|
||||
opts = [community: "public", version: :v2c, port: 161, timeout: 1000]
|
||||
|
||||
assert {:error, :timeout} = SnmpMgr.walk(target, oid, opts)
|
||||
end
|
||||
|
||||
test "walk result format is consistent" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.walk(target, oid, opts) do
|
||||
{:ok, results} when is_list(results) ->
|
||||
# All results should be maps with :oid and :value keys
|
||||
for result <- results do
|
||||
assert is_map(result)
|
||||
assert Map.has_key?(result, :oid)
|
||||
assert Map.has_key?(result, :value)
|
||||
assert is_binary(result.oid) or is_list(result.oid)
|
||||
end
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr get_bulk/3" do
|
||||
test "successfully performs get_bulk operation" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1.10"
|
||||
|
||||
opts = [
|
||||
community: "public",
|
||||
version: :v2c,
|
||||
port: 1161,
|
||||
timeout: 5000,
|
||||
max_repetitions: 10
|
||||
]
|
||||
|
||||
case SnmpMgr.get_bulk(target, oid, opts) do
|
||||
{:ok, results} ->
|
||||
assert is_list(results)
|
||||
|
||||
case results do
|
||||
[first | _rest] ->
|
||||
assert is_map(first)
|
||||
assert Map.has_key?(first, :oid)
|
||||
assert Map.has_key?(first, :value)
|
||||
|
||||
[] ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
assert is_atom(reason)
|
||||
end
|
||||
end
|
||||
|
||||
test "respects max_repetitions parameter" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1"
|
||||
|
||||
opts = [
|
||||
community: "public",
|
||||
version: :v2c,
|
||||
port: 1161,
|
||||
timeout: 5000,
|
||||
max_repetitions: 5
|
||||
]
|
||||
|
||||
case SnmpMgr.get_bulk(target, oid, opts) do
|
||||
{:ok, results} ->
|
||||
# Should get at most max_repetitions results per column
|
||||
assert is_list(results)
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
test "get_bulk only works with SNMPv2c" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1.10"
|
||||
|
||||
# Try with v1 - should not support get_bulk
|
||||
opts_v1 = [community: "public", version: :v1, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get_bulk(target, oid, opts_v1) do
|
||||
{:error, _reason} ->
|
||||
# Expected - v1 doesn't support get_bulk
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
# Some implementations may allow it or fall back
|
||||
:ok
|
||||
end
|
||||
|
||||
# Try with v2c - should work
|
||||
opts_v2c = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get_bulk(target, oid, opts_v2c) do
|
||||
{:ok, results} ->
|
||||
assert is_list(results)
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr with different versions" do
|
||||
test "SNMPv1 get operation" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.3.0"
|
||||
opts = [community: "public", version: :v1, port: 1161, timeout: 5000]
|
||||
|
||||
result = SnmpMgr.get(target, oid, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "SNMPv2c get operation" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.3.0"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
result = SnmpMgr.get(target, oid, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr error handling" do
|
||||
test "handles network errors gracefully" do
|
||||
target = "192.0.2.1"
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
opts = [community: "public", version: :v2c, port: 161, timeout: 1000]
|
||||
|
||||
assert {:error, :timeout} = SnmpMgr.get(target, oid, opts)
|
||||
end
|
||||
|
||||
test "handles invalid target format" do
|
||||
target = "not-a-valid-ip"
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
opts = [community: "public", version: :v2c, port: 161, timeout: 1000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
# Some systems may resolve hostnames
|
||||
end
|
||||
end
|
||||
|
||||
test "handles malformed OID" do
|
||||
target = "127.0.0.1"
|
||||
# Invalid OID format
|
||||
oid = "not.an.oid"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:error, _reason} ->
|
||||
# Expected
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
# Some parsers may handle this
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr real-world scenarios" do
|
||||
test "retrieve system information bundle" do
|
||||
target = "127.0.0.1"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
system_oids = [
|
||||
"1.3.6.1.2.1.1.1.0",
|
||||
# sysDescr
|
||||
"1.3.6.1.2.1.1.2.0",
|
||||
# sysObjectID
|
||||
"1.3.6.1.2.1.1.3.0",
|
||||
# sysUpTime
|
||||
"1.3.6.1.2.1.1.4.0",
|
||||
# sysContact
|
||||
"1.3.6.1.2.1.1.5.0",
|
||||
# sysName
|
||||
"1.3.6.1.2.1.1.6.0"
|
||||
# sysLocation
|
||||
]
|
||||
|
||||
# Try to get all system info
|
||||
results =
|
||||
Enum.map(system_oids, fn oid ->
|
||||
SnmpMgr.get(target, oid, opts)
|
||||
end)
|
||||
|
||||
# At least some should succeed (if simulator running) or all timeout
|
||||
assert Enum.all?(results, fn result ->
|
||||
match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end)
|
||||
end
|
||||
|
||||
test "walk and count interfaces" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1.1"
|
||||
# ifIndex
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.walk(target, oid, opts) do
|
||||
{:ok, results} ->
|
||||
interface_count = length(results)
|
||||
assert interface_count >= 0
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
test "retrieve interface statistics" do
|
||||
target = "127.0.0.1"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
# Common interface stats OIDs
|
||||
stats_oids = [
|
||||
"1.3.6.1.2.1.2.2.1.10.1",
|
||||
# ifInOctets for interface 1
|
||||
"1.3.6.1.2.1.2.2.1.16.1",
|
||||
# ifOutOctets for interface 1
|
||||
"1.3.6.1.2.1.2.2.1.8.1"
|
||||
# ifOperStatus for interface 1
|
||||
]
|
||||
|
||||
results =
|
||||
Enum.map(stats_oids, fn oid ->
|
||||
SnmpMgr.get(target, oid, opts)
|
||||
end)
|
||||
|
||||
assert Enum.all?(results, fn result ->
|
||||
match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr performance and reliability" do
|
||||
test "handles multiple concurrent requests" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.3.0"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
tasks =
|
||||
for _ <- 1..5 do
|
||||
Task.async(fn -> SnmpMgr.get(target, oid, opts) end)
|
||||
end
|
||||
|
||||
results = Task.await_many(tasks, 10_000)
|
||||
|
||||
# All requests should complete
|
||||
assert length(results) == 5
|
||||
|
||||
# All should either succeed or have consistent errors
|
||||
assert Enum.all?(results, fn result ->
|
||||
match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end)
|
||||
end
|
||||
|
||||
test "respects timeout parameter" do
|
||||
target = "192.0.2.1"
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
opts = [community: "public", version: :v2c, port: 161, timeout: 500]
|
||||
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
result = SnmpMgr.get(target, oid, opts)
|
||||
end_time = System.monotonic_time(:millisecond)
|
||||
|
||||
assert {:error, :timeout} = result
|
||||
|
||||
# Should timeout around the specified timeout value
|
||||
duration = end_time - start_time
|
||||
# Allow some overhead
|
||||
assert duration < 2000
|
||||
end
|
||||
|
||||
test "handles rapid sequential requests" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.3.0"
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
results =
|
||||
for _ <- 1..10 do
|
||||
SnmpMgr.get(target, oid, opts)
|
||||
end
|
||||
|
||||
# All should complete
|
||||
assert length(results) == 10
|
||||
|
||||
# All should have consistent results
|
||||
assert Enum.all?(results, fn result ->
|
||||
match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpMgr data type handling" do
|
||||
test "correctly handles timeticks values" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.3.0"
|
||||
# sysUpTime
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:ok, value} ->
|
||||
assert is_tuple(value)
|
||||
assert elem(value, 0) == :timeticks
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
test "correctly handles octet string values" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.1.1.0"
|
||||
# sysDescr
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:ok, value} ->
|
||||
assert is_tuple(value)
|
||||
# Could be :octet_string
|
||||
type = elem(value, 0)
|
||||
assert is_atom(type)
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
test "correctly handles integer values" do
|
||||
target = "127.0.0.1"
|
||||
oid = "1.3.6.1.2.1.2.2.1.8.1"
|
||||
# ifOperStatus
|
||||
opts = [community: "public", version: :v2c, port: 1161, timeout: 5000]
|
||||
|
||||
case SnmpMgr.get(target, oid, opts) do
|
||||
{:ok, value} ->
|
||||
assert is_tuple(value) or is_integer(value)
|
||||
|
||||
{:error, :timeout} ->
|
||||
:ok
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue