mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-08 01:49:31 +01:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d520d78380 | ||
|
|
46ae4b307c | ||
|
|
1728d81677 | ||
|
|
fc5495eb3b | ||
|
|
004f5c448e | ||
|
|
995447ae0b | ||
|
|
76baa6fd2d | ||
|
|
2e27389cda | ||
|
|
48d607fb96 | ||
|
|
b8b173674f | ||
|
|
d6920eceb1 | ||
|
|
fbbdb3807c | ||
|
|
a1953bab8b | ||
|
|
aa000bf26d | ||
|
|
4ed3d54566 | ||
|
|
522a0c20e7 | ||
|
|
b02c54ce52 | ||
|
|
43e030f1db | ||
|
|
945ca31460 | ||
|
|
fc3cb72ab8 | ||
|
|
4a04af145b | ||
|
|
e7615cf32f | ||
|
|
8b357a311d | ||
|
|
fdfc32899d | ||
|
|
03fa000d8d | ||
|
|
ec667eeed0 | ||
|
|
6c415794cd | ||
|
|
9acd0e99f9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,5 +2,5 @@
|
||||
configuration.py
|
||||
.idea
|
||||
/*.sh
|
||||
!upgrade.sh
|
||||
fabfile.py
|
||||
|
||||
|
||||
@@ -49,8 +49,14 @@ Even if it's not quite right for NetBox, we may be able to point you to a tool b
|
||||
* A rough description of any changes necessary to the database schema (if applicable)
|
||||
* Any third-party libraries or other resources which would be involved
|
||||
|
||||
# Submitting Pull Requests
|
||||
## Submitting Pull Requests
|
||||
|
||||
When submitting a pull request, please be sure to work off of branch `develop`, rather than branch `master`.
|
||||
* When submitting a pull request, please be sure to work off of branch `develop`, rather than branch `master`.
|
||||
In NetBox, the `develop` branch is used for ongoing development, while `master` is used for tagging new
|
||||
stable releases.
|
||||
|
||||
* All code submissions should meet the following criteria (CI will enforce these checks):
|
||||
|
||||
* Python syntax is valid
|
||||
* All tests pass when run with `./manage.py test netbox/`
|
||||
* PEP 8 compliance is enforced, with the exception that lines may be greater than 80 characters in length
|
||||
|
||||
@@ -25,6 +25,8 @@ Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net*
|
||||
|
||||
Please see docs/getting-started.md for instructions on installing NetBox.
|
||||
|
||||
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
|
||||
|
||||
# Components
|
||||
|
||||
NetBox understands all of the physical and logical building blocks that comprise network infrastructure, and the manners in which they are all related.
|
||||
|
||||
@@ -48,24 +48,37 @@ You can verify that authentication works using the following command:
|
||||
|
||||
# NetBox
|
||||
|
||||
## Dependencies
|
||||
## Installation
|
||||
|
||||
NetBox requires following dependencies:
|
||||
|
||||
* python2.7
|
||||
* python-dev
|
||||
* git
|
||||
* python-pip
|
||||
* libxml2-dev
|
||||
* libxslt1-dev
|
||||
* libffi-dev
|
||||
* graphviz*
|
||||
* graphviz
|
||||
|
||||
```
|
||||
# apt-get install python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz
|
||||
```
|
||||
|
||||
*graphviz is needed to render topology maps. If you have no need for this feature, graphviz is not required.
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
|
||||
## Clone the Git Repository
|
||||
### Option A: Download a Release
|
||||
|
||||
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
|
||||
|
||||
```
|
||||
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -s netbox-1.0.4/ netbox
|
||||
# cd /opt/netbox/
|
||||
```
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
|
||||
|
||||
@@ -74,6 +87,12 @@ Create the base directory for the NetBox installation. For this guide, we'll use
|
||||
# cd /opt/netbox/
|
||||
```
|
||||
|
||||
If `git` is not already installed, install it:
|
||||
|
||||
```
|
||||
# sudo apt-get install git
|
||||
```
|
||||
|
||||
Next, clone the NetBox git repository into the current directory:
|
||||
|
||||
```
|
||||
@@ -87,6 +106,8 @@ Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
```
|
||||
|
||||
### Install Python Packages
|
||||
|
||||
Install the necessary Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the required dependencies.)
|
||||
|
||||
```
|
||||
@@ -264,38 +285,38 @@ Restart the nginx service to use the new configuration.
|
||||
```
|
||||
## Apache Configuration
|
||||
|
||||
If you're feeling adventurous, or you already have Apache installed and can't run a dual-stack on your server, the following configuration should work for Apache:
|
||||
The following configuration should work for Apache. Be sure to modify the `ServerName` appropriately.
|
||||
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ProxyPreserveHost On
|
||||
|
||||
|
||||
ServerName netbox.example.com
|
||||
|
||||
Alias /static/ /opt/netbox/netbox/static
|
||||
Alias /static /opt/netbox/netbox/static
|
||||
|
||||
<Directory /opt/netbox/netbox/static>
|
||||
Options Indexes FollowSymLinks MultiViews
|
||||
AllowOverride None
|
||||
Order allow,deny
|
||||
Allow from all
|
||||
# Uncomment the line below if running Apache 2.4
|
||||
#Require all granted
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Location /static>
|
||||
ProxyPass !
|
||||
</Location>
|
||||
|
||||
ProxyPass / http://127.0.0.1:8001
|
||||
ProxyPassReverse / http://127.0.0.1:8001
|
||||
ProxyPass / http://127.0.0.1:8001/
|
||||
ProxyPassReverse / http://127.0.0.1:8001/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf` and reload Apache:
|
||||
Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache:
|
||||
|
||||
```
|
||||
# a2ensite netbox; service apache2 restart
|
||||
# a2enmod proxy
|
||||
# a2enmod proxy_http
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
||||
## gunicorn Configuration
|
||||
@@ -329,4 +350,103 @@ Finally, restart the supervisor service to detect and run the gunicorn service:
|
||||
|
||||
At this point, you should be able to connect to the nginx HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
|
||||
|
||||
Please keep in mind that the configurations provided here are a bare minimum to get NetBox up and running. You will almost certainly want to make some changes to better suit your production environment.
|
||||
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You will almost certainly want to make some changes to better suit your production environment.
|
||||
|
||||
## Let's Encrypt SSL + nginx
|
||||
|
||||
To add SSL support to the installation we'll start by installing the arbitrary precision calculator language.
|
||||
|
||||
```
|
||||
# sudo apt-get -y bc
|
||||
```
|
||||
|
||||
Next we'll clone Let’s Encrypt in to /opt
|
||||
|
||||
```
|
||||
# sudo git clone https://github.com/letsencrypt/letsencrypt /opt/letsencrypt
|
||||
```
|
||||
|
||||
To ensure Let's Encrypt can publicly access the directory it needs for certificate validation you'll need to edit `/etc/nginx/sites-available/netbox` and add:
|
||||
|
||||
```
|
||||
location /.well-known/ {
|
||||
alias /opt/netbox/netbox/.well-known/;
|
||||
allow all;
|
||||
}
|
||||
```
|
||||
|
||||
Then restart nginix:
|
||||
|
||||
```
|
||||
# sudo services nginx restart
|
||||
```
|
||||
|
||||
To create the certificate use the following commands ensuring to change `netbox.example.com` to the domain name of the server:
|
||||
|
||||
```
|
||||
# cd /opt/letsencrypt
|
||||
# ./letsencrypt-auto certonly -a webroot --webroot-path=/opt/netbox/netbox/ -d netbox.example.com
|
||||
```
|
||||
|
||||
If you wish to add support for the `www` prefix you'd use:
|
||||
|
||||
```
|
||||
# cd /opt/letsencrypt
|
||||
# ./letsencrypt-auto certonly -a webroot --webroot-path=/opt/netbox/netbox/ -d netbox.example.com -d www.netbox.example.com
|
||||
```
|
||||
|
||||
Make sure you have DNS records setup for the hostnames you use and that they resolve back the netbox server.
|
||||
|
||||
You will be prompted for your email address to receive notifications about your SSL and then asked to accept the subscriber agreement.
|
||||
|
||||
If successful you'll now have four files in `/etc/letsencrypt/live/netbox.example.com` (remember, your hostname is different)
|
||||
|
||||
```
|
||||
cert.pem
|
||||
chain.pem
|
||||
fullchain.pem
|
||||
privkey.pem
|
||||
```
|
||||
|
||||
Now edit your nginx configuration `/etc/nginx/sites-available/netbox` and at the top edit to the following:
|
||||
|
||||
```
|
||||
#listen 80;
|
||||
#listen [::]80;
|
||||
listen 443;
|
||||
listen [::]443;
|
||||
|
||||
ssl on;
|
||||
ssl_certificate /etc/letsencrypt/live/netbox.example.com/cert.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/netbox.example.com/privkey.pem;
|
||||
```
|
||||
|
||||
If you are not using IPv6 then you do not need `listen [::]443;` The two commented lines are for non-SSL for both IPv4 and IPv6.
|
||||
|
||||
Lastly, restart nginx:
|
||||
|
||||
```
|
||||
# sudo services nginx restart
|
||||
```
|
||||
|
||||
You should now have netbox running on a SSL protected connection.
|
||||
|
||||
# Upgrading
|
||||
|
||||
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured).
|
||||
|
||||
```
|
||||
# ./upgrade.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
* Installs or upgrades any new required Python packages
|
||||
* Applies any database migrations that were included in the release
|
||||
* Collects all static files to be served by the HTTP service
|
||||
|
||||
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
|
||||
|
||||
```
|
||||
# sudo supervisorctl restart netbox
|
||||
```
|
||||
|
||||
@@ -52,15 +52,13 @@ A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefix
|
||||
|
||||
### Statuses
|
||||
|
||||
Each prefix is assigned an operational status. This may be one of the following:
|
||||
Each prefix is assigned an operational status. This is one of the following:
|
||||
|
||||
* Container - A summary of child prefixes
|
||||
* Active - Provisioned and in use
|
||||
* Reserved - Earmarked for future use
|
||||
* Deprecated - No longer in use
|
||||
|
||||
NetBox provides several statuses by default, but you are free to change them to suit the needs of your organization.
|
||||
|
||||
### Roles
|
||||
|
||||
Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include:
|
||||
@@ -71,7 +69,7 @@ Whereas a status describes a prefix's operational state, a role describes its fu
|
||||
* Lab
|
||||
* Out-of-band
|
||||
|
||||
Role assignment is optional. And like statuses, you are free to create your own.
|
||||
Role assignment is optional and you are free to create as many as you'd like.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,6 +8,27 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
class SiteFilter(django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['q', 'name', 'facility', 'asn']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
|
||||
Q(shipping_address__icontains=value)
|
||||
try:
|
||||
qs_filter |= Q(asn=int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class RackGroupFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
|
||||
@@ -386,10 +386,13 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
# Rack position
|
||||
try:
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.data['rack']).get_rack_units(face=self.data.get('face'))
|
||||
position_choices = Rack.objects.get(pk=self.data['rack'])\
|
||||
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||
elif self.initial.get('rack') and str(self.initial.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack']).get_rack_units(face=self.initial.get('face'))
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack'])\
|
||||
.get_rack_units(face=self.initial.get('face'), exclude=pk)
|
||||
else:
|
||||
position_choices = []
|
||||
except Rack.DoesNotExist:
|
||||
@@ -424,7 +427,7 @@ class DeviceFromCSVForm(forms.ModelForm):
|
||||
'invalid_choice': 'Invalid site name.',
|
||||
})
|
||||
rack_name = forms.CharField()
|
||||
face = forms.ChoiceField(choices=[('Front', 'Front'), ('Rear', 'Rear')])
|
||||
face = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -443,7 +446,7 @@ class DeviceFromCSVForm(forms.ModelForm):
|
||||
try:
|
||||
self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
|
||||
except DeviceType.DoesNotExist:
|
||||
self.add_error('model_name', "Invalid device type ({})".format(model_name))
|
||||
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
|
||||
|
||||
# Validate rack
|
||||
if site and rack_name:
|
||||
@@ -454,11 +457,15 @@ class DeviceFromCSVForm(forms.ModelForm):
|
||||
|
||||
def clean_face(self):
|
||||
face = self.cleaned_data['face']
|
||||
if face.lower() == 'front':
|
||||
return 0
|
||||
if face.lower() == 'rear':
|
||||
return 1
|
||||
raise forms.ValidationError("Invalid rack face ({})".format(face))
|
||||
if face:
|
||||
try:
|
||||
return {
|
||||
'front': 0,
|
||||
'rear': 1,
|
||||
}[face.lower()]
|
||||
except KeyError:
|
||||
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
|
||||
return face
|
||||
|
||||
|
||||
class DeviceImportForm(BulkImportForm, BootstrapMixin):
|
||||
|
||||
25
netbox/dcim/migrations/0003_auto_20160628_1721.py
Normal file
25
netbox/dcim/migrations/0003_auto_20160628_1721.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-06-28 17:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0002_auto_20160622_1821'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (100BASE-TX)'], [1000, b'1GE (1000BASE-T)'], [1100, b'1GE (SFP)'], [1150, b'10GE (10GBASE-T)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (100BASE-TX)'], [1000, b'1GE (1000BASE-T)'], [1100, b'1GE (SFP)'], [1150, b'10GE (10GBASE-T)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200),
|
||||
),
|
||||
]
|
||||
@@ -45,14 +45,16 @@ IFACE_FF_VIRTUAL = 0
|
||||
IFACE_FF_100M_COPPER = 800
|
||||
IFACE_FF_1GE_COPPER = 1000
|
||||
IFACE_FF_SFP = 1100
|
||||
IFACE_FF_10GE_COPPER = 1150
|
||||
IFACE_FF_SFP_PLUS = 1200
|
||||
IFACE_FF_XFP = 1300
|
||||
IFACE_FF_QSFP_PLUS = 1400
|
||||
IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
[IFACE_FF_100M_COPPER, '10/100M (Copper)'],
|
||||
[IFACE_FF_1GE_COPPER, '1GE (Copper)'],
|
||||
[IFACE_FF_100M_COPPER, '10/100M (100BASE-TX)'],
|
||||
[IFACE_FF_1GE_COPPER, '1GE (1000BASE-T)'],
|
||||
[IFACE_FF_SFP, '1GE (SFP)'],
|
||||
[IFACE_FF_10GE_COPPER, '10GE (10GBASE-T)'],
|
||||
[IFACE_FF_SFP_PLUS, '10GE (SFP+)'],
|
||||
[IFACE_FF_XFP, '10GE (XFP)'],
|
||||
[IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'],
|
||||
@@ -83,6 +85,48 @@ RPC_CLIENT_CHOICES = [
|
||||
]
|
||||
|
||||
|
||||
def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
|
||||
"""
|
||||
Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the
|
||||
following pattern:
|
||||
|
||||
{a}/{b}/{c}:{d}
|
||||
|
||||
Interfaces are ordered first by field a, then b, then c, and finally d. Leading text (which typically indicates the
|
||||
interface's type) is ignored. If any fields are not contained by an interface name, those fields are treated as
|
||||
None. 'None' is ordered after all other values. For example:
|
||||
|
||||
et-0/0/0
|
||||
et-0/0/1
|
||||
et-0/1/0
|
||||
xe-0/1/1:0
|
||||
xe-0/1/1:1
|
||||
xe-0/1/1:2
|
||||
xe-0/1/1:3
|
||||
et-0/1/2
|
||||
...
|
||||
et-0/1/9
|
||||
et-0/1/10
|
||||
et-0/1/11
|
||||
et-1/0/0
|
||||
et-1/0/1
|
||||
...
|
||||
vlan1
|
||||
vlan10
|
||||
|
||||
:param queryset: The base queryset to be ordered
|
||||
:param sql_col: Table and name of the SQL column which contains the interface name (ex: ''dcim_interface.name')
|
||||
:param primary_ordering: A tuple of fields which take ordering precedence before the interface name (optional)
|
||||
"""
|
||||
ordering = primary_ordering + ('_id1', '_id2', '_id3', '_id4')
|
||||
return queryset.extra(select={
|
||||
'_id1': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_id2': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_id3': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_id4': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
|
||||
}).order_by(*ordering)
|
||||
|
||||
|
||||
class Site(CreatedUpdatedModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
@@ -213,12 +257,13 @@ class Rack(CreatedUpdatedModel):
|
||||
return "{} ({})".format(self.name, self.facility_id)
|
||||
return self.name
|
||||
|
||||
def get_rack_units(self, face=RACK_FACE_FRONT, remove_redundant=False):
|
||||
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
|
||||
"""
|
||||
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
|
||||
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
|
||||
|
||||
:param face: Rack face (front or rear)
|
||||
:param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
|
||||
:param remove_redundant: If True, rack units occupied by a device already listed will be omitted
|
||||
"""
|
||||
|
||||
@@ -229,7 +274,9 @@ class Rack(CreatedUpdatedModel):
|
||||
# Add devices to rack units list
|
||||
if self.pk:
|
||||
for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
|
||||
.filter(rack=self, position__gt=0).filter(Q(face=face) | Q(device_type__is_full_depth=True)):
|
||||
.exclude(pk=exclude)\
|
||||
.filter(rack=self, position__gt=0)\
|
||||
.filter(Q(face=face) | Q(device_type__is_full_depth=True)):
|
||||
if remove_redundant:
|
||||
elevation[device.position]['device'] = device
|
||||
for u in range(device.position + 1, device.position + device.device_type.u_height):
|
||||
@@ -408,6 +455,13 @@ class PowerOutletTemplate(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class InterfaceTemplateManager(models.Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super(InterfaceTemplateManager, self).get_queryset()
|
||||
return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',))
|
||||
|
||||
|
||||
class InterfaceTemplate(models.Model):
|
||||
"""
|
||||
A template for a physical data interface on a new Device.
|
||||
@@ -417,6 +471,8 @@ class InterfaceTemplate(models.Model):
|
||||
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
|
||||
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
|
||||
|
||||
objects = InterfaceTemplateManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
unique_together = ['device_type', 'name']
|
||||
@@ -512,7 +568,10 @@ class Device(CreatedUpdatedModel):
|
||||
raise ValidationError("Must specify rack face with rack position.")
|
||||
|
||||
# Validate rack space
|
||||
rack_face = self.face if not self.device_type.is_full_depth else None
|
||||
try:
|
||||
rack_face = self.face if not self.device_type.is_full_depth else None
|
||||
except DeviceType.DoesNotExist:
|
||||
raise ValidationError("Must specify device type.")
|
||||
exclude_list = [self.pk] if self.pk else []
|
||||
try:
|
||||
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
|
||||
@@ -708,18 +767,8 @@ class PowerOutlet(models.Model):
|
||||
class InterfaceManager(models.Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Cast up to three interface slot/position IDs as independent integers and order appropriately. This ensures that
|
||||
interfaces are ordered numerically without regard to type. For example:
|
||||
xe-0/0/0, xe-0/0/1, xe-0/0/2 ... et-0/0/47, et-0/0/48, et-0/0/49 ...
|
||||
instead of:
|
||||
et-0/0/48, et-0/0/49, et-0/0/50 ... et-0/0/53, xe-0/0/0, xe-0/0/1 ...
|
||||
"""
|
||||
return super(InterfaceManager, self).get_queryset().extra(select={
|
||||
'_id1': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)\/([0-9]+)$') AS integer)",
|
||||
'_id2': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)$') AS integer)",
|
||||
'_id3': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)$') AS integer)",
|
||||
}).order_by('device', '_id1', '_id2', '_id3')
|
||||
qs = super(InterfaceManager, self).get_queryset()
|
||||
return order_interfaces(qs, 'dcim_interface.name', ('device',))
|
||||
|
||||
def virtual(self):
|
||||
return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL)
|
||||
|
||||
@@ -61,6 +61,7 @@ def expand_pattern(string):
|
||||
|
||||
class SiteListView(ObjectListView):
|
||||
queryset = Site.objects.all()
|
||||
filter = filters.SiteFilter
|
||||
table = tables.SiteTable
|
||||
template_name = 'dcim/site_list.html'
|
||||
|
||||
@@ -75,11 +76,13 @@ def site(request, slug):
|
||||
'vlan_count': VLAN.objects.filter(site=site).count(),
|
||||
'circuit_count': Circuit.objects.filter(site=site).count(),
|
||||
}
|
||||
rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
|
||||
topology_maps = TopologyMap.objects.filter(site=site)
|
||||
|
||||
return render(request, 'dcim/site.html', {
|
||||
'site': site,
|
||||
'stats': stats,
|
||||
'rack_groups': rack_groups,
|
||||
'topology_maps': topology_maps,
|
||||
})
|
||||
|
||||
@@ -1514,7 +1517,10 @@ def module_add(request, pk):
|
||||
module.device = device
|
||||
module.save()
|
||||
messages.success(request, "Added module {} to {}".format(module.name, module.device.name))
|
||||
return redirect('dcim:device_inventory', pk=module.device.pk)
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:module_add', pk=module.device.pk)
|
||||
else:
|
||||
return redirect('dcim:device_inventory', pk=module.device.pk)
|
||||
|
||||
else:
|
||||
form = forms.ModuleForm()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import pydot
|
||||
import graphviz
|
||||
from rest_framework import generics
|
||||
from rest_framework.views import APIView
|
||||
import tempfile
|
||||
@@ -49,32 +49,30 @@ class TopologyMapView(APIView):
|
||||
tmap = get_object_or_404(TopologyMap, slug=slug)
|
||||
|
||||
# Construct the graph
|
||||
graph = pydot.Dot(graph_type='graph', ranksep='1')
|
||||
graph = graphviz.Graph()
|
||||
graph.graph_attr['ranksep'] = '1'
|
||||
for i, device_set in enumerate(tmap.device_sets):
|
||||
|
||||
subgraph = pydot.Subgraph('sg{}'.format(i), rank='same')
|
||||
subgraph = graphviz.Graph(name='sg{}'.format(i))
|
||||
subgraph.graph_attr['rank'] = 'same'
|
||||
|
||||
# Add a pseudonode for each device_set to enforce hierarchical layout
|
||||
subgraph.add_node(pydot.Node('set{}'.format(i), shape='none', width='0', label=''))
|
||||
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
|
||||
if i:
|
||||
graph.add_edge(pydot.Edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis'))
|
||||
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
||||
|
||||
# Add each device to the graph
|
||||
devices = []
|
||||
for query in device_set.split(','):
|
||||
devices += Device.objects.filter(name__regex=query)
|
||||
for d in devices:
|
||||
node = pydot.Node(d.name)
|
||||
subgraph.add_node(node)
|
||||
subgraph.node(d.name)
|
||||
|
||||
# Add an invisible connection to each successive device in a set to enforce horizontal order
|
||||
for j in range(0, len(devices) - 1):
|
||||
edge = pydot.Edge(devices[j].name, devices[j + 1].name)
|
||||
# edge.set('style', 'invis') doesn't seem to work for some reason
|
||||
edge.set_style('invis')
|
||||
subgraph.add_edge(edge)
|
||||
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
|
||||
|
||||
graph.add_subgraph(subgraph)
|
||||
graph.subgraph(subgraph)
|
||||
|
||||
# Compile list of all devices
|
||||
device_superset = Q()
|
||||
@@ -87,17 +85,14 @@ class TopologyMapView(APIView):
|
||||
connections = InterfaceConnection.objects.filter(interface_a__device__in=devices,
|
||||
interface_b__device__in=devices)
|
||||
for c in connections:
|
||||
edge = pydot.Edge(c.interface_a.device.name, c.interface_b.device.name)
|
||||
graph.add_edge(edge)
|
||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
|
||||
|
||||
# Write the image to disk and return
|
||||
topo_file = tempfile.NamedTemporaryFile()
|
||||
# Get the image data and return
|
||||
try:
|
||||
graph.write(topo_file.name, format='png')
|
||||
topo_data = graph.pipe(format='png')
|
||||
except:
|
||||
return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz "
|
||||
"executables have been installed correctly.")
|
||||
response = HttpResponse(FileWrapper(topo_file), content_type='image/png')
|
||||
topo_file.close()
|
||||
response = HttpResponse(topo_data, content_type='image/png')
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import generics
|
||||
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
|
||||
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter
|
||||
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
|
||||
|
||||
from . import serializers
|
||||
|
||||
@@ -12,6 +12,7 @@ class VRFListView(generics.ListAPIView):
|
||||
"""
|
||||
queryset = VRF.objects.all()
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filter_class = VRFFilter
|
||||
|
||||
|
||||
class VRFDetailView(generics.RetrieveAPIView):
|
||||
|
||||
@@ -46,9 +46,14 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
action='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
vrf = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
)
|
||||
# Duplicate of `vrf` for backward-compatibility
|
||||
vrf_id = django_filters.MethodFilter(
|
||||
action='vrf',
|
||||
label='VRF (ID)',
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
@@ -84,7 +89,7 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['family', 'site_id', 'site', 'vrf_id', 'vrf', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
|
||||
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
@@ -104,7 +109,7 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def vrf(self, queryset, value):
|
||||
def _vrf(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
try:
|
||||
@@ -121,10 +126,14 @@ class IPAddressFilter(django_filters.FilterSet):
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label='VRF (ID)',
|
||||
vrf = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
)
|
||||
# Duplicate of `vrf` for backward-compatibility
|
||||
vrf_id = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device',
|
||||
@@ -155,6 +164,17 @@ class IPAddressFilter(django_filters.FilterSet):
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def _vrf(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
try:
|
||||
vrf_id = int(value)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
if vrf_id == 0:
|
||||
return queryset.filter(vrf__isnull=True)
|
||||
return queryset.filter(vrf__pk=value)
|
||||
|
||||
|
||||
class VLANFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
|
||||
@@ -13,6 +13,10 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
|
||||
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
@@ -215,6 +219,7 @@ class PrefixBulkEditForm(forms.Form, BootstrapMixin):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
|
||||
help_text="Select the VRF to assign, or check below to remove VRF assignment")
|
||||
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
|
||||
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=50, required=False)
|
||||
|
||||
@@ -444,6 +449,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
|
||||
class VLANBulkEditForm(forms.Form, BootstrapMixin):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ except ImportError:
|
||||
"the documentation.")
|
||||
|
||||
|
||||
VERSION = '1.0.6'
|
||||
|
||||
# Import local configuration
|
||||
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<div class="panel-body">
|
||||
<p>There was a problem with your request. This error has been logged and administrative staff have
|
||||
been notified. Please return to the home page and try again.</p>
|
||||
<p>If you are responsible for this installation, please consider
|
||||
<a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>.</p>
|
||||
<div class="text-right">
|
||||
<a href="/" class="btn btn-primary">Home Page</a>
|
||||
</div>
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<p class="text-muted">{{ settings.HOSTNAME }}</p>
|
||||
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
|
||||
|
||||
@@ -74,12 +74,12 @@
|
||||
<tr>
|
||||
<td>Face</td>
|
||||
<td>Rack face; front or rear (optional)</td>
|
||||
<td>rear</td>
|
||||
<td>Rear</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,rear</pre>
|
||||
<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,6 +6,26 @@
|
||||
{% block title %}{{ site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
|
||||
<li>{{ site }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'dcim:site_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site_graphs' pk=site.pk %}" title="Show graphs">
|
||||
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
|
||||
@@ -124,6 +144,25 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Rack Groups</strong>
|
||||
</div>
|
||||
{% if rack_groups %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for rg in rack_groups %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-folder"></i> <a href="{{ rg.get_absolute_url }}">{{ rg.name }}</a></td>
|
||||
<td>{{ rg.rack_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="panel-body text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Topology Maps</strong>
|
||||
@@ -132,7 +171,7 @@
|
||||
<table class="table table-hover panel-body">
|
||||
{% for tm in topology_maps %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-map text-success"></i> <a href="{% url 'dcim-api:topology_map' slug=tm.slug %}" target="_blank">{{ tm }}</a></td>
|
||||
<td><i class="fa fa-fw fa-map"></i> <a href="{% url 'dcim-api:topology_map' slug=tm.slug %}" target="_blank">{{ tm }}</a></td>
|
||||
<td>{{ tm.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -14,5 +14,28 @@
|
||||
{% include 'inc/export_button.html' with obj_type='sites' %}
|
||||
</div>
|
||||
<h1>Sites</h1>
|
||||
{% render_table table 'table.html' %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% render_table table 'table.html' %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Search</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form action="{% url 'dcim:site_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -253,6 +253,9 @@ class BulkImportForm(forms.Form):
|
||||
else:
|
||||
for field, errors in obj_form.errors.items():
|
||||
for e in errors:
|
||||
self.add_error('csv', "Record {} ({}): {}".format(i, field, e))
|
||||
if field == '__all__':
|
||||
self.add_error('csv', "Record {}: {}".format(i, e))
|
||||
else:
|
||||
self.add_error('csv', "Record {} ({}): {}".format(i, field, e))
|
||||
|
||||
self.cleaned_data['csv'] = obj_list
|
||||
|
||||
@@ -5,6 +5,7 @@ django-filter==0.13.0
|
||||
django-rest-swagger==0.3.7
|
||||
django-tables2==1.2.1
|
||||
djangorestframework==3.3.3
|
||||
graphviz==0.4.10
|
||||
Markdown==2.6.6
|
||||
ncclient==0.4.7
|
||||
netaddr==0.7.18
|
||||
@@ -12,6 +13,5 @@ paramiko==2.0.0
|
||||
psycopg2==2.6.1
|
||||
py-gfm==0.1.3
|
||||
pycrypto==2.6.1
|
||||
pydot==1.0.2
|
||||
sqlparse==0.1.19
|
||||
xmltodict==0.10.2
|
||||
|
||||
15
upgrade.sh
Executable file
15
upgrade.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# This script will prepare NetBox to run after the code has been upgraded to
|
||||
# its most recent release.
|
||||
#
|
||||
# Once the script completes, remember to restart the WSGI service (e.g.
|
||||
# gunicorn or uWSGI).
|
||||
|
||||
# Install any new Python packages
|
||||
pip install -r requirements.txt --upgrade
|
||||
|
||||
# Apply any database migrations
|
||||
./netbox/manage.py migrate
|
||||
|
||||
# Collect static files
|
||||
./netbox/manage.py collectstatic --noinput
|
||||
Reference in New Issue
Block a user