netbox-pdu-control 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_pdu_control/__init__.py +56 -0
- netbox_pdu_control/api/__init__.py +6 -0
- netbox_pdu_control/api/serializers.py +101 -0
- netbox_pdu_control/api/urls.py +12 -0
- netbox_pdu_control/api/views.py +23 -0
- netbox_pdu_control/backends/__init__.py +25 -0
- netbox_pdu_control/backends/base.py +139 -0
- netbox_pdu_control/backends/raritan.py +709 -0
- netbox_pdu_control/backends/unifi.py +351 -0
- netbox_pdu_control/choices.py +55 -0
- netbox_pdu_control/filtersets.py +58 -0
- netbox_pdu_control/forms.py +142 -0
- netbox_pdu_control/graphql/__init__.py +3 -0
- netbox_pdu_control/graphql/enums.py +6 -0
- netbox_pdu_control/graphql/filters.py +37 -0
- netbox_pdu_control/graphql/schema.py +14 -0
- netbox_pdu_control/graphql/types.py +28 -0
- netbox_pdu_control/jobs.py +370 -0
- netbox_pdu_control/migrations/0001_initial.py +179 -0
- netbox_pdu_control/migrations/0002_managedpdu_last_metrics_fetched.py +17 -0
- netbox_pdu_control/migrations/0003_pduinlet_poleline_l1_current_a_and_more.py +100 -0
- netbox_pdu_control/migrations/0004_pduoutlet_apparent_power_va.py +20 -0
- netbox_pdu_control/migrations/0005_managedpdu_metrics_status.py +24 -0
- netbox_pdu_control/migrations/0006_managedpdu_pdu_name_alter_managedpdu_verify_ssl.py +31 -0
- netbox_pdu_control/migrations/0007_managedpdu_sync_metrics_enabled.py +28 -0
- netbox_pdu_control/migrations/__init__.py +7 -0
- netbox_pdu_control/models.py +619 -0
- netbox_pdu_control/navigation.py +60 -0
- netbox_pdu_control/search.py +23 -0
- netbox_pdu_control/tables.py +254 -0
- netbox_pdu_control/template_content.py +29 -0
- netbox_pdu_control/templates/netbox_pdu_control/inc/device_pdu_button.html +3 -0
- netbox_pdu_control/templates/netbox_pdu_control/inc/device_pdu_outlets.html +11 -0
- netbox_pdu_control/templates/netbox_pdu_control/managedpdu.html +307 -0
- netbox_pdu_control/templates/netbox_pdu_control/pduinlet.html +228 -0
- netbox_pdu_control/templates/netbox_pdu_control/pduoutlet.html +166 -0
- netbox_pdu_control/testing/__init__.py +421 -0
- netbox_pdu_control/testing/utils.py +339 -0
- netbox_pdu_control/tests/__init__.py +6 -0
- netbox_pdu_control/tests/test_api.py +117 -0
- netbox_pdu_control/tests/test_backends_raritan.py +1121 -0
- netbox_pdu_control/tests/test_backends_unifi.py +793 -0
- netbox_pdu_control/tests/test_factory.py +96 -0
- netbox_pdu_control/tests/test_graphql.py +94 -0
- netbox_pdu_control/tests/test_models.py +184 -0
- netbox_pdu_control/tests/test_views.py +470 -0
- netbox_pdu_control/urls.py +84 -0
- netbox_pdu_control/views.py +518 -0
- netbox_pdu_control-0.2.0.dist-info/METADATA +524 -0
- netbox_pdu_control-0.2.0.dist-info/RECORD +53 -0
- netbox_pdu_control-0.2.0.dist-info/WHEEL +5 -0
- netbox_pdu_control-0.2.0.dist-info/licenses/LICENSE +200 -0
- netbox_pdu_control-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NetBox PDU Control
|
|
3
|
+
|
|
4
|
+
Plugin configuration for NetBox PDU Control.
|
|
5
|
+
|
|
6
|
+
For a complete list of PluginConfig attributes, see:
|
|
7
|
+
https://docs.netbox.dev/en/stable/plugins/development/#pluginconfig-attributes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "tak-55"
|
|
11
|
+
__email__ = "8895617+tak-55@users.noreply.github.com"
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from netbox.plugins import PluginConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PduConfig(PluginConfig):
|
|
19
|
+
name = "netbox_pdu_control"
|
|
20
|
+
verbose_name = "NetBox PDU Control"
|
|
21
|
+
description = "NetBox plugin for Managed PDUs (Raritan / Ubiquiti)."
|
|
22
|
+
author = "tak-55"
|
|
23
|
+
author_email = "8895617+tak-55@users.noreply.github.com"
|
|
24
|
+
version = __version__
|
|
25
|
+
base_url = "pdu"
|
|
26
|
+
min_version = "4.5.0"
|
|
27
|
+
max_version = "4.5.99"
|
|
28
|
+
graphql_schema = "graphql.schema"
|
|
29
|
+
queues = ["default"]
|
|
30
|
+
|
|
31
|
+
def ready(self):
|
|
32
|
+
super().ready()
|
|
33
|
+
from . import jobs # noqa: F401 — registers @system_job if metrics_poll_interval is set
|
|
34
|
+
|
|
35
|
+
self._cleanup_stuck_jobs()
|
|
36
|
+
|
|
37
|
+
def _cleanup_stuck_jobs(self):
|
|
38
|
+
"""Mark any stuck 'running' PDU jobs as errored on startup (e.g. after container restart or laptop sleep)."""
|
|
39
|
+
try:
|
|
40
|
+
from core.choices import JobStatusChoices
|
|
41
|
+
from core.models import Job
|
|
42
|
+
|
|
43
|
+
stuck = Job.objects.filter(
|
|
44
|
+
name__in=["PDU Get Metrics", "PDU Sync"],
|
|
45
|
+
status=JobStatusChoices.STATUS_RUNNING,
|
|
46
|
+
)
|
|
47
|
+
count = stuck.update(status=JobStatusChoices.STATUS_ERRORED)
|
|
48
|
+
if count:
|
|
49
|
+
import logging
|
|
50
|
+
|
|
51
|
+
logging.getLogger(__name__).warning("Cleaned up %d stuck PDU job(s) on startup", count)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass # Do not block startup if cleanup fails
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
config = PduConfig
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from dcim.api.serializers import DeviceSerializer
|
|
2
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from ..models import ManagedPDU, PDUInlet, PDUOutlet
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ManagedPDUSerializer(NetBoxModelSerializer):
|
|
9
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_pdu_control-api:managedpdu-detail")
|
|
10
|
+
device = DeviceSerializer(nested=True)
|
|
11
|
+
outlet_count = serializers.IntegerField(read_only=True)
|
|
12
|
+
api_password = serializers.CharField(write_only=True)
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
model = ManagedPDU
|
|
16
|
+
fields = (
|
|
17
|
+
"id",
|
|
18
|
+
"url",
|
|
19
|
+
"display",
|
|
20
|
+
"device",
|
|
21
|
+
"vendor",
|
|
22
|
+
"api_url",
|
|
23
|
+
"api_username",
|
|
24
|
+
"api_password",
|
|
25
|
+
"verify_ssl",
|
|
26
|
+
"sync_enabled",
|
|
27
|
+
"metrics_enabled",
|
|
28
|
+
"sync_status",
|
|
29
|
+
"last_synced",
|
|
30
|
+
"outlet_count",
|
|
31
|
+
"comments",
|
|
32
|
+
"tags",
|
|
33
|
+
"custom_fields",
|
|
34
|
+
"created",
|
|
35
|
+
"last_updated",
|
|
36
|
+
)
|
|
37
|
+
brief_fields = ("id", "url", "display", "device")
|
|
38
|
+
# api_password is write-only — accepted on POST/PATCH but never returned in responses
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PDUOutletSerializer(NetBoxModelSerializer):
|
|
42
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_pdu_control-api:pduoutlet-detail")
|
|
43
|
+
managed_pdu = ManagedPDUSerializer(nested=True)
|
|
44
|
+
connected_device = DeviceSerializer(nested=True, required=False, allow_null=True)
|
|
45
|
+
|
|
46
|
+
class Meta:
|
|
47
|
+
model = PDUOutlet
|
|
48
|
+
fields = (
|
|
49
|
+
"id",
|
|
50
|
+
"url",
|
|
51
|
+
"display",
|
|
52
|
+
"managed_pdu",
|
|
53
|
+
"outlet_number",
|
|
54
|
+
"outlet_name",
|
|
55
|
+
"connected_device",
|
|
56
|
+
"status",
|
|
57
|
+
"current_a",
|
|
58
|
+
"power_w",
|
|
59
|
+
"voltage_v",
|
|
60
|
+
"power_factor",
|
|
61
|
+
"energy_wh",
|
|
62
|
+
"energy_reset_at",
|
|
63
|
+
"last_updated_from_pdu",
|
|
64
|
+
"comments",
|
|
65
|
+
"tags",
|
|
66
|
+
"custom_fields",
|
|
67
|
+
"created",
|
|
68
|
+
"last_updated",
|
|
69
|
+
)
|
|
70
|
+
brief_fields = ("id", "url", "display", "outlet_number", "outlet_name")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PDUInletSerializer(NetBoxModelSerializer):
|
|
74
|
+
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_pdu_control-api:pduinlet-detail")
|
|
75
|
+
managed_pdu = ManagedPDUSerializer(nested=True)
|
|
76
|
+
|
|
77
|
+
class Meta:
|
|
78
|
+
model = PDUInlet
|
|
79
|
+
fields = (
|
|
80
|
+
"id",
|
|
81
|
+
"url",
|
|
82
|
+
"display",
|
|
83
|
+
"managed_pdu",
|
|
84
|
+
"inlet_number",
|
|
85
|
+
"inlet_name",
|
|
86
|
+
"current_a",
|
|
87
|
+
"power_w",
|
|
88
|
+
"apparent_power_va",
|
|
89
|
+
"voltage_v",
|
|
90
|
+
"power_factor",
|
|
91
|
+
"frequency_hz",
|
|
92
|
+
"energy_wh",
|
|
93
|
+
"energy_reset_at",
|
|
94
|
+
"last_updated_from_pdu",
|
|
95
|
+
"comments",
|
|
96
|
+
"tags",
|
|
97
|
+
"custom_fields",
|
|
98
|
+
"created",
|
|
99
|
+
"last_updated",
|
|
100
|
+
)
|
|
101
|
+
brief_fields = ("id", "url", "display", "inlet_number", "inlet_name")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from netbox.api.routers import NetBoxRouter
|
|
2
|
+
|
|
3
|
+
from . import views
|
|
4
|
+
|
|
5
|
+
app_name = "netbox_pdu_control"
|
|
6
|
+
|
|
7
|
+
router = NetBoxRouter()
|
|
8
|
+
router.register("managed-pdus", views.ManagedPDUViewSet)
|
|
9
|
+
router.register("outlets", views.PDUOutletViewSet)
|
|
10
|
+
router.register("inlets", views.PDUInletViewSet)
|
|
11
|
+
|
|
12
|
+
urlpatterns = router.urls
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from django.db.models import Count
|
|
2
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
|
3
|
+
|
|
4
|
+
from .. import filtersets, models
|
|
5
|
+
from .serializers import ManagedPDUSerializer, PDUInletSerializer, PDUOutletSerializer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ManagedPDUViewSet(NetBoxModelViewSet):
|
|
9
|
+
queryset = models.ManagedPDU.objects.annotate(outlet_count=Count("outlets")).prefetch_related("tags")
|
|
10
|
+
serializer_class = ManagedPDUSerializer
|
|
11
|
+
filterset_class = filtersets.ManagedPDUFilterSet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PDUOutletViewSet(NetBoxModelViewSet):
|
|
15
|
+
queryset = models.PDUOutlet.objects.select_related("managed_pdu", "connected_device").prefetch_related("tags")
|
|
16
|
+
serializer_class = PDUOutletSerializer
|
|
17
|
+
filterset_class = filtersets.PDUOutletFilterSet
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PDUInletViewSet(NetBoxModelViewSet):
|
|
21
|
+
queryset = models.PDUInlet.objects.select_related("managed_pdu").prefetch_related("tags")
|
|
22
|
+
serializer_class = PDUInletSerializer
|
|
23
|
+
filterset_class = filtersets.PDUInletFilterSet
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .base import BasePDUClient, PDUClientError
|
|
2
|
+
from .raritan import RaritanPDUClient
|
|
3
|
+
from .unifi import UniFiPDUClient
|
|
4
|
+
|
|
5
|
+
_VENDOR_BACKENDS = {
|
|
6
|
+
"raritan": RaritanPDUClient,
|
|
7
|
+
"ubiquiti": UniFiPDUClient,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_pdu_client(managed_pdu) -> BasePDUClient:
|
|
12
|
+
"""
|
|
13
|
+
Return the appropriate PDU client for the given ManagedPDU instance.
|
|
14
|
+
Raises PDUClientError if no backend is registered for the vendor.
|
|
15
|
+
"""
|
|
16
|
+
backend_class = _VENDOR_BACKENDS.get(managed_pdu.vendor)
|
|
17
|
+
if not backend_class:
|
|
18
|
+
raise PDUClientError(f"No backend registered for vendor: {managed_pdu.vendor!r}")
|
|
19
|
+
return backend_class(
|
|
20
|
+
base_url=managed_pdu.api_url,
|
|
21
|
+
username=managed_pdu.api_username,
|
|
22
|
+
password=managed_pdu.api_password,
|
|
23
|
+
verify_ssl=managed_pdu.verify_ssl,
|
|
24
|
+
managed_pdu=managed_pdu,
|
|
25
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base interface for PDU vendor backends.
|
|
3
|
+
|
|
4
|
+
To add a new vendor:
|
|
5
|
+
1. Create backends/<vendor>.py implementing BasePDUClient
|
|
6
|
+
2. Register it in backends/__init__._VENDOR_BACKENDS
|
|
7
|
+
3. Add the vendor to choices.VendorChoices
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PDUClientError(Exception):
|
|
14
|
+
"""Raised when communication with a PDU fails."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BasePDUClient(ABC):
|
|
20
|
+
"""
|
|
21
|
+
Abstract base class for PDU vendor backends.
|
|
22
|
+
|
|
23
|
+
All backends must implement these methods so that views and sync logic
|
|
24
|
+
can work with any vendor without modification.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, base_url: str, username: str, password: str, verify_ssl: bool = True, **kwargs):
|
|
28
|
+
self.base_url = base_url
|
|
29
|
+
self.username = username
|
|
30
|
+
self.password = password
|
|
31
|
+
self.verify_ssl = verify_ssl
|
|
32
|
+
|
|
33
|
+
#: Set to True in backends that implement get_all_metrics_prometheus().
|
|
34
|
+
supports_prometheus_metrics: bool = False
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def get_pdu_info(self) -> dict:
|
|
38
|
+
"""
|
|
39
|
+
Return basic PDU hardware information.
|
|
40
|
+
|
|
41
|
+
Must contain:
|
|
42
|
+
model (str) - PDU model name
|
|
43
|
+
serial_number (str) - Serial number
|
|
44
|
+
firmware_version (str) - Firmware version string
|
|
45
|
+
pdu_name (str) - Device name configured on the PDU hardware (may be empty)
|
|
46
|
+
network_interfaces (list[dict]) - each dict:
|
|
47
|
+
name (str) - interface name (e.g. 'ETH1')
|
|
48
|
+
mac_address (str) - MAC address
|
|
49
|
+
ip_address (str) - IP address (may be empty)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def get_all_outlet_data(self) -> list[dict]:
|
|
54
|
+
"""
|
|
55
|
+
Return status and sensor data for all outlets.
|
|
56
|
+
|
|
57
|
+
Each dict must contain:
|
|
58
|
+
outlet_number (int) - 1-indexed
|
|
59
|
+
name (str) - outlet label (may be empty)
|
|
60
|
+
switchingState(str) - 'on', 'off', or 'unknown'
|
|
61
|
+
current_a (float|None)
|
|
62
|
+
power_w (float|None)
|
|
63
|
+
voltage_v (float|None)
|
|
64
|
+
power_factor (float|None)
|
|
65
|
+
energy_wh (float|None)
|
|
66
|
+
energy_reset_epoch (float|None)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def get_single_outlet_data(self, outlet_index: int) -> dict:
|
|
71
|
+
"""Return data for one outlet (0-indexed). Same dict format as get_all_outlet_data."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def get_all_inlet_data(self) -> list[dict]:
|
|
75
|
+
"""
|
|
76
|
+
Return power data for all inlets.
|
|
77
|
+
|
|
78
|
+
Each dict must contain:
|
|
79
|
+
inlet_number (int)
|
|
80
|
+
name (str)
|
|
81
|
+
current_a (float|None)
|
|
82
|
+
power_w (float|None)
|
|
83
|
+
apparent_power_va (float|None)
|
|
84
|
+
voltage_v (float|None)
|
|
85
|
+
power_factor (float|None)
|
|
86
|
+
frequency_hz (float|None)
|
|
87
|
+
energy_wh (float|None)
|
|
88
|
+
energy_reset_epoch(float|None)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def get_single_inlet_data(self, inlet_index: int) -> dict:
|
|
93
|
+
"""Return data for one inlet (0-indexed). Same dict format as get_all_inlet_data."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def set_outlet_power_state(self, outlet_index: int, state: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Change the power state of an outlet.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
outlet_index: 0-indexed outlet number
|
|
102
|
+
state: 'on', 'off', or 'cycle'
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def get_outlet_power_state_by_index(self, outlet_index: int) -> str:
|
|
107
|
+
"""Return the current power state of an outlet: 'on', 'off', or 'unknown'."""
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def set_outlet_name(self, outlet_index: int, name: str) -> None:
|
|
111
|
+
"""Push a display name to the PDU outlet (0-indexed)."""
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def set_inlet_name(self, inlet_index: int, name: str) -> None:
|
|
115
|
+
"""Push a display name to the PDU inlet (0-indexed)."""
|
|
116
|
+
|
|
117
|
+
def get_outlet_thresholds(self, outlet_index: int) -> list[dict]:
|
|
118
|
+
"""
|
|
119
|
+
Return threshold data for one outlet (0-indexed).
|
|
120
|
+
|
|
121
|
+
Each dict must contain:
|
|
122
|
+
label (str) - sensor label (e.g. 'Current')
|
|
123
|
+
unit (str) - unit string (e.g. 'A')
|
|
124
|
+
lower_critical (float|None)
|
|
125
|
+
lower_warning (float|None)
|
|
126
|
+
upper_warning (float|None)
|
|
127
|
+
upper_critical (float|None)
|
|
128
|
+
|
|
129
|
+
Default implementation returns [] (vendor does not support thresholds).
|
|
130
|
+
Override in backends that support threshold retrieval.
|
|
131
|
+
"""
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
def get_inlet_thresholds(self, inlet_index: int) -> list[dict]:
|
|
135
|
+
"""
|
|
136
|
+
Return threshold data for one inlet (0-indexed). Same dict format as get_outlet_thresholds.
|
|
137
|
+
Default implementation returns [].
|
|
138
|
+
"""
|
|
139
|
+
return []
|