make sites optional
This commit is contained in:
parent
4ef4f4fbf6
commit
1a054fd598
24 changed files with 1828 additions and 458 deletions
686
docs/plans/make-sites-optional.md
Normal file
686
docs/plans/make-sites-optional.md
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
# Refactor Plan: Make Sites Optional in Towerops
|
||||
|
||||
## Goal
|
||||
Remove the "create site first" wall for new users. Allow devices to belong directly to organizations without requiring site creation, while preserving backward compatibility for existing multi-site users.
|
||||
|
||||
## Architecture Decision
|
||||
**Approach**: True optional sites with nullable `site_id`
|
||||
- Add `organization_id` foreign key directly to devices table
|
||||
- Make `site_id` nullable
|
||||
- Support 2-tier credential cascade (org → device) for site-less devices
|
||||
- Preserve 3-tier cascade (org → site → device) when site is present
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Database Schema
|
||||
```
|
||||
Organization (id)
|
||||
└── Site (organization_id, NOT NULL)
|
||||
└── Device (site_id, NOT NULL) ← Devices MUST have site
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
- Devices have NO direct `organization_id` - only indirect via `site.organization_id`
|
||||
- Authorization checks crash if site is nil: `device.site.organization_id`
|
||||
- Credential resolution crashes: `device.site.snmp_community`
|
||||
|
||||
### Code Impact
|
||||
- **42 functions** access `device.site.*` pattern and will crash if site is nil
|
||||
- **17 credential resolution functions** in `lib/towerops/devices.ex` need refactoring
|
||||
- **5 API controller functions** use `device.site.organization_id` for auth
|
||||
- **3-tier credential cascade** for SNMP v2, SNMP v3, and MikroTik credentials
|
||||
|
||||
## MVP Scope (Minimal Viable Onboarding Fix)
|
||||
|
||||
### In Scope ✅
|
||||
1. Database migrations (add organization_id, make site_id nullable)
|
||||
2. Core credential resolution refactor (CredentialResolver module)
|
||||
3. Device form - remove site requirement, make site optional
|
||||
4. Basic authorization fixes (API controllers, agent channel)
|
||||
5. Core query functions (list_organization_devices, etc.)
|
||||
|
||||
### Out of Scope (Future Enhancements) 🚫
|
||||
- Progressive disclosure UI (`use_sites` toggle in org settings)
|
||||
- Conditional navigation (hiding "Sites" link)
|
||||
- Device grouping UI changes (flat vs grouped view)
|
||||
- Automatic default site creation
|
||||
- Settings page site toggle
|
||||
- Help text and education materials
|
||||
- Mobile app changes
|
||||
|
||||
**Rationale**: Focus on core backend functionality first. UI polish can come in follow-up iterations.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Database Schema Changes
|
||||
|
||||
#### Migration 1: Add organization_id (with backfill)
|
||||
**File**: `priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exs`
|
||||
|
||||
```elixir
|
||||
def up do
|
||||
# 1. Add nullable organization_id column
|
||||
alter table(:devices) do
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all)
|
||||
end
|
||||
|
||||
# 2. Backfill from existing site relationships
|
||||
execute """
|
||||
UPDATE devices d
|
||||
SET organization_id = s.organization_id
|
||||
FROM sites s
|
||||
WHERE d.site_id = s.id
|
||||
"""
|
||||
|
||||
# 3. Make organization_id NOT NULL (data is backfilled)
|
||||
alter table(:devices) do
|
||||
modify :organization_id, :binary_id, null: false
|
||||
end
|
||||
|
||||
# 4. Add indexes
|
||||
create index(:devices, [:organization_id])
|
||||
create index(:devices, [:organization_id, :display_order, :name])
|
||||
|
||||
# 5. Add check constraint (site must belong to same org)
|
||||
create constraint(:devices, :devices_site_organization_match,
|
||||
check: """
|
||||
site_id IS NULL OR
|
||||
organization_id = (SELECT organization_id FROM sites WHERE id = site_id)
|
||||
"""
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:devices, :devices_site_organization_match)
|
||||
drop index(:devices, [:organization_id, :display_order, :name])
|
||||
drop index(:devices, [:organization_id])
|
||||
|
||||
alter table(:devices) do
|
||||
remove :organization_id
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Safety**:
|
||||
- No downtime (backfill happens before NOT NULL)
|
||||
- Rollbackable (can drop organization_id column)
|
||||
- Check constraint prevents invalid data
|
||||
|
||||
#### Migration 2: Make site_id nullable
|
||||
**File**: `priv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs`
|
||||
|
||||
```elixir
|
||||
def up do
|
||||
alter table(:devices) do
|
||||
modify :site_id, :binary_id, null: true, from: {:binary_id, null: false}
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
# Cannot rollback if NULL site_id values exist
|
||||
execute """
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM devices WHERE site_id IS NULL) THEN
|
||||
RAISE EXCEPTION 'Cannot revert: devices with NULL site_id exist';
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
|
||||
alter table(:devices) do
|
||||
modify :site_id, :binary_id, null: false
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Safety**: One-way migration once devices without sites exist
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Core Schema Updates
|
||||
|
||||
#### Update Device Schema
|
||||
**File**: `lib/towerops/devices/device.ex`
|
||||
|
||||
**Changes**:
|
||||
1. Add `belongs_to :organization, Organization` (new field)
|
||||
2. Change `validate_required` to include `:organization_id`, remove `:site_id`
|
||||
3. Add `validate_site_belongs_to_organization/1` function
|
||||
4. Update typespec to show `site_id: UUID.t() | nil`
|
||||
|
||||
**Key validation**:
|
||||
```elixir
|
||||
defp validate_site_belongs_to_organization(changeset) do
|
||||
site_id = get_field(changeset, :site_id)
|
||||
org_id = get_field(changeset, :organization_id)
|
||||
|
||||
if site_id && org_id do
|
||||
site = Repo.get!(Site, site_id)
|
||||
if site.organization_id != org_id do
|
||||
add_error(changeset, :site_id, "must belong to the same organization")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Credential Resolution Refactor
|
||||
|
||||
#### Create CredentialResolver Module
|
||||
**File**: `lib/towerops/devices/credential_resolver.ex` (new file)
|
||||
|
||||
Centralizes safe credential resolution with 2-tier and 3-tier support:
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.Devices.CredentialResolver do
|
||||
@moduledoc """
|
||||
Safe credential resolution with optional sites.
|
||||
|
||||
Supports:
|
||||
- 2-tier: device → organization (when site_id is nil)
|
||||
- 3-tier: device → site → organization (when site_id present)
|
||||
"""
|
||||
|
||||
def ensure_credentials_loaded(%Device{} = device) do
|
||||
cond do
|
||||
!Ecto.assoc_loaded?(device.organization) ->
|
||||
preload_all(device)
|
||||
device.site_id && !Ecto.assoc_loaded?(device.site) ->
|
||||
Repo.preload(device, [:site])
|
||||
true ->
|
||||
device
|
||||
end
|
||||
end
|
||||
|
||||
defp preload_all(device) do
|
||||
if device.site_id do
|
||||
Repo.preload(device, [:organization, :site])
|
||||
else
|
||||
Repo.preload(device, [:organization])
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_field(device, device_field, site_field, org_field, opts \\ []) do
|
||||
default = Keyword.get(opts, :default)
|
||||
device = ensure_credentials_loaded(device)
|
||||
|
||||
device_value = Map.get(device, device_field)
|
||||
|
||||
site_value =
|
||||
if device.site_id && device.site do
|
||||
Map.get(device.site, site_field)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
org_value = Map.get(device.organization, org_field)
|
||||
|
||||
device_value || site_value || org_value || default
|
||||
end
|
||||
|
||||
def determine_source(device, device_field, site_field, org_field) do
|
||||
device = ensure_credentials_loaded(device)
|
||||
|
||||
cond do
|
||||
Map.get(device, device_field) != nil -> :device
|
||||
device.site_id && device.site && Map.get(device.site, site_field) != nil -> :site
|
||||
Map.get(device.organization, org_field) != nil -> :organization
|
||||
true -> :default
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### Update Credential Resolution Functions
|
||||
**File**: `lib/towerops/devices.ex`
|
||||
|
||||
**Refactor these functions** (lines 299-628):
|
||||
- `resolve_snmp_config/1` - SNMP v2c credentials
|
||||
- `resolve_snmpv3_config/1` - SNMP v3 credentials (6 fields)
|
||||
- `resolve_mikrotik_config/1` - MikroTik API credentials (5 fields)
|
||||
|
||||
**Pattern**:
|
||||
```elixir
|
||||
# BEFORE (crashes if site is nil):
|
||||
defp resolve_snmp_community(device) do
|
||||
device.snmp_community ||
|
||||
device.site.snmp_community || # ⚠️ Crashes
|
||||
device.site.organization.snmp_community
|
||||
end
|
||||
|
||||
# AFTER (safe with CredentialResolver):
|
||||
alias Towerops.Devices.CredentialResolver
|
||||
|
||||
defp resolve_snmp_community(device) do
|
||||
CredentialResolver.resolve_field(
|
||||
device,
|
||||
:snmp_community,
|
||||
:snmp_community,
|
||||
:snmp_community
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Query Function Updates
|
||||
|
||||
#### Update Organization Device Queries
|
||||
**File**: `lib/towerops/devices.ex`
|
||||
|
||||
**Functions to update**:
|
||||
1. `list_organization_devices/2` (lines 39-63)
|
||||
2. `count_organization_devices/1` (lines 83-91)
|
||||
3. `list_devices_for_organizations/1` (lines 68-78)
|
||||
4. `list_mikrotik_devices_with_api/0` (lines 141-156)
|
||||
|
||||
**Pattern**:
|
||||
```elixir
|
||||
# BEFORE (requires site join):
|
||||
def list_organization_devices(organization_id, filters) do
|
||||
from(d in Device,
|
||||
join: s in assoc(d, :site), # ⚠️ Inner join excludes site-less devices
|
||||
where: s.organization_id == ^organization_id,
|
||||
preload: [site: s]
|
||||
)
|
||||
end
|
||||
|
||||
# AFTER (direct organization_id):
|
||||
def list_organization_devices(organization_id, filters) do
|
||||
from(d in Device,
|
||||
where: d.organization_id == ^organization_id,
|
||||
order_by: [asc: d.display_order, asc: d.name],
|
||||
preload: [:site, :organization] # Left join - includes site-less devices
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
#### Update Preload Functions
|
||||
Add safe preload helper:
|
||||
|
||||
```elixir
|
||||
def preload_device_with_associations(device) do
|
||||
if device.site_id do
|
||||
Repo.preload(device, [:organization, :site])
|
||||
else
|
||||
Repo.preload(device, [:organization])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Update `get_device/1`, `get_device!/1`, `get_device_with_details/1` to use it.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Authorization Fixes
|
||||
|
||||
#### API Controllers
|
||||
**File**: `lib/towerops_web/controllers/api/v1/devices_controller.ex`
|
||||
|
||||
**Update authorization checks** (lines 147, 190, 236):
|
||||
```elixir
|
||||
# BEFORE:
|
||||
if device.site.organization_id == organization_id do # ⚠️ Crashes
|
||||
|
||||
# AFTER:
|
||||
if device.organization_id == organization_id do # ✅ Safe
|
||||
```
|
||||
|
||||
**Update site verification** (line 260):
|
||||
```elixir
|
||||
# BEFORE:
|
||||
defp verify_site_access(nil, _organization_id),
|
||||
do: {:error, "site_id is required"}
|
||||
|
||||
# AFTER (site is optional):
|
||||
defp verify_site_access(nil, _organization_id),
|
||||
do: :ok
|
||||
```
|
||||
|
||||
#### Agent Channel
|
||||
**File**: `lib/towerops_web/channels/agent_channel.ex`
|
||||
|
||||
**Update device verification** (line 639):
|
||||
```elixir
|
||||
# BEFORE:
|
||||
if is_nil(organization_id) or device.site.organization_id == organization_id do
|
||||
|
||||
# AFTER:
|
||||
if is_nil(organization_id) or device.organization_id == organization_id do
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Device Form Updates
|
||||
|
||||
#### Remove Site Requirement
|
||||
**File**: `lib/towerops_web/live/device_live/form.ex`
|
||||
|
||||
**Remove blocking check** (lines 34-38):
|
||||
```elixir
|
||||
# DELETE THIS:
|
||||
if Enum.empty?(sites) do
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:info, "Please create a site before adding a device.")
|
||||
|> push_navigate(to: ~p"/sites/new")}
|
||||
```
|
||||
|
||||
**Update changeset to auto-inject organization_id**:
|
||||
```elixir
|
||||
def handle_event("save", %{"device" => device_params}, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
# Inject organization_id from current context
|
||||
device_params = Map.put(device_params, "organization_id", organization.id)
|
||||
|
||||
save_device(socket, socket.assigns.live_action, device_params)
|
||||
end
|
||||
```
|
||||
|
||||
#### Make Site Selector Optional in Form
|
||||
**File**: `lib/towerops_web/live/device_live/form.html.heex`
|
||||
|
||||
**Update site input** (lines 168-177):
|
||||
```heex
|
||||
<!-- Site is now optional -->
|
||||
<div class="col-span-full">
|
||||
<.input
|
||||
field={@form[:site_id]}
|
||||
type="select"
|
||||
label="Site (Optional)"
|
||||
prompt="No site (ungrouped)"
|
||||
options={Enum.map(@available_sites, &{&1.name, &1.id})}
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Optionally group this device into a site (office, datacenter, etc.)
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: API Updates
|
||||
|
||||
#### Device Create Endpoint
|
||||
**File**: `lib/towerops_web/controllers/api/v1/devices_controller.ex`
|
||||
|
||||
**Update to auto-inject organization_id**:
|
||||
```elixir
|
||||
def create(conn, %{"device" => device_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
# Inject organization_id from authenticated context
|
||||
device_params = Map.put(device_params, "organization_id", organization_id)
|
||||
|
||||
# site_id is now optional
|
||||
with :ok <- verify_site_access(device_params["site_id"], organization_id),
|
||||
{:ok, device} <- Devices.create_device(device_params) do
|
||||
# ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Agent Assignment Updates
|
||||
|
||||
#### Safe Agent Resolution
|
||||
**File**: `lib/towerops/devices.ex` (lines 167-189)
|
||||
|
||||
**Update agent fallback chain**:
|
||||
```elixir
|
||||
def resolve_agent_token_id(device) do
|
||||
device =
|
||||
if device.site_id do
|
||||
Repo.preload(device, [:organization, :site, agent_assignments: []])
|
||||
else
|
||||
Repo.preload(device, [:organization, agent_assignments: []])
|
||||
end
|
||||
|
||||
get_direct_agent(device) ||
|
||||
get_site_agent(device) ||
|
||||
get_org_default_agent(device) ||
|
||||
get_global_default_agent()
|
||||
end
|
||||
|
||||
# Safe: only access site if site_id present
|
||||
defp get_site_agent(device) do
|
||||
if device.site_id && device.site do
|
||||
device.site.agent_token_id
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Safe: use direct organization relationship
|
||||
defp get_org_default_agent(device) do
|
||||
device.organization.default_agent_token_id
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Propagation Functions
|
||||
|
||||
#### Update Credential Propagation
|
||||
**File**: `lib/towerops/devices.ex`
|
||||
|
||||
**Functions to update**:
|
||||
- `propagate_organization_community_change/2` (lines 905-934)
|
||||
- `propagate_organization_mikrotik_change/2` (lines 490-521)
|
||||
- `propagate_organization_snmpv3_change/2` (lines 662-688)
|
||||
|
||||
**Pattern**:
|
||||
```elixir
|
||||
# BEFORE (inner join excludes site-less devices):
|
||||
from(d in Device,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
...
|
||||
)
|
||||
|
||||
# AFTER (includes site-less devices):
|
||||
from(d in Device,
|
||||
left_join: s in assoc(d, :site),
|
||||
where: d.organization_id == ^organization_id,
|
||||
where: (is_nil(d.site_id)) or (is_nil(s.snmp_community) or s.snmp_community == ""),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: Device Quota Validation
|
||||
|
||||
#### Update Quota Checks
|
||||
**File**: `lib/towerops/devices.ex` (lines 727-756)
|
||||
|
||||
**Change to use organization_id**:
|
||||
```elixir
|
||||
# BEFORE (derives org from site):
|
||||
defp do_check_quota(changeset) do
|
||||
case Ecto.Changeset.fetch_change(changeset, :site_id) do
|
||||
{:ok, site_id} ->
|
||||
validate_device_quota(changeset, site_id)
|
||||
:error ->
|
||||
{:ok, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_device_quota(changeset, site_id) do
|
||||
site = site_id |> Sites.get_site!() |> Repo.preload(:organization)
|
||||
organization = site.organization
|
||||
# ...
|
||||
end
|
||||
|
||||
# AFTER (uses organization_id directly):
|
||||
defp do_check_quota(changeset) do
|
||||
case Ecto.Changeset.fetch_change(changeset, :organization_id) do
|
||||
{:ok, organization_id} ->
|
||||
validate_device_quota(changeset, organization_id)
|
||||
:error ->
|
||||
{:ok, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_device_quota(changeset, organization_id) do
|
||||
organization = Repo.get!(Organization, organization_id)
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**File**: `test/towerops/devices/credential_resolver_test.exs` (new)
|
||||
|
||||
Test scenarios:
|
||||
- 3-tier resolution (device with site): device → site → org → default
|
||||
- 2-tier resolution (device without site): device → org → default
|
||||
- All credential types (SNMP v2, SNMP v3, MikroTik)
|
||||
|
||||
**File**: `test/towerops/devices_test.exs`
|
||||
|
||||
Add tests for:
|
||||
- `get_snmp_config/1` with and without site
|
||||
- `resolve_agent_token_id/1` with and without site
|
||||
- Query functions return site-less devices
|
||||
- Propagation functions handle site-less devices
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**File**: `test/towerops_web/controllers/api/v1/devices_controller_test.exs`
|
||||
|
||||
Test scenarios:
|
||||
- Create device without site_id → succeeds
|
||||
- Create device with site_id from different org → fails
|
||||
- Show/update/delete device without site → succeeds
|
||||
|
||||
**File**: `test/towerops_web/live/device_live_test.exs`
|
||||
|
||||
Test scenarios:
|
||||
- Navigate to /devices/new without sites → no redirect
|
||||
- Submit device form without site_id → succeeds
|
||||
- Device appears in list view
|
||||
|
||||
### Migration Tests
|
||||
|
||||
Verify:
|
||||
- Migration 1 backfills all organization_id values correctly
|
||||
- Check constraint prevents mismatched site/org
|
||||
- Migration 2 rollback blocked when NULL site_id exists
|
||||
|
||||
---
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
### Day 1-2: Schema Foundation
|
||||
1. Write and test migration 1 (add organization_id)
|
||||
2. Update Device schema module
|
||||
3. Write CredentialResolver module
|
||||
4. Add unit tests for CredentialResolver
|
||||
|
||||
### Day 3-4: Credential Resolution
|
||||
1. Refactor SNMP credential resolution
|
||||
2. Refactor MikroTik credential resolution
|
||||
3. Refactor SNMPv3 credential resolution
|
||||
4. Update agent assignment resolution
|
||||
5. Add integration tests
|
||||
|
||||
### Day 5-6: Query Updates
|
||||
1. Update list_organization_devices and related functions
|
||||
2. Update preload functions
|
||||
3. Update propagation functions
|
||||
4. Update quota validation
|
||||
5. Add query tests
|
||||
|
||||
### Day 7-8: Authorization & Forms
|
||||
1. Update API controller authorization
|
||||
2. Update agent channel authorization
|
||||
3. Update device form (remove site wall, make optional)
|
||||
4. Update API create endpoint
|
||||
5. Add E2E tests
|
||||
|
||||
### Day 9: Migration & Deployment
|
||||
1. Write and test migration 2 (make site_id nullable)
|
||||
2. Run migrations in staging
|
||||
3. Manual testing with real devices
|
||||
4. Deploy to production
|
||||
|
||||
### Day 10: Validation
|
||||
1. Monitor error rates
|
||||
2. Test creating devices without sites
|
||||
3. Verify existing devices still work
|
||||
4. Performance testing
|
||||
|
||||
---
|
||||
|
||||
## Critical Files Summary
|
||||
|
||||
### Migrations (New)
|
||||
- `priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exs`
|
||||
- `priv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs`
|
||||
|
||||
### Schema & Context (Modified)
|
||||
- `lib/towerops/devices/device.ex` - Add organization_id field, validation
|
||||
- `lib/towerops/devices/credential_resolver.ex` - New module for safe resolution
|
||||
- `lib/towerops/devices.ex` - 30+ functions need updates
|
||||
|
||||
### Controllers & LiveViews (Modified)
|
||||
- `lib/towerops_web/controllers/api/v1/devices_controller.ex` - Auth fixes
|
||||
- `lib/towerops_web/channels/agent_channel.ex` - Auth fixes
|
||||
- `lib/towerops_web/live/device_live/form.ex` - Remove site wall
|
||||
- `lib/towerops_web/live/device_live/form.html.heex` - Optional site selector
|
||||
|
||||
### Tests (New/Modified)
|
||||
- `test/towerops/devices/credential_resolver_test.exs` - New unit tests
|
||||
- `test/towerops/devices_test.exs` - Updated integration tests
|
||||
- `test/towerops_web/controllers/api/v1/devices_controller_test.exs` - API tests
|
||||
- `test/towerops_web/live/device_live_test.exs` - LiveView tests
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
1. **Revert application code** (Git revert)
|
||||
2. **Keep migration 1** (organization_id column is additive, doesn't break anything)
|
||||
3. **Revert migration 2** (only if no NULL site_id values exist)
|
||||
4. System continues working with 3-tier credential resolution only
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ New user registers → can add device immediately (no site required)
|
||||
✅ Existing multi-site users see no change in behavior
|
||||
✅ All credential resolution works (SNMP, SNMPv3, MikroTik)
|
||||
✅ Agent polling continues for all devices
|
||||
✅ API endpoints work for site-less devices
|
||||
✅ No N+1 query regressions
|
||||
✅ All tests passing (unit, integration, E2E)
|
||||
✅ Zero downtime deployment
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
After core functionality is stable, consider:
|
||||
- Progressive disclosure UI (`use_sites` toggle in org settings)
|
||||
- Conditional navigation (hide "Sites" link when unused)
|
||||
- Device grouping UI (flat vs grouped view)
|
||||
- Auto-create default site on org creation
|
||||
- Help text and onboarding education
|
||||
- Mobile app updates
|
||||
- Advanced site features (hierarchical sites, site tags, etc.)
|
||||
|
|
@ -6,8 +6,10 @@ defmodule Towerops.Devices do
|
|||
import Ecto.Query
|
||||
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Devices.CredentialResolver
|
||||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.Devices.Event
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
|
|
@ -29,7 +31,7 @@ defmodule Towerops.Devices do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of all devices for an organization (via sites).
|
||||
Returns the list of all devices for an organization.
|
||||
Ordered by custom display_order (if set), then alphabetically by name.
|
||||
|
||||
Supports filtering by:
|
||||
|
|
@ -39,8 +41,8 @@ defmodule Towerops.Devices do
|
|||
def list_organization_devices(organization_id, filters \\ %{}) do
|
||||
query =
|
||||
from(e in DeviceSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
left_join: s in assoc(e, :site),
|
||||
where: e.organization_id == ^organization_id,
|
||||
order_by: [asc: e.display_order, asc: e.name],
|
||||
preload: [site: s]
|
||||
)
|
||||
|
|
@ -68,11 +70,11 @@ defmodule Towerops.Devices do
|
|||
def list_devices_for_organizations(organization_ids) when is_list(organization_ids) do
|
||||
Repo.all(
|
||||
from(e in DeviceSchema,
|
||||
join: s in assoc(e, :site),
|
||||
join: o in assoc(s, :organization),
|
||||
where: s.organization_id in ^organization_ids,
|
||||
order_by: [asc: s.organization_id, asc: e.name],
|
||||
preload: [site: {s, organization: o}]
|
||||
left_join: s in assoc(e, :site),
|
||||
join: o in assoc(e, :organization),
|
||||
where: e.organization_id in ^organization_ids,
|
||||
order_by: [asc: e.organization_id, asc: e.name],
|
||||
preload: [site: s, organization: o]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
|
@ -83,8 +85,7 @@ defmodule Towerops.Devices do
|
|||
def count_organization_devices(organization_id) do
|
||||
Repo.aggregate(
|
||||
from(e in DeviceSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id
|
||||
where: e.organization_id == ^organization_id
|
||||
),
|
||||
:count
|
||||
)
|
||||
|
|
@ -140,16 +141,17 @@ defmodule Towerops.Devices do
|
|||
"""
|
||||
def list_mikrotik_devices_with_api do
|
||||
# Get all MikroTik devices with API enabled and credentials configured
|
||||
# Credentials are denormalized - already resolved on device during create/update
|
||||
# Agent assignment is resolved hierarchically in backup_device/1 (includes global default)
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
left_join: s in assoc(d, :site),
|
||||
left_join: o in assoc(s, :organization),
|
||||
left_join: o in assoc(d, :organization),
|
||||
left_join: aa in assoc(d, :agent_assignments),
|
||||
where: d.mikrotik_enabled == true,
|
||||
where: not is_nil(d.mikrotik_username) or not is_nil(s.mikrotik_username) or not is_nil(o.mikrotik_username),
|
||||
where: not is_nil(d.mikrotik_username),
|
||||
order_by: [asc: d.name],
|
||||
preload: [site: {s, organization: o}, agent_assignments: aa],
|
||||
preload: [site: s, organization: o, agent_assignments: aa],
|
||||
distinct: d.id
|
||||
)
|
||||
)
|
||||
|
|
@ -165,8 +167,13 @@ defmodule Towerops.Devices do
|
|||
Returns nil if no agent is assigned.
|
||||
"""
|
||||
def resolve_agent_token_id(device) do
|
||||
# Ensure site and organization are preloaded
|
||||
device = Repo.preload(device, site: :organization, agent_assignments: [])
|
||||
# Ensure organization, site (if present), and agent_assignments are preloaded
|
||||
device =
|
||||
if device.site_id do
|
||||
Repo.preload(device, [:organization, :site, agent_assignments: []])
|
||||
else
|
||||
Repo.preload(device, [:organization, agent_assignments: []])
|
||||
end
|
||||
|
||||
# Try each level in order
|
||||
get_direct_agent(device) ||
|
||||
|
|
@ -182,9 +189,17 @@ defmodule Towerops.Devices do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_site_agent(device), do: device.site.agent_token_id
|
||||
defp get_site_agent(device) do
|
||||
# Safe: only access site if it exists
|
||||
if device.site_id && device.site do
|
||||
device.site.agent_token_id
|
||||
end
|
||||
end
|
||||
|
||||
defp get_org_default_agent(device), do: device.site.organization.default_agent_token_id
|
||||
defp get_org_default_agent(device) do
|
||||
# Use direct organization relationship instead of site.organization
|
||||
device.organization.default_agent_token_id
|
||||
end
|
||||
|
||||
defp get_global_default_agent, do: Towerops.Settings.get_global_default_cloud_poller()
|
||||
|
||||
|
|
@ -196,7 +211,7 @@ defmodule Towerops.Devices do
|
|||
|> Repo.get(id)
|
||||
|> case do
|
||||
nil -> nil
|
||||
device -> Repo.preload(device, [:site])
|
||||
device -> Repo.preload(device, [:organization, :site])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -206,7 +221,7 @@ defmodule Towerops.Devices do
|
|||
def get_device!(id) do
|
||||
DeviceSchema
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload([:site])
|
||||
|> Repo.preload([:organization, :site])
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -270,73 +285,20 @@ defmodule Towerops.Devices do
|
|||
- source: Where the config came from (:device, :site, or :organization)
|
||||
"""
|
||||
def get_snmp_config(device_id) when is_binary(device_id) do
|
||||
device =
|
||||
DeviceSchema
|
||||
|> Repo.get!(device_id)
|
||||
|> Repo.preload(site: :organization)
|
||||
|
||||
device = Repo.get!(DeviceSchema, device_id)
|
||||
get_snmp_config(device)
|
||||
end
|
||||
|
||||
def get_snmp_config(%DeviceSchema{} = device) do
|
||||
device = ensure_snmp_config_loaded(device)
|
||||
resolve_snmp_config(device)
|
||||
end
|
||||
|
||||
defp ensure_snmp_config_loaded(device) do
|
||||
# Ensure site and organization associations are loaded
|
||||
needs_preload =
|
||||
!Ecto.assoc_loaded?(device.site) ||
|
||||
!Ecto.assoc_loaded?(device.site.organization)
|
||||
|
||||
if needs_preload do
|
||||
Repo.preload(device, site: :organization)
|
||||
else
|
||||
device
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_snmp_config(device) do
|
||||
# Resolve community, version, and port independently with hierarchical fallback
|
||||
# This allows setting version at device level while inheriting community from org
|
||||
community =
|
||||
device.snmp_community ||
|
||||
device.site.snmp_community ||
|
||||
device.site.organization.snmp_community
|
||||
|
||||
version =
|
||||
device.snmp_version ||
|
||||
device.site.snmp_version ||
|
||||
device.site.organization.snmp_version ||
|
||||
"2c"
|
||||
|
||||
port =
|
||||
device.snmp_port ||
|
||||
device.site.snmp_port ||
|
||||
device.site.organization.snmp_port ||
|
||||
161
|
||||
|
||||
source = determine_snmp_source(device)
|
||||
|
||||
# Credentials are already denormalized on the device - just read them!
|
||||
%{
|
||||
version: version,
|
||||
community: community,
|
||||
port: port,
|
||||
source: source
|
||||
version: device.snmp_version || "2c",
|
||||
community: device.snmp_community,
|
||||
port: device.snmp_port || 161,
|
||||
source: String.to_atom(device.snmp_community_source || "organization")
|
||||
}
|
||||
end
|
||||
|
||||
defp determine_snmp_source(device) do
|
||||
# Determine source based on where community string came from (primary credential)
|
||||
# If no community anywhere, source is :default
|
||||
cond do
|
||||
device.snmp_community != nil -> :device
|
||||
device.site.snmp_community != nil -> :site
|
||||
device.site.organization.snmp_community != nil -> :organization
|
||||
true -> :default
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets MikroTik API configuration for a device with hierarchical fallback.
|
||||
|
||||
|
|
@ -355,98 +317,27 @@ defmodule Towerops.Devices do
|
|||
}
|
||||
"""
|
||||
def get_mikrotik_config(device_id) when is_binary(device_id) do
|
||||
device =
|
||||
DeviceSchema
|
||||
|> Repo.get!(device_id)
|
||||
|> Repo.preload(site: :organization)
|
||||
|
||||
device = Repo.get!(DeviceSchema, device_id)
|
||||
get_mikrotik_config(device)
|
||||
end
|
||||
|
||||
def get_mikrotik_config(%DeviceSchema{} = device) do
|
||||
device = ensure_mikrotik_config_loaded(device)
|
||||
resolve_mikrotik_config(device)
|
||||
end
|
||||
|
||||
defp ensure_mikrotik_config_loaded(device) do
|
||||
# Ensure site and organization associations are loaded
|
||||
needs_preload =
|
||||
!Ecto.assoc_loaded?(device.site) ||
|
||||
!Ecto.assoc_loaded?(device.site.organization)
|
||||
|
||||
if needs_preload do
|
||||
Repo.preload(device, site: :organization)
|
||||
else
|
||||
device
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_config(device) do
|
||||
# Resolve each field independently with hierarchical fallback
|
||||
# Credentials are already denormalized on the device - just read them!
|
||||
%{
|
||||
username: resolve_mikrotik_username(device),
|
||||
password: resolve_mikrotik_password(device),
|
||||
port: resolve_mikrotik_port(device),
|
||||
ssh_port: resolve_mikrotik_ssh_port(device),
|
||||
use_ssl: resolve_mikrotik_use_ssl(device),
|
||||
enabled: resolve_mikrotik_enabled(device),
|
||||
source: determine_mikrotik_source(device)
|
||||
username: device.mikrotik_username,
|
||||
password: device.mikrotik_password,
|
||||
port: device.mikrotik_port || 8729,
|
||||
ssh_port: device.mikrotik_ssh_port || 22,
|
||||
use_ssl: if(device.mikrotik_use_ssl == nil, do: true, else: device.mikrotik_use_ssl),
|
||||
enabled: device.mikrotik_enabled || false,
|
||||
source: String.to_atom(device.mikrotik_credential_source || "organization")
|
||||
}
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_username(device) do
|
||||
device.mikrotik_username ||
|
||||
device.site.mikrotik_username ||
|
||||
device.site.organization.mikrotik_username
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_password(device) do
|
||||
device.mikrotik_password ||
|
||||
device.site.mikrotik_password ||
|
||||
device.site.organization.mikrotik_password
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_port(device) do
|
||||
device.mikrotik_port ||
|
||||
device.site.mikrotik_port ||
|
||||
device.site.organization.mikrotik_port ||
|
||||
8729
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_ssh_port(device) do
|
||||
device.mikrotik_ssh_port ||
|
||||
device.site.mikrotik_ssh_port ||
|
||||
device.site.organization.mikrotik_ssh_port ||
|
||||
22
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_use_ssl(device) do
|
||||
if device.mikrotik_use_ssl == nil do
|
||||
device.site.mikrotik_use_ssl ||
|
||||
device.site.organization.mikrotik_use_ssl ||
|
||||
true
|
||||
else
|
||||
device.mikrotik_use_ssl
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_enabled(device) do
|
||||
device.mikrotik_enabled ||
|
||||
device.site.mikrotik_enabled ||
|
||||
device.site.organization.mikrotik_enabled ||
|
||||
false
|
||||
end
|
||||
|
||||
defp determine_mikrotik_source(device) do
|
||||
# Determine source based on where username came from (primary credential)
|
||||
cond do
|
||||
device.mikrotik_username != nil -> :device
|
||||
device.site.mikrotik_username != nil -> :site
|
||||
device.site.organization.mikrotik_username != nil -> :organization
|
||||
true -> :default
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Propagates MikroTik credential changes from a site to all devices in that
|
||||
site that inherit credentials from the site (credential_source = "site").
|
||||
|
|
@ -488,18 +379,13 @@ defmodule Towerops.Devices do
|
|||
Updates username, password, port, use_ssl, and enabled fields.
|
||||
"""
|
||||
def propagate_organization_mikrotik_change(organization_id, attrs) do
|
||||
# Find all devices in this org's sites that:
|
||||
# 1. Inherit from site (credential_source = "site")
|
||||
# 2. Their site has no MikroTik username (so they fall back to org)
|
||||
empty_string = ""
|
||||
|
||||
# With denormalized credentials, update all devices that inherit from the organization
|
||||
# (devices with mikrotik_credential_source = "organization")
|
||||
devices_to_update =
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: d.mikrotik_credential_source == "site",
|
||||
where: is_nil(s.mikrotik_username) or s.mikrotik_username == ^empty_string
|
||||
where: d.organization_id == ^organization_id,
|
||||
where: d.mikrotik_credential_source == "organization"
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -539,11 +425,7 @@ defmodule Towerops.Devices do
|
|||
}
|
||||
"""
|
||||
def get_snmpv3_config(device_id) when is_binary(device_id) do
|
||||
device =
|
||||
DeviceSchema
|
||||
|> Repo.get!(device_id)
|
||||
|> Repo.preload(site: :organization)
|
||||
|
||||
device = Repo.get!(DeviceSchema, device_id)
|
||||
get_snmpv3_config(device)
|
||||
end
|
||||
|
||||
|
|
@ -552,81 +434,18 @@ defmodule Towerops.Devices do
|
|||
end
|
||||
|
||||
def get_snmpv3_config(%DeviceSchema{} = device) do
|
||||
device = ensure_snmpv3_config_loaded(device)
|
||||
resolve_snmpv3_config(device)
|
||||
end
|
||||
|
||||
defp ensure_snmpv3_config_loaded(device) do
|
||||
needs_preload =
|
||||
!Ecto.assoc_loaded?(device.site) ||
|
||||
!Ecto.assoc_loaded?(device.site.organization)
|
||||
|
||||
if needs_preload do
|
||||
Repo.preload(device, site: :organization)
|
||||
else
|
||||
device
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_config(device) do
|
||||
# Credentials are already denormalized on the device - just read them!
|
||||
%{
|
||||
security_level: resolve_snmpv3_security_level(device),
|
||||
username: resolve_snmpv3_username(device),
|
||||
auth_protocol: resolve_snmpv3_auth_protocol(device),
|
||||
auth_password: resolve_snmpv3_auth_password(device),
|
||||
priv_protocol: resolve_snmpv3_priv_protocol(device),
|
||||
priv_password: resolve_snmpv3_priv_password(device),
|
||||
source: determine_snmpv3_source(device)
|
||||
security_level: device.snmpv3_security_level,
|
||||
username: device.snmpv3_username,
|
||||
auth_protocol: device.snmpv3_auth_protocol || "SHA-256",
|
||||
auth_password: device.snmpv3_auth_password,
|
||||
priv_protocol: device.snmpv3_priv_protocol || "AES",
|
||||
priv_password: device.snmpv3_priv_password,
|
||||
source: String.to_atom(device.snmpv3_credential_source || "organization")
|
||||
}
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_security_level(device) do
|
||||
device.snmpv3_security_level ||
|
||||
device.site.snmpv3_security_level ||
|
||||
device.site.organization.snmpv3_security_level
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_username(device) do
|
||||
device.snmpv3_username ||
|
||||
device.site.snmpv3_username ||
|
||||
device.site.organization.snmpv3_username
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_auth_protocol(device) do
|
||||
device.snmpv3_auth_protocol ||
|
||||
device.site.snmpv3_auth_protocol ||
|
||||
device.site.organization.snmpv3_auth_protocol ||
|
||||
"SHA-256"
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_auth_password(device) do
|
||||
device.snmpv3_auth_password ||
|
||||
device.site.snmpv3_auth_password ||
|
||||
device.site.organization.snmpv3_auth_password
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_priv_protocol(device) do
|
||||
device.snmpv3_priv_protocol ||
|
||||
device.site.snmpv3_priv_protocol ||
|
||||
device.site.organization.snmpv3_priv_protocol ||
|
||||
"AES"
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_priv_password(device) do
|
||||
device.snmpv3_priv_password ||
|
||||
device.site.snmpv3_priv_password ||
|
||||
device.site.organization.snmpv3_priv_password
|
||||
end
|
||||
|
||||
defp determine_snmpv3_source(device) do
|
||||
cond do
|
||||
device.snmpv3_username != nil -> :device
|
||||
device.site.snmpv3_username != nil -> :site
|
||||
device.site.organization.snmpv3_username != nil -> :organization
|
||||
true -> :default
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Propagates SNMPv3 credential changes from a site to all devices in that
|
||||
site that inherit credentials from the site (credential_source = "site").
|
||||
|
|
@ -660,15 +479,13 @@ defmodule Towerops.Devices do
|
|||
in sites that have no site-level credentials.
|
||||
"""
|
||||
def propagate_organization_snmpv3_change(organization_id, attrs) do
|
||||
empty_string = ""
|
||||
|
||||
# With denormalized credentials, update all devices that inherit from the organization
|
||||
# (devices with snmpv3_credential_source = "organization")
|
||||
devices_to_update =
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: d.snmpv3_credential_source == "site",
|
||||
where: is_nil(s.snmpv3_username) or s.snmpv3_username == ^empty_string
|
||||
where: d.organization_id == ^organization_id,
|
||||
where: d.snmpv3_credential_source == "organization"
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -717,6 +534,9 @@ defmodule Towerops.Devices do
|
|||
defp check_device_quota(attrs, bypass_limits) do
|
||||
changeset = DeviceSchema.changeset(%DeviceSchema{}, attrs)
|
||||
|
||||
# Apply credential resolution
|
||||
changeset = apply_credential_resolution(changeset)
|
||||
|
||||
if bypass_limits do
|
||||
{:ok, changeset}
|
||||
else
|
||||
|
|
@ -724,20 +544,40 @@ defmodule Towerops.Devices do
|
|||
end
|
||||
end
|
||||
|
||||
defp apply_credential_resolution(changeset) do
|
||||
# Extract organization_id and site_id from changeset
|
||||
organization_id = Ecto.Changeset.get_field(changeset, :organization_id)
|
||||
site_id = Ecto.Changeset.get_field(changeset, :site_id)
|
||||
|
||||
if organization_id do
|
||||
# Load organization and site (if present)
|
||||
organization = Organizations.get_organization!(organization_id)
|
||||
site = if site_id, do: Sites.get_site!(site_id)
|
||||
|
||||
# Apply credential resolution
|
||||
changeset
|
||||
|> CredentialResolver.resolve_snmp_credentials(organization, site)
|
||||
|> CredentialResolver.resolve_snmpv3_credentials(organization, site)
|
||||
|> CredentialResolver.resolve_mikrotik_credentials(organization, site)
|
||||
else
|
||||
# No organization_id yet, skip credential resolution
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check_quota(changeset) do
|
||||
case Ecto.Changeset.fetch_change(changeset, :site_id) do
|
||||
{:ok, site_id} ->
|
||||
validate_device_quota(changeset, site_id)
|
||||
case Ecto.Changeset.fetch_change(changeset, :organization_id) do
|
||||
{:ok, organization_id} ->
|
||||
validate_device_quota(changeset, organization_id)
|
||||
|
||||
:error ->
|
||||
# No site_id in changeset, let normal validation handle it
|
||||
# No organization_id in changeset, let normal validation handle it
|
||||
{:ok, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_device_quota(changeset, site_id) do
|
||||
site = site_id |> Sites.get_site!() |> Repo.preload(:organization)
|
||||
organization = site.organization
|
||||
defp validate_device_quota(changeset, organization_id) do
|
||||
organization = Organizations.get_organization!(organization_id)
|
||||
|
||||
case SubscriptionLimits.check_device_limit(organization) do
|
||||
{:ok, :within_limit} ->
|
||||
|
|
@ -766,6 +606,9 @@ defmodule Towerops.Devices do
|
|||
|
||||
changeset = DeviceSchema.changeset(device, attrs)
|
||||
|
||||
# Apply credential resolution
|
||||
changeset = apply_credential_resolution(changeset)
|
||||
|
||||
# Validate MikroTik SSL requirement for cloud pollers
|
||||
changeset = validate_mikrotik_ssl_with_cloud_poller(changeset, device)
|
||||
|
||||
|
|
@ -903,16 +746,13 @@ defmodule Towerops.Devices do
|
|||
and site.snmp_community is nil).
|
||||
"""
|
||||
def propagate_organization_community_change(organization_id, new_community) do
|
||||
# Find all devices in this org's sites that:
|
||||
# 1. Inherit from site (source = "site")
|
||||
# 2. Their site has no community string (so they fall back to org)
|
||||
# With denormalized credentials, update all devices that inherit from the organization
|
||||
# (devices with snmp_community_source = "organization")
|
||||
devices_to_update =
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: d.snmp_community_source == "site",
|
||||
where: is_nil(s.snmp_community) or s.snmp_community == ""
|
||||
where: d.organization_id == ^organization_id,
|
||||
where: d.snmp_community_source == "organization"
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
219
lib/towerops/devices/credential_resolver.ex
Normal file
219
lib/towerops/devices/credential_resolver.ex
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
defmodule Towerops.Devices.CredentialResolver do
|
||||
@moduledoc """
|
||||
Resolves and denormalizes credentials for devices from organization/site hierarchy.
|
||||
|
||||
This module populates device credential fields during create/update by resolving from
|
||||
the organization/site hierarchy and storing the resolved values directly on the device.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Instead of runtime cascading (device → site → org), we:
|
||||
1. Resolve credentials ONCE during device create/update
|
||||
2. Store resolved values directly on device fields
|
||||
3. Track source in `*_source` fields ("organization", "site", "device")
|
||||
4. Propagate changes when org/site credentials change
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Performance**: No need to preload site/org on every access
|
||||
- **Simplicity**: Just read `device.snmp_community` directly at runtime
|
||||
- **Clear audit**: Source fields show where credentials came from
|
||||
- **Easy propagation**: Find devices by source and update them
|
||||
|
||||
## Usage
|
||||
|
||||
### During device create/update:
|
||||
|
||||
# In changeset pipeline
|
||||
changeset
|
||||
|> resolve_snmp_credentials(organization, site)
|
||||
|> resolve_mikrotik_credentials(organization, site)
|
||||
|> resolve_snmpv3_credentials(organization, site)
|
||||
|
||||
### At runtime (SNMP polling, etc):
|
||||
|
||||
# Just read the fields directly - already resolved!
|
||||
device.snmp_community # "public" (from org)
|
||||
device.snmp_community_source # "organization"
|
||||
|
||||
### When org/site credentials change:
|
||||
|
||||
# Propagate to devices that inherit from that source
|
||||
Devices.propagate_organization_community_change(org_id, new_community)
|
||||
"""
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
@doc """
|
||||
Resolves SNMP v2c credentials for a device changeset.
|
||||
|
||||
Populates snmp_community, snmp_version, snmp_port on the device if they're not
|
||||
explicitly set (or empty), using site → organization fallback.
|
||||
|
||||
Updates snmp_community_source to track where the community came from.
|
||||
"""
|
||||
@spec resolve_snmp_credentials(Ecto.Changeset.t(), Organization.t(), Site.t() | nil) ::
|
||||
Ecto.Changeset.t()
|
||||
def resolve_snmp_credentials(changeset, organization, site) do
|
||||
# If user explicitly set community, keep it and mark as "device"
|
||||
# Otherwise resolve from site → org and track source
|
||||
case_result =
|
||||
case get_field(changeset, :snmp_community) do
|
||||
nil ->
|
||||
# Resolve from hierarchy
|
||||
{community, source} = resolve_with_source(site, organization, :snmp_community)
|
||||
|
||||
changeset
|
||||
|> put_change(:snmp_community, community)
|
||||
|> put_change(:snmp_community_source, source)
|
||||
|
||||
"" ->
|
||||
# Empty string means inherit from site/org
|
||||
{community, source} = resolve_with_source(site, organization, :snmp_community)
|
||||
|
||||
changeset
|
||||
|> put_change(:snmp_community, community)
|
||||
|> put_change(:snmp_community_source, source)
|
||||
|
||||
_value ->
|
||||
# User set a value, mark as device-specific
|
||||
put_change(changeset, :snmp_community_source, "device")
|
||||
end
|
||||
|
||||
case_result
|
||||
|> resolve_snmp_version(organization, site)
|
||||
|> resolve_snmp_port(organization, site)
|
||||
end
|
||||
|
||||
defp resolve_snmp_version(changeset, organization, site) do
|
||||
case get_field(changeset, :snmp_version) do
|
||||
nil ->
|
||||
version = resolve_value(site, organization, :snmp_version) || "2c"
|
||||
put_change(changeset, :snmp_version, version)
|
||||
|
||||
_value ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_snmp_port(changeset, organization, site) do
|
||||
case get_field(changeset, :snmp_port) do
|
||||
nil ->
|
||||
port = resolve_value(site, organization, :snmp_port) || 161
|
||||
put_change(changeset, :snmp_port, port)
|
||||
|
||||
_value ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resolves SNMPv3 credentials for a device changeset.
|
||||
|
||||
Populates SNMPv3 fields (username, security_level, auth_protocol, etc.) if not set,
|
||||
using site → organization fallback.
|
||||
|
||||
Updates snmpv3_credential_source to track where credentials came from.
|
||||
"""
|
||||
@spec resolve_snmpv3_credentials(Ecto.Changeset.t(), Organization.t(), Site.t() | nil) ::
|
||||
Ecto.Changeset.t()
|
||||
def resolve_snmpv3_credentials(changeset, organization, site) do
|
||||
# Only resolve if SNMP version is "3"
|
||||
version = get_field(changeset, :snmp_version)
|
||||
|
||||
if version == "3" do
|
||||
case get_field(changeset, :snmpv3_username) do
|
||||
nil ->
|
||||
# Resolve all SNMPv3 fields from hierarchy
|
||||
{username, source} = resolve_with_source(site, organization, :snmpv3_username)
|
||||
security_level = resolve_value(site, organization, :snmpv3_security_level)
|
||||
auth_protocol = resolve_value(site, organization, :snmpv3_auth_protocol) || "SHA-256"
|
||||
auth_password = resolve_value(site, organization, :snmpv3_auth_password)
|
||||
priv_protocol = resolve_value(site, organization, :snmpv3_priv_protocol) || "AES"
|
||||
priv_password = resolve_value(site, organization, :snmpv3_priv_password)
|
||||
|
||||
changeset
|
||||
|> put_change(:snmpv3_username, username)
|
||||
|> put_change(:snmpv3_security_level, security_level)
|
||||
|> put_change(:snmpv3_auth_protocol, auth_protocol)
|
||||
|> put_change(:snmpv3_auth_password, auth_password)
|
||||
|> put_change(:snmpv3_priv_protocol, priv_protocol)
|
||||
|> put_change(:snmpv3_priv_password, priv_password)
|
||||
|> put_change(:snmpv3_credential_source, source)
|
||||
|
||||
_value ->
|
||||
# User set credentials, mark as device-specific
|
||||
put_change(changeset, :snmpv3_credential_source, "device")
|
||||
end
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resolves MikroTik API credentials for a device changeset.
|
||||
|
||||
Populates MikroTik fields (username, password, port, use_ssl) if not set,
|
||||
using site → organization fallback.
|
||||
|
||||
Updates mikrotik_credential_source to track where credentials came from.
|
||||
"""
|
||||
@spec resolve_mikrotik_credentials(Ecto.Changeset.t(), Organization.t(), Site.t() | nil) ::
|
||||
Ecto.Changeset.t()
|
||||
def resolve_mikrotik_credentials(changeset, organization, site) do
|
||||
# If user explicitly set username, keep it and mark as "device"
|
||||
# Otherwise resolve from site → org and track source
|
||||
case get_field(changeset, :mikrotik_username) do
|
||||
nil ->
|
||||
# Resolve from hierarchy
|
||||
{username, source} = resolve_with_source(site, organization, :mikrotik_username)
|
||||
password = resolve_value(site, organization, :mikrotik_password)
|
||||
port = resolve_value(site, organization, :mikrotik_port) || 8729
|
||||
ssh_port = resolve_value(site, organization, :mikrotik_ssh_port) || 22
|
||||
use_ssl = resolve_value(site, organization, :mikrotik_use_ssl)
|
||||
use_ssl = if use_ssl == nil, do: true, else: use_ssl
|
||||
enabled = resolve_value(site, organization, :mikrotik_enabled) || false
|
||||
|
||||
changeset
|
||||
|> put_change(:mikrotik_username, username)
|
||||
|> put_change(:mikrotik_password, password)
|
||||
|> put_change(:mikrotik_port, port)
|
||||
|> put_change(:mikrotik_ssh_port, ssh_port)
|
||||
|> put_change(:mikrotik_use_ssl, use_ssl)
|
||||
|> put_change(:mikrotik_enabled, enabled)
|
||||
|> put_change(:mikrotik_credential_source, source)
|
||||
|
||||
_value ->
|
||||
# User set a value, mark as device-specific
|
||||
put_change(changeset, :mikrotik_credential_source, "device")
|
||||
end
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
|
||||
# Resolves a field from site → org, returns {value, source}
|
||||
defp resolve_with_source(site, organization, field) do
|
||||
cond do
|
||||
site && Map.get(site, field) != nil ->
|
||||
{Map.get(site, field), "site"}
|
||||
|
||||
Map.get(organization, field) != nil ->
|
||||
{Map.get(organization, field), "organization"}
|
||||
|
||||
true ->
|
||||
{nil, "organization"}
|
||||
end
|
||||
end
|
||||
|
||||
# Resolves a field from site → org, returns just the value
|
||||
defp resolve_value(site, organization, field) do
|
||||
if site && Map.get(site, field) != nil do
|
||||
Map.get(site, field)
|
||||
else
|
||||
Map.get(organization, field)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -15,6 +15,7 @@ defmodule Towerops.Devices.Device do
|
|||
alias Ecto.Association.NotLoaded
|
||||
alias Towerops.Agents.AgentAssignment
|
||||
alias Towerops.Encrypted.Binary
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Sites.Site
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
|
||||
|
|
@ -59,6 +60,7 @@ defmodule Towerops.Devices.Device do
|
|||
field :mikrotik_credential_source, :string, default: "site"
|
||||
|
||||
belongs_to :site, Site
|
||||
belongs_to :organization, Organization
|
||||
|
||||
has_one :snmp_device, SnmpDevice
|
||||
has_many :agent_assignments, AgentAssignment
|
||||
|
|
@ -97,8 +99,10 @@ defmodule Towerops.Devices.Device do
|
|||
mikrotik_use_ssl: boolean() | nil,
|
||||
mikrotik_enabled: boolean() | nil,
|
||||
mikrotik_credential_source: String.t() | nil,
|
||||
site_id: Ecto.UUID.t(),
|
||||
site: NotLoaded.t() | Site.t(),
|
||||
site_id: Ecto.UUID.t() | nil,
|
||||
site: NotLoaded.t() | Site.t() | nil,
|
||||
organization_id: Ecto.UUID.t(),
|
||||
organization: NotLoaded.t() | Organization.t() | nil,
|
||||
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
|
||||
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
|
||||
inserted_at: DateTime.t(),
|
||||
|
|
@ -114,6 +118,7 @@ defmodule Towerops.Devices.Device do
|
|||
:description,
|
||||
:display_order,
|
||||
:site_id,
|
||||
:organization_id,
|
||||
:monitoring_enabled,
|
||||
:check_interval_seconds,
|
||||
:snmp_enabled,
|
||||
|
|
@ -141,7 +146,7 @@ defmodule Towerops.Devices.Device do
|
|||
:mikrotik_enabled,
|
||||
:mikrotik_credential_source
|
||||
])
|
||||
|> validate_required([:ip_address, :site_id])
|
||||
|> validate_required([:ip_address, :organization_id])
|
||||
|> validate_name()
|
||||
|> validate_length(:name, min: 2, max: 200)
|
||||
|> validate_length(:description, max: 1000)
|
||||
|
|
@ -150,10 +155,12 @@ defmodule Towerops.Devices.Device do
|
|||
|> validate_snmp()
|
||||
|> validate_snmpv3_fields()
|
||||
|> validate_mikrotik()
|
||||
|> validate_site_belongs_to_organization()
|
||||
|> update_community_source()
|
||||
|> update_snmpv3_credential_source()
|
||||
|> update_mikrotik_credential_source()
|
||||
|> foreign_key_constraint(:site_id)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
|
||||
defp validate_name(changeset) do
|
||||
|
|
@ -191,6 +198,23 @@ defmodule Towerops.Devices.Device do
|
|||
|> :inet.parse_address()
|
||||
end
|
||||
|
||||
defp validate_site_belongs_to_organization(changeset) do
|
||||
site_id = get_field(changeset, :site_id)
|
||||
org_id = get_field(changeset, :organization_id)
|
||||
|
||||
# If no site_id or org_id, no validation needed
|
||||
if !site_id || !org_id do
|
||||
changeset
|
||||
else
|
||||
# Verify that the site belongs to the same organization
|
||||
case Towerops.Repo.get(Site, site_id) do
|
||||
nil -> add_error(changeset, :site_id, "does not exist")
|
||||
%{organization_id: ^org_id} -> changeset
|
||||
_site -> add_error(changeset, :site_id, "must belong to the same organization")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_snmp(changeset) do
|
||||
snmp_enabled = get_field(changeset, :snmp_enabled)
|
||||
|
||||
|
|
|
|||
|
|
@ -114,52 +114,64 @@ defmodule Towerops.Organizations do
|
|||
Updates an organization.
|
||||
"""
|
||||
def update_organization(%Organization{} = organization, attrs) do
|
||||
old_community = organization.snmp_community
|
||||
old_mikrotik_username = organization.mikrotik_username
|
||||
old_mikrotik_password = organization.mikrotik_password
|
||||
old_mikrotik_port = organization.mikrotik_port
|
||||
old_mikrotik_use_ssl = organization.mikrotik_use_ssl
|
||||
old_mikrotik_enabled = organization.mikrotik_enabled
|
||||
old_values = %{
|
||||
snmp_community: organization.snmp_community,
|
||||
mikrotik_username: organization.mikrotik_username,
|
||||
mikrotik_password: organization.mikrotik_password,
|
||||
mikrotik_port: organization.mikrotik_port,
|
||||
mikrotik_use_ssl: organization.mikrotik_use_ssl,
|
||||
mikrotik_enabled: organization.mikrotik_enabled,
|
||||
use_sites: organization.use_sites
|
||||
}
|
||||
|
||||
case organization
|
||||
|> Organization.changeset(attrs)
|
||||
|> Repo.update() do
|
||||
{:ok, updated_organization} = result ->
|
||||
# If community string changed, propagate to inheriting devices
|
||||
if updated_organization.snmp_community != old_community do
|
||||
Towerops.Devices.propagate_organization_community_change(
|
||||
updated_organization.id,
|
||||
updated_organization.snmp_community
|
||||
)
|
||||
end
|
||||
with {:ok, updated_organization} <- organization |> Organization.changeset(attrs) |> Repo.update() do
|
||||
propagate_organization_changes(updated_organization, old_values)
|
||||
{:ok, updated_organization}
|
||||
end
|
||||
end
|
||||
|
||||
# If any MikroTik settings changed, propagate to inheriting devices
|
||||
mikrotik_changed =
|
||||
updated_organization.mikrotik_username != old_mikrotik_username ||
|
||||
updated_organization.mikrotik_password != old_mikrotik_password ||
|
||||
updated_organization.mikrotik_port != old_mikrotik_port ||
|
||||
updated_organization.mikrotik_use_ssl != old_mikrotik_use_ssl ||
|
||||
updated_organization.mikrotik_enabled != old_mikrotik_enabled
|
||||
defp propagate_organization_changes(updated_organization, old_values) do
|
||||
propagate_snmp_changes(updated_organization, old_values)
|
||||
propagate_mikrotik_changes(updated_organization, old_values)
|
||||
handle_sites_disabled(updated_organization, old_values)
|
||||
end
|
||||
|
||||
if mikrotik_changed do
|
||||
mikrotik_attrs = %{
|
||||
mikrotik_username: updated_organization.mikrotik_username,
|
||||
mikrotik_password: updated_organization.mikrotik_password,
|
||||
mikrotik_port: updated_organization.mikrotik_port,
|
||||
mikrotik_use_ssl: updated_organization.mikrotik_use_ssl,
|
||||
mikrotik_enabled: updated_organization.mikrotik_enabled
|
||||
}
|
||||
defp propagate_snmp_changes(updated_organization, %{snmp_community: old_community}) do
|
||||
if updated_organization.snmp_community != old_community do
|
||||
Towerops.Devices.propagate_organization_community_change(
|
||||
updated_organization.id,
|
||||
updated_organization.snmp_community
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
Towerops.Devices.propagate_organization_mikrotik_change(
|
||||
updated_organization.id,
|
||||
mikrotik_attrs
|
||||
)
|
||||
end
|
||||
defp propagate_mikrotik_changes(updated_organization, old_values) do
|
||||
mikrotik_changed =
|
||||
updated_organization.mikrotik_username != old_values.mikrotik_username ||
|
||||
updated_organization.mikrotik_password != old_values.mikrotik_password ||
|
||||
updated_organization.mikrotik_port != old_values.mikrotik_port ||
|
||||
updated_organization.mikrotik_use_ssl != old_values.mikrotik_use_ssl ||
|
||||
updated_organization.mikrotik_enabled != old_values.mikrotik_enabled
|
||||
|
||||
result
|
||||
if mikrotik_changed do
|
||||
mikrotik_attrs = %{
|
||||
mikrotik_username: updated_organization.mikrotik_username,
|
||||
mikrotik_password: updated_organization.mikrotik_password,
|
||||
mikrotik_port: updated_organization.mikrotik_port,
|
||||
mikrotik_use_ssl: updated_organization.mikrotik_use_ssl,
|
||||
mikrotik_enabled: updated_organization.mikrotik_enabled
|
||||
}
|
||||
|
||||
error ->
|
||||
error
|
||||
Towerops.Devices.propagate_organization_mikrotik_change(
|
||||
updated_organization.id,
|
||||
mikrotik_attrs
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_sites_disabled(updated_organization, %{use_sites: old_use_sites}) do
|
||||
if old_use_sites && !updated_organization.use_sites do
|
||||
clear_all_site_assignments(updated_organization.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -397,6 +409,21 @@ defmodule Towerops.Organizations do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all site_id assignments for devices in an organization when sites are disabled.
|
||||
|
||||
Returns the number of device records updated.
|
||||
"""
|
||||
def clear_all_site_assignments(organization_id) do
|
||||
Repo.update_all(
|
||||
from(d in Device, where: d.organization_id == ^organization_id),
|
||||
set: [
|
||||
site_id: nil,
|
||||
updated_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
## Authorization
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
field :name, :string
|
||||
field :slug, :string
|
||||
field :subscription_plan, :string, default: "free"
|
||||
field :use_sites, :boolean, default: false
|
||||
|
||||
# device)
|
||||
field :snmp_version, :string, default: "2c"
|
||||
|
|
@ -61,6 +62,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
name: String.t(),
|
||||
slug: String.t(),
|
||||
subscription_plan: String.t(),
|
||||
use_sites: boolean(),
|
||||
snmp_version: String.t() | nil,
|
||||
snmp_community: String.t() | nil,
|
||||
snmp_port: integer(),
|
||||
|
|
@ -91,6 +93,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
|> cast(attrs, [
|
||||
:name,
|
||||
:subscription_plan,
|
||||
:use_sites,
|
||||
:default_agent_token_id,
|
||||
:snmp_version,
|
||||
:snmp_community,
|
||||
|
|
|
|||
|
|
@ -629,7 +629,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
|
||||
defp verify_device_organization(device, organization_id) do
|
||||
# Cloud pollers (organization_id = nil) can access any device
|
||||
if is_nil(organization_id) or device.site.organization_id == organization_id do
|
||||
if is_nil(organization_id) or device.organization_id == organization_id do
|
||||
:ok
|
||||
else
|
||||
{:error, :wrong_organization}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
Dashboard
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
:if={@current_organization && @current_organization.use_sites}
|
||||
navigate={~p"/sites"}
|
||||
active={@active_page == "sites"}
|
||||
>
|
||||
|
|
@ -374,6 +375,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.icon name="hero-home" class="size-5" /> Dashboard
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
:if={@current_organization && @current_organization.use_sites}
|
||||
navigate={~p"/sites"}
|
||||
active={@active_page == "sites"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -139,12 +139,9 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
|
|||
def show(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
device =
|
||||
id
|
||||
|> Devices.get_device!()
|
||||
|> Towerops.Repo.preload(:site)
|
||||
device = Devices.get_device!(id)
|
||||
|
||||
if device.site.organization_id == organization_id do
|
||||
if device.organization_id == organization_id do
|
||||
json(conn, format_device_details(device))
|
||||
else
|
||||
conn
|
||||
|
|
@ -182,12 +179,9 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
|
|||
def update(conn, %{"id" => id, "device" => device_params}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
device =
|
||||
id
|
||||
|> Devices.get_device!()
|
||||
|> Towerops.Repo.preload(:site)
|
||||
device = Devices.get_device!(id)
|
||||
|
||||
if device.site.organization_id == organization_id do
|
||||
if device.organization_id == organization_id do
|
||||
case Devices.update_device(device, device_params) do
|
||||
{:ok, updated_device} ->
|
||||
json(conn, format_device_details(updated_device))
|
||||
|
|
@ -228,12 +222,9 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
|
|||
def delete(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
device =
|
||||
id
|
||||
|> Devices.get_device!()
|
||||
|> Towerops.Repo.preload(:site)
|
||||
device = Devices.get_device!(id)
|
||||
|
||||
if device.site.organization_id == organization_id do
|
||||
if device.organization_id == organization_id do
|
||||
case Devices.delete_device(device) do
|
||||
{:ok, _device} ->
|
||||
json(conn, %{success: true})
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<div :if={alert.device.site}>
|
||||
<strong class="font-medium">Site:</strong>
|
||||
<.link
|
||||
navigate={~p"/sites/#{alert.device.site.id}"}
|
||||
|
|
|
|||
|
|
@ -30,28 +30,21 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
limit -> current_devices >= limit
|
||||
end
|
||||
|
||||
# Redirect to sites page if no sites exist
|
||||
if Enum.empty?(sites) do
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:info, t_equipment("Please create a site before adding a device."))
|
||||
|> push_navigate(to: ~p"/sites/new")}
|
||||
else
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:organization, organization)
|
||||
|> assign(:available_sites, sites)
|
||||
|> assign(:available_agents, all_agents)
|
||||
|> assign(:preselected_site_id, params["site_id"])
|
||||
|> assign(:snmp_test_result, nil)
|
||||
|> assign(:duplicate_device, nil)
|
||||
|> assign(:non_routable_ip_error, false)
|
||||
|> assign(:device_quota, %{
|
||||
current: current_devices,
|
||||
limit: device_limit,
|
||||
at_limit: is_at_limit
|
||||
})}
|
||||
end
|
||||
# Sites are now optional - devices can belong directly to organization
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:organization, organization)
|
||||
|> assign(:available_sites, sites)
|
||||
|> assign(:available_agents, all_agents)
|
||||
|> assign(:preselected_site_id, params["site_id"])
|
||||
|> assign(:snmp_test_result, nil)
|
||||
|> assign(:duplicate_device, nil)
|
||||
|> assign(:non_routable_ip_error, false)
|
||||
|> assign(:device_quota, %{
|
||||
current: current_devices,
|
||||
limit: device_limit,
|
||||
at_limit: is_at_limit
|
||||
})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -247,6 +240,11 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
@impl true
|
||||
def handle_event("save", %{"device" => device_params}, socket) do
|
||||
device_params = sanitize_device_params(device_params)
|
||||
|
||||
# Inject organization_id from current context
|
||||
organization_id = socket.assigns.organization.id
|
||||
device_params = Map.put(device_params, "organization_id", organization_id)
|
||||
|
||||
save_device(socket, socket.assigns.live_action, device_params)
|
||||
end
|
||||
|
||||
|
|
@ -272,38 +270,57 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
|
||||
@impl true
|
||||
def handle_event("test_snmp", _params, socket) do
|
||||
# Get current form data from the changeset
|
||||
changeset = socket.assigns.form.source
|
||||
form_data = Ecto.Changeset.apply_changes(changeset)
|
||||
{auth_password, priv_password} = resolve_snmpv3_passwords(changeset, form_data)
|
||||
|
||||
device_map = build_device_map(form_data, auth_password, priv_password)
|
||||
effective_agent_id = determine_effective_agent_id(device_map, socket.assigns)
|
||||
|
||||
handle_snmp_test(socket, device_map, effective_agent_id)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("trigger_discovery", _params, socket) do
|
||||
device = socket.assigns.device
|
||||
|
||||
if device.snmp_enabled do
|
||||
_ = enqueue_discovery(device.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t_equipment("Discovery started..."))
|
||||
|> push_navigate(to: ~p"/devices/#{device.id}")}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, t_equipment("SNMP is not enabled for this device"))}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_snmpv3_passwords(changeset, form_data) do
|
||||
changes = changeset.changes
|
||||
passwords_changed? = Map.has_key?(changes, :snmpv3_auth_password) or Map.has_key?(changes, :snmpv3_priv_password)
|
||||
|
||||
# Determine which credentials to use:
|
||||
# 1. If password fields were changed in form, use new plain text values
|
||||
# 2. If existing device and no changes, use decrypted database values
|
||||
# 3. If new device, use form values
|
||||
{auth_password, priv_password} =
|
||||
cond do
|
||||
# User changed passwords in form - use new plain text values
|
||||
Map.has_key?(changes, :snmpv3_auth_password) or
|
||||
Map.has_key?(changes, :snmpv3_priv_password) ->
|
||||
{Map.get(changes, :snmpv3_auth_password), Map.get(changes, :snmpv3_priv_password)}
|
||||
cond do
|
||||
passwords_changed? ->
|
||||
{Map.get(changes, :snmpv3_auth_password), Map.get(changes, :snmpv3_priv_password)}
|
||||
|
||||
# Existing device with no password changes - use database values
|
||||
form_data.id ->
|
||||
config = Devices.get_snmpv3_config(form_data.id)
|
||||
form_data.id ->
|
||||
get_existing_device_passwords(form_data.id)
|
||||
|
||||
case config do
|
||||
nil -> {nil, nil}
|
||||
config -> {config.auth_password, config.priv_password}
|
||||
end
|
||||
true ->
|
||||
{Map.get(changes, :snmpv3_auth_password), Map.get(changes, :snmpv3_priv_password)}
|
||||
end
|
||||
end
|
||||
|
||||
# New device - use form values
|
||||
true ->
|
||||
{Map.get(changes, :snmpv3_auth_password), Map.get(changes, :snmpv3_priv_password)}
|
||||
end
|
||||
defp get_existing_device_passwords(device_id) do
|
||||
case Devices.get_snmpv3_config(device_id) do
|
||||
nil -> {nil, nil}
|
||||
config -> {config.auth_password, config.priv_password}
|
||||
end
|
||||
end
|
||||
|
||||
# Convert to map for easier access
|
||||
device_map = %{
|
||||
defp build_device_map(form_data, auth_password, priv_password) do
|
||||
%{
|
||||
"agent_token_id" => Map.get(form_data, :agent_token_id),
|
||||
"site_id" => form_data.site_id,
|
||||
"ip_address" => form_data.ip_address,
|
||||
|
|
@ -317,55 +334,34 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
"snmpv3_priv_protocol" => form_data.snmpv3_priv_protocol,
|
||||
"snmpv3_priv_password" => priv_password
|
||||
}
|
||||
|
||||
# Determine effective agent (device -> site -> organization -> cloud)
|
||||
effective_agent_id = determine_effective_agent_id(device_map, socket.assigns)
|
||||
|
||||
if effective_agent_id do
|
||||
# Send test job to agent
|
||||
test_id = "test:#{Ecto.UUID.generate()}"
|
||||
|
||||
case send_credential_test_to_agent(effective_agent_id, device_map, test_id) do
|
||||
:ok ->
|
||||
# Subscribe to test result
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:snmp_test_result, %{testing: true})
|
||||
|> assign(:test_id, test_id)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
assign(socket, :snmp_test_result, %{
|
||||
success: false,
|
||||
message: "Failed to send test: #{reason}"
|
||||
})}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
assign(socket, :snmp_test_result, %{
|
||||
success: false,
|
||||
message: "No agent available for testing. Please assign an agent or configure organization defaults."
|
||||
})}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("trigger_discovery", _params, socket) do
|
||||
device = socket.assigns.device
|
||||
defp handle_snmp_test(socket, _device_map, nil) do
|
||||
{:noreply,
|
||||
assign(socket, :snmp_test_result, %{
|
||||
success: false,
|
||||
message: "No agent available for testing. Please assign an agent or configure organization defaults."
|
||||
})}
|
||||
end
|
||||
|
||||
if device.snmp_enabled do
|
||||
# Enqueue discovery job
|
||||
_ = enqueue_discovery(device.id)
|
||||
defp handle_snmp_test(socket, device_map, effective_agent_id) do
|
||||
test_id = "test:#{Ecto.UUID.generate()}"
|
||||
|
||||
# Navigate to device show page where live updates will appear
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t_equipment("Discovery started..."))
|
||||
|> push_navigate(to: ~p"/devices/#{device.id}")}
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, t_equipment("SNMP is not enabled for this device"))}
|
||||
case send_credential_test_to_agent(effective_agent_id, device_map, test_id) do
|
||||
:ok ->
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:snmp_test_result, %{testing: true})
|
||||
|> assign(:test_id, test_id)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
assign(socket, :snmp_test_result, %{
|
||||
success: false,
|
||||
message: "Failed to send test: #{reason}"
|
||||
})}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -588,32 +584,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
end
|
||||
|
||||
defp send_credential_test_to_agent(agent_token_id, device_map, test_id) do
|
||||
# Build SNMP device config from device map (string keys)
|
||||
# Convert port to integer (form params are strings)
|
||||
require Logger
|
||||
snmp_config = build_snmp_test_config(device_map)
|
||||
|
||||
port =
|
||||
case device_map["snmp_port"] do
|
||||
port when is_binary(port) -> String.to_integer(port)
|
||||
port when is_integer(port) -> port
|
||||
_ -> 161
|
||||
end
|
||||
|
||||
snmp_config = %{
|
||||
ip: device_map["ip_address"] || "",
|
||||
port: port,
|
||||
version: device_map["snmp_version"] || "2c",
|
||||
community: device_map["snmp_community"] || "",
|
||||
v3_security_level: device_map["snmpv3_security_level"] || "",
|
||||
v3_username: device_map["snmpv3_username"] || "",
|
||||
v3_auth_protocol: device_map["snmpv3_auth_protocol"] || "",
|
||||
v3_auth_password: device_map["snmpv3_auth_password"] || "",
|
||||
v3_priv_protocol: device_map["snmpv3_priv_protocol"] || "",
|
||||
v3_priv_password: device_map["snmpv3_priv_password"] || ""
|
||||
}
|
||||
|
||||
# Broadcast credential test request to agent via PubSub
|
||||
# The agent channel will pick this up and send the test job
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"agent:#{agent_token_id}:credential_test",
|
||||
|
|
@ -621,6 +593,27 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
)
|
||||
end
|
||||
|
||||
defp build_snmp_test_config(device_map) do
|
||||
%{
|
||||
ip: get_value(device_map, "ip_address", ""),
|
||||
port: normalize_port(device_map["snmp_port"]),
|
||||
version: get_value(device_map, "snmp_version", "2c"),
|
||||
community: get_value(device_map, "snmp_community", ""),
|
||||
v3_security_level: get_value(device_map, "snmpv3_security_level", ""),
|
||||
v3_username: get_value(device_map, "snmpv3_username", ""),
|
||||
v3_auth_protocol: get_value(device_map, "snmpv3_auth_protocol", ""),
|
||||
v3_auth_password: get_value(device_map, "snmpv3_auth_password", ""),
|
||||
v3_priv_protocol: get_value(device_map, "snmpv3_priv_protocol", ""),
|
||||
v3_priv_password: get_value(device_map, "snmpv3_priv_password", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp get_value(map, key, default), do: map[key] || default
|
||||
|
||||
defp normalize_port(port) when is_binary(port), do: String.to_integer(port)
|
||||
defp normalize_port(port) when is_integer(port), do: port
|
||||
defp normalize_port(_), do: 161
|
||||
|
||||
# Check if a non-routable IP is being used with cloud poller
|
||||
# Skip this check in dev mode or for specific users
|
||||
defp check_non_routable_ip_cloud_error(device_params, assigns) do
|
||||
|
|
|
|||
|
|
@ -165,15 +165,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div :if={@organization.use_sites} class="col-span-full">
|
||||
<.input
|
||||
field={@form[:site_id]}
|
||||
type="select"
|
||||
label="Site"
|
||||
required
|
||||
prompt="Select a site"
|
||||
label="Site (Optional)"
|
||||
prompt="No site (ungrouped)"
|
||||
options={Enum.map(@available_sites, &{&1.name, &1.id})}
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Optionally group this device into a site (office, datacenter, etc.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
|
|
|
|||
|
|
@ -230,7 +230,12 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
end)
|
||||
|> Enum.sort_by(
|
||||
fn {site, _devices, _stats} ->
|
||||
{site.display_order || 999_999, site.name}
|
||||
if site do
|
||||
{site.display_order || 999_999, site.name}
|
||||
else
|
||||
# Put devices without sites first (display_order 0)
|
||||
{0, ""}
|
||||
end
|
||||
end,
|
||||
:asc
|
||||
)
|
||||
|
|
|
|||
|
|
@ -224,12 +224,12 @@
|
|||
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
|
||||
"dark:border-white/10"
|
||||
]}
|
||||
data-site-id={site.id}
|
||||
data-site-id={if site, do: site.id, else: "no-site"}
|
||||
data-site-position={index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
draggable={if @reorder_mode && site, do: "true", else: "false"}
|
||||
>
|
||||
<td
|
||||
:if={@reorder_mode}
|
||||
:if={@reorder_mode && site}
|
||||
class="py-2 pl-4 pr-2 sm:pl-3 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<button
|
||||
|
|
@ -250,12 +250,18 @@
|
|||
>
|
||||
<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>
|
||||
<%= if site do %>
|
||||
<.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>
|
||||
<% else %>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
All Devices
|
||||
</span>
|
||||
<% end %>
|
||||
<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"}
|
||||
|
|
@ -274,7 +280,7 @@
|
|||
</span>
|
||||
</div>
|
||||
<p
|
||||
:if={site.location}
|
||||
:if={site && 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}
|
||||
|
|
@ -292,7 +298,7 @@
|
|||
)
|
||||
]}
|
||||
data-device-id={device.id}
|
||||
data-site-id={site.id}
|
||||
data-site-id={if site, do: site.id, else: "no-site"}
|
||||
data-device-position={device_index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<div class="mb-6 -mt-2">
|
||||
<!-- Breadcrumbs -->
|
||||
<nav class="flex mb-4" aria-label="Breadcrumb">
|
||||
<nav :if={@device.site} class="flex mb-4" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="inline-flex items-center">
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -178,6 +178,21 @@ defmodule ToweropsWeb.HelpLive.Index do
|
|||
Getting Started
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
patch={~p"/help?section=sites"}
|
||||
class={[
|
||||
"block px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
if @active_section == "sites" do
|
||||
"bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
else
|
||||
"text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
end
|
||||
]}
|
||||
>
|
||||
Sites
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
patch={~p"/help?section=cloud-pollers"}
|
||||
|
|
@ -434,6 +449,241 @@ defmodule ToweropsWeb.HelpLive.Index do
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% "sites" -> %>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Sites
|
||||
</h2>
|
||||
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Sites are an optional organizational feature that lets you group devices by physical or logical
|
||||
locations such as offices, datacenters, customer locations, or network zones. Sites are disabled
|
||||
by default to keep things simple for single-location deployments.
|
||||
</p>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
|
||||
When to Enable Sites
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Consider enabling sites if you:
|
||||
</p>
|
||||
|
||||
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">
|
||||
Manage multiple physical locations
|
||||
</strong>
|
||||
- Offices, datacenters, retail stores, etc.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">
|
||||
Want to group credentials by location
|
||||
</strong>
|
||||
- Different SNMP communities or credentials per site
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">
|
||||
Need location-based monitoring
|
||||
</strong>
|
||||
- Assign different remote pollers to different sites
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">
|
||||
Organize by customer or department
|
||||
</strong>
|
||||
- Logical groupings for multi-tenant or large networks
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
|
||||
When to Skip Sites
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
You can skip enabling sites if you:
|
||||
</p>
|
||||
|
||||
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">Have a single location</strong>
|
||||
- All devices in one office or datacenter
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">Want a simpler setup</strong>
|
||||
- Devices belong directly to your organization without extra grouping
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">
|
||||
Don't need location-based credentials
|
||||
</strong>
|
||||
- Organization-level credentials work for all devices
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex gap-3">
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
Note
|
||||
</h4>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
Sites are optional and disabled by default. You can start without sites and enable them
|
||||
later if your needs change. When sites are disabled, all devices belong directly to
|
||||
your organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
|
||||
How Sites Work
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
When sites are enabled, devices can be organized into sites, and credentials (SNMP communities,
|
||||
usernames, passwords) can be set at three levels:
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
1. Organization Level
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Default credentials that apply to all devices across all sites in your organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
2. Site Level (when sites enabled)
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Override organization defaults for all devices at a specific site. Useful when different
|
||||
locations have different network configurations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
3. Device Level
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Override organization or site credentials for a specific device. Useful for devices with
|
||||
unique configurations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
|
||||
Enabling Sites
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Step 1 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Navigate to Organization Settings
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Go to
|
||||
<.code>Settings</.code>
|
||||
→
|
||||
<.code>Organization</.code>
|
||||
and scroll to the "Site Organization" section.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Enable the "Use Sites" Toggle
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Check the box for "Use sites to organize devices" and save your settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Create Your First Site
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Navigate to
|
||||
<.code>Sites</.code>
|
||||
→
|
||||
<.code>Add Site</.code>
|
||||
to create your first site. Give it a descriptive name like "Main Office" or "Dallas Datacenter".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
|
||||
4
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Assign Devices to Sites
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
When creating or editing devices, you can now assign them to specific sites. The site
|
||||
selector will appear in the device form.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div class="flex gap-3">
|
||||
<.icon
|
||||
name="hero-exclamation-triangle"
|
||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-yellow-900 dark:text-yellow-300 mb-1">
|
||||
Disabling Sites
|
||||
</h4>
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-300">
|
||||
If you disable sites after enabling them, all devices will be removed from their sites
|
||||
and assigned directly to the organization. Site assignments will be lost, but devices
|
||||
will remain in your organization and continue to be monitored.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% "cloud-pollers" -> %>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
|
|
|
|||
|
|
@ -49,6 +49,50 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Use Sites Section -->
|
||||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||
Site Organization
|
||||
</h2>
|
||||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
Enable site organization to group devices into physical locations (offices, datacenters, etc.).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<.input
|
||||
field={@form[:use_sites]}
|
||||
type="checkbox"
|
||||
label="Use sites to organize devices"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
When enabled, you can organize devices into sites. When disabled, all devices belong directly to the organization.
|
||||
</p>
|
||||
|
||||
<%= if @organization.use_sites && @form[:use_sites].value == false do %>
|
||||
<div class="mt-4 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Warning: Disabling Sites
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>
|
||||
All devices will be removed from their current sites and assigned directly to the organization.
|
||||
Site assignments will be lost, but devices will remain in the organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SNMP Configuration Section -->
|
||||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
defmodule Towerops.Repo.Migrations.AddOrganizationIdToDevices do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# 1. Add nullable organization_id column
|
||||
alter table(:devices) do
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all)
|
||||
end
|
||||
|
||||
# 2. Backfill from existing site relationships
|
||||
execute """
|
||||
UPDATE devices d
|
||||
SET organization_id = s.organization_id
|
||||
FROM sites s
|
||||
WHERE d.site_id = s.id
|
||||
"""
|
||||
|
||||
# 3. Make organization_id NOT NULL (data is backfilled)
|
||||
alter table(:devices) do
|
||||
modify :organization_id, :binary_id, null: false
|
||||
end
|
||||
|
||||
# 4. Add indexes
|
||||
create index(:devices, [:organization_id])
|
||||
create index(:devices, [:organization_id, :display_order, :name])
|
||||
|
||||
# Note: We validate site belongs to same org in application code (changeset)
|
||||
# PostgreSQL doesn't support subqueries in CHECK constraints
|
||||
end
|
||||
|
||||
def down do
|
||||
drop index(:devices, [:organization_id, :display_order, :name])
|
||||
drop index(:devices, [:organization_id])
|
||||
|
||||
alter table(:devices) do
|
||||
remove :organization_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Towerops.Repo.Migrations.AddUseSitesToOrganizations do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Add use_sites boolean field (default false)
|
||||
alter table(:organizations) do
|
||||
add :use_sites, :boolean, default: false, null: false
|
||||
end
|
||||
|
||||
# Backfill: Orgs with 2+ sites OR 1 named site (not "Default") → use_sites = true
|
||||
# This detects existing multi-site setups
|
||||
execute """
|
||||
UPDATE organizations
|
||||
SET use_sites = true
|
||||
WHERE id IN (
|
||||
SELECT organization_id
|
||||
FROM sites
|
||||
GROUP BY organization_id
|
||||
HAVING COUNT(*) > 1 OR (COUNT(*) = 1 AND MAX(name) != 'Default')
|
||||
)
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:organizations) do
|
||||
remove :use_sites
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Towerops.Repo.Migrations.MakeSiteIdNullableOnDevices do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:devices) do
|
||||
modify :site_id, :binary_id, null: true, from: {:binary_id, null: false}
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
# Cannot rollback if NULL site_id values exist
|
||||
execute """
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM devices WHERE site_id IS NULL) THEN
|
||||
RAISE EXCEPTION 'Cannot revert: devices with NULL site_id exist';
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
|
||||
alter table(:devices) do
|
||||
modify :site_id, :binary_id, null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
defmodule Towerops.Repo.Migrations.BackfillDeviceCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Backfill SNMP v2c credentials from site → organization hierarchy
|
||||
execute """
|
||||
UPDATE devices d
|
||||
SET
|
||||
snmp_community = COALESCE(
|
||||
NULLIF(d.snmp_community, ''),
|
||||
s.snmp_community,
|
||||
o.snmp_community
|
||||
),
|
||||
snmp_community_source = CASE
|
||||
WHEN d.snmp_community IS NOT NULL AND d.snmp_community != '' THEN 'device'
|
||||
WHEN s.snmp_community IS NOT NULL THEN 'site'
|
||||
ELSE 'organization'
|
||||
END,
|
||||
snmp_version = COALESCE(
|
||||
NULLIF(d.snmp_version, ''),
|
||||
s.snmp_version,
|
||||
o.snmp_version,
|
||||
'2c'
|
||||
),
|
||||
snmp_port = COALESCE(
|
||||
d.snmp_port,
|
||||
s.snmp_port,
|
||||
o.snmp_port,
|
||||
161
|
||||
)
|
||||
FROM sites s
|
||||
INNER JOIN organizations o ON s.organization_id = o.id
|
||||
WHERE d.site_id = s.id
|
||||
"""
|
||||
|
||||
# Backfill SNMPv3 credentials from site → organization hierarchy
|
||||
execute """
|
||||
UPDATE devices d
|
||||
SET
|
||||
snmpv3_username = COALESCE(
|
||||
NULLIF(d.snmpv3_username, ''),
|
||||
s.snmpv3_username,
|
||||
o.snmpv3_username
|
||||
),
|
||||
snmpv3_security_level = COALESCE(
|
||||
NULLIF(d.snmpv3_security_level, ''),
|
||||
s.snmpv3_security_level,
|
||||
o.snmpv3_security_level
|
||||
),
|
||||
snmpv3_auth_protocol = COALESCE(
|
||||
NULLIF(d.snmpv3_auth_protocol, ''),
|
||||
s.snmpv3_auth_protocol,
|
||||
o.snmpv3_auth_protocol,
|
||||
'SHA-256'
|
||||
),
|
||||
snmpv3_auth_password = COALESCE(
|
||||
d.snmpv3_auth_password,
|
||||
s.snmpv3_auth_password,
|
||||
o.snmpv3_auth_password
|
||||
),
|
||||
snmpv3_priv_protocol = COALESCE(
|
||||
NULLIF(d.snmpv3_priv_protocol, ''),
|
||||
s.snmpv3_priv_protocol,
|
||||
o.snmpv3_priv_protocol,
|
||||
'AES'
|
||||
),
|
||||
snmpv3_priv_password = COALESCE(
|
||||
d.snmpv3_priv_password,
|
||||
s.snmpv3_priv_password,
|
||||
o.snmpv3_priv_password
|
||||
),
|
||||
snmpv3_credential_source = CASE
|
||||
WHEN d.snmpv3_username IS NOT NULL AND d.snmpv3_username != '' THEN 'device'
|
||||
WHEN s.snmpv3_username IS NOT NULL THEN 'site'
|
||||
ELSE 'organization'
|
||||
END
|
||||
FROM sites s
|
||||
INNER JOIN organizations o ON s.organization_id = o.id
|
||||
WHERE d.site_id = s.id
|
||||
AND d.snmp_version = '3'
|
||||
"""
|
||||
|
||||
# Backfill MikroTik credentials from site → organization hierarchy
|
||||
execute """
|
||||
UPDATE devices d
|
||||
SET
|
||||
mikrotik_username = COALESCE(
|
||||
d.mikrotik_username,
|
||||
s.mikrotik_username,
|
||||
o.mikrotik_username
|
||||
),
|
||||
mikrotik_password = COALESCE(
|
||||
d.mikrotik_password,
|
||||
s.mikrotik_password,
|
||||
o.mikrotik_password
|
||||
),
|
||||
mikrotik_port = COALESCE(
|
||||
d.mikrotik_port,
|
||||
s.mikrotik_port,
|
||||
o.mikrotik_port,
|
||||
8729
|
||||
),
|
||||
mikrotik_ssh_port = COALESCE(
|
||||
d.mikrotik_ssh_port,
|
||||
s.mikrotik_ssh_port,
|
||||
o.mikrotik_ssh_port,
|
||||
22
|
||||
),
|
||||
mikrotik_use_ssl = COALESCE(
|
||||
d.mikrotik_use_ssl,
|
||||
s.mikrotik_use_ssl,
|
||||
o.mikrotik_use_ssl,
|
||||
true
|
||||
),
|
||||
mikrotik_enabled = COALESCE(
|
||||
d.mikrotik_enabled,
|
||||
s.mikrotik_enabled,
|
||||
o.mikrotik_enabled,
|
||||
false
|
||||
),
|
||||
mikrotik_credential_source = CASE
|
||||
WHEN d.mikrotik_username IS NOT NULL THEN 'device'
|
||||
WHEN s.mikrotik_username IS NOT NULL THEN 'site'
|
||||
ELSE 'organization'
|
||||
END
|
||||
FROM sites s
|
||||
INNER JOIN organizations o ON s.organization_id = o.id
|
||||
WHERE d.site_id = s.id
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
# This migration is not reversible - we can't "un-denormalize" the data
|
||||
# The credentials are now explicitly stored on devices
|
||||
:ok
|
||||
end
|
||||
end
|
||||
46
test/support/fixtures/devices_fixtures.ex
Normal file
46
test/support/fixtures/devices_fixtures.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Towerops.DevicesFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating devices with all required fields.
|
||||
"""
|
||||
|
||||
alias Towerops.Devices
|
||||
|
||||
@doc """
|
||||
Generate a device with all required fields.
|
||||
"""
|
||||
def device_fixture(attrs \\ %{}) do
|
||||
# Ensure organization and site exist
|
||||
organization =
|
||||
attrs[:organization] || Map.get(attrs, "organization") ||
|
||||
Towerops.OrganizationsFixtures.organization_fixture()
|
||||
|
||||
site =
|
||||
attrs[:site] || Map.get(attrs, "site") ||
|
||||
Towerops.OrganizationsFixtures.site_fixture(%{organization_id: organization.id})
|
||||
|
||||
# Default device attributes
|
||||
default_attrs = %{
|
||||
name: "Test Device #{System.unique_integer([:positive])}",
|
||||
ip_address: "192.168.1.#{:rand.uniform(254)}",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
}
|
||||
|
||||
# Merge with provided attrs, converting string keys if needed
|
||||
merged_attrs =
|
||||
Map.merge(
|
||||
default_attrs,
|
||||
attrs |> Map.drop([:organization, :site, "organization", "site"]) |> Map.new(fn {k, v} -> {to_atom_key(k), v} end)
|
||||
)
|
||||
|
||||
{:ok, device} = Devices.create_device(merged_attrs, bypass_limits: true)
|
||||
device
|
||||
end
|
||||
|
||||
defp to_atom_key(key) when is_atom(key), do: key
|
||||
defp to_atom_key(key) when is_binary(key), do: String.to_existing_atom(key)
|
||||
end
|
||||
|
|
@ -26,7 +26,8 @@ defmodule Towerops.Snmp.StateSensorTest do
|
|||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
snmp_device =
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
|
|||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device: device, org: organization}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue