mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-10 22:02:17 -06:00
Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf44e512ff | ||
|
|
026403ed38 | ||
|
|
f6bd1f0c48 | ||
|
|
66489438b9 | ||
|
|
e5a6a4f05e | ||
|
|
9e4aa9c056 | ||
|
|
4ce40891f0 | ||
|
|
46b1ac23af | ||
|
|
a5f6e64849 | ||
|
|
b9db1ac7f7 | ||
|
|
124c2acad7 | ||
|
|
2691590aa1 | ||
|
|
51cc0d5083 | ||
|
|
9c32943d73 | ||
|
|
4483ba55dd | ||
|
|
f20e0edb35 | ||
|
|
aed2180142 | ||
|
|
4913d25d18 | ||
|
|
9e181c20c7 | ||
|
|
404d934736 | ||
|
|
024c7da15b | ||
|
|
d3a5b82d93 | ||
|
|
1e3a03c463 | ||
|
|
bafbc052e2 | ||
|
|
9421ec040c | ||
|
|
07fc2e5502 | ||
|
|
9098001bcb | ||
|
|
d9bf199e75 | ||
|
|
6f1ed9fc16 | ||
|
|
96b496ffa8 | ||
|
|
f1b6f0cfee | ||
|
|
e19ce043d6 | ||
|
|
35a2671525 | ||
|
|
03542b400d | ||
|
|
73d24532c9 | ||
|
|
b60f964835 | ||
|
|
8e7e02a622 | ||
|
|
2c23ca33a2 | ||
|
|
69affb7a6e | ||
|
|
6a6cf14a38 | ||
|
|
e1da3b8f10 | ||
|
|
da50cd0f03 | ||
|
|
d80ffd2308 | ||
|
|
18846cf40a | ||
|
|
e81a2094df | ||
|
|
17011843d7 | ||
|
|
4c45e38aea | ||
|
|
e1a6188580 | ||
|
|
4e4bb01a55 | ||
|
|
e92f60afda | ||
|
|
a365cfcf9b | ||
|
|
f617828712 | ||
|
|
d85561c6fd | ||
|
|
4cba418d89 | ||
|
|
6112e5542d | ||
|
|
1b8786ab98 | ||
|
|
876361c0b2 | ||
|
|
db25894363 | ||
|
|
f1881fad71 | ||
|
|
40173b4f8e | ||
|
|
173a4cde8b | ||
|
|
d9867423de | ||
|
|
bf35b4121f | ||
|
|
85461f9be0 | ||
|
|
fafd2ab517 | ||
|
|
498eb50f56 | ||
|
|
1cdf70da0b | ||
|
|
cbd6370889 | ||
|
|
8dd6112a4b | ||
|
|
4c354277ec | ||
|
|
b8c5366c3e | ||
|
|
129415e15f | ||
|
|
68515b9d46 | ||
|
|
c948682370 | ||
|
|
5758ce2be4 | ||
|
|
27c8cb046c | ||
|
|
b6e87d1526 | ||
|
|
90dadfc5d9 | ||
|
|
edc3ab597f | ||
|
|
4372043ddb | ||
|
|
db72a64ef7 | ||
|
|
eb9315c11c | ||
|
|
b9e0739f72 | ||
|
|
29358a18b8 | ||
|
|
9c48340b9a | ||
|
|
4e4996e88f | ||
|
|
ece16200a3 | ||
|
|
bfe8979523 | ||
|
|
7228801cb0 | ||
|
|
1338bf6012 | ||
|
|
c19124fcac | ||
|
|
edde021c85 | ||
|
|
966ea45050 | ||
|
|
e7f21dea4b | ||
|
|
3276caa284 | ||
|
|
891a128736 | ||
|
|
a74ddd8527 | ||
|
|
24c48bece8 | ||
|
|
a069e92ce0 | ||
|
|
c0ab9f70dc | ||
|
|
cc17604220 | ||
|
|
9793b406e9 | ||
|
|
7a2f6eaf34 | ||
|
|
dc847ce4d6 | ||
|
|
578013fdd2 | ||
|
|
9f75d5bd23 | ||
|
|
a6d41c95b8 | ||
|
|
9da4c28cd5 | ||
|
|
0ce92cb2ee | ||
|
|
6fb530b75d | ||
|
|
5034b836ea | ||
|
|
35f3355cfe | ||
|
|
65514102cd | ||
|
|
343b65cb50 | ||
|
|
33d755d51a | ||
|
|
9750da4761 | ||
|
|
d974cecda3 | ||
|
|
5e9090a03a | ||
|
|
60b48f9e4e | ||
|
|
e8b8b015bb | ||
|
|
7021ce2ecf | ||
|
|
58e23a9773 | ||
|
|
064582f6c5 | ||
|
|
75789fc956 | ||
|
|
1c159968bf | ||
|
|
1b5231c188 | ||
|
|
dc3cbfcdd4 | ||
|
|
ee65d3f406 | ||
|
|
fc9aa03dc1 | ||
|
|
34c332d165 | ||
|
|
c57e63ff00 | ||
|
|
14502123d8 | ||
|
|
80c8d2f0c0 | ||
|
|
acccdc09f2 | ||
|
|
20e3ef9a04 | ||
|
|
db9b0dcaef | ||
|
|
97fbfeecc3 | ||
|
|
7eae636562 | ||
|
|
af87345637 | ||
|
|
85c55cd27f | ||
|
|
6e1f8d3503 | ||
|
|
60cc88bcde | ||
|
|
8b7d86df5a | ||
|
|
a5066a905e | ||
|
|
aabe641d63 | ||
|
|
06a38d836c | ||
|
|
0123dbcf5f | ||
|
|
35c5423127 | ||
|
|
7682b66034 | ||
|
|
067f22e444 | ||
|
|
f96171f529 | ||
|
|
49f06cfeb2 | ||
|
|
1bb2a3f152 | ||
|
|
7a68e1d901 | ||
|
|
7f353e88c9 | ||
|
|
2829303c74 | ||
|
|
c9bf10421b | ||
|
|
d2bcd71b32 | ||
|
|
3ea12c646a | ||
|
|
24e361dc50 | ||
|
|
381639d4a7 | ||
|
|
cf17088b0a | ||
|
|
a165445808 | ||
|
|
66d8c27b1e | ||
|
|
85f3324d97 | ||
|
|
a010a6dde5 | ||
|
|
1c49909e2c | ||
|
|
019daf5524 | ||
|
|
519ab21ba0 | ||
|
|
26286b6e36 | ||
|
|
d520d78380 | ||
|
|
46ae4b307c | ||
|
|
1728d81677 | ||
|
|
fc5495eb3b | ||
|
|
004f5c448e | ||
|
|
995447ae0b | ||
|
|
76baa6fd2d | ||
|
|
2e27389cda | ||
|
|
48d607fb96 | ||
|
|
b8b173674f | ||
|
|
d6920eceb1 | ||
|
|
fbbdb3807c | ||
|
|
a1953bab8b | ||
|
|
aa000bf26d | ||
|
|
c93bc40479 | ||
|
|
4ed3d54566 | ||
|
|
522a0c20e7 | ||
|
|
b02c54ce52 | ||
|
|
43e030f1db | ||
|
|
945ca31460 | ||
|
|
fc3cb72ab8 | ||
|
|
4a04af145b | ||
|
|
e7615cf32f | ||
|
|
8b357a311d | ||
|
|
fdfc32899d | ||
|
|
03fa000d8d | ||
|
|
ec667eeed0 | ||
|
|
6c415794cd | ||
|
|
2ddb4b90c5 | ||
|
|
cce6c89810 | ||
|
|
b37503ed8f | ||
|
|
374702927b | ||
|
|
0eb8227044 | ||
|
|
98febf3979 | ||
|
|
6a4a636794 | ||
|
|
9acd0e99f9 | ||
|
|
f1857dd189 | ||
|
|
d22e4e7698 | ||
|
|
6848a3dc81 | ||
|
|
4dac43c1c9 | ||
|
|
b392aa4a4a | ||
|
|
5181c97281 | ||
|
|
66a16dd06b | ||
|
|
c5d498ac14 | ||
|
|
2080abc6c3 | ||
|
|
b379918295 | ||
|
|
4e5f537cc5 | ||
|
|
7918f85cdd | ||
|
|
df1147d941 | ||
|
|
65bc91e9de | ||
|
|
9aa0972a8c | ||
|
|
4cd6f99cbd | ||
|
|
df01947c9e | ||
|
|
4dd31497e5 | ||
|
|
f958bc0580 | ||
|
|
0a22821209 | ||
|
|
a4cbfd7d5b | ||
|
|
f0fb60734a | ||
|
|
e334c64a7c | ||
|
|
6e068770ea | ||
|
|
d5d4eb9fd5 | ||
|
|
ab880e1053 | ||
|
|
1ea8f04c23 | ||
|
|
0b37d4f5e6 | ||
|
|
c6e66a073d | ||
|
|
eade3cbd6b | ||
|
|
7cf437e11b | ||
|
|
19a302774a | ||
|
|
a35d927235 | ||
|
|
1cd20861f2 | ||
|
|
e78263637a | ||
|
|
215c31e7a0 | ||
|
|
5935a8843e |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
*.pyc
|
||||
configuration.py
|
||||
.idea
|
||||
*.sh
|
||||
/*.sh
|
||||
!upgrade.sh
|
||||
fabfile.py
|
||||
|
||||
*.swp
|
||||
|
||||
8
.travis.yml
Normal file
8
.travis.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
- pip install pep8
|
||||
script:
|
||||
- ./scripts/cibuild.sh
|
||||
@@ -27,9 +27,10 @@ IRC.
|
||||
|
||||
## Feature Requests
|
||||
|
||||
* First, check the [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you'd like to see
|
||||
has already been requested (and possibly rejected). If it is, be sure to comment with a "+1" and any additional
|
||||
justification you have for the feature.
|
||||
* First, check the [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting
|
||||
has already been requested (and possibly rejected). If it has, click "add a reaction" in the top right corner of the
|
||||
issue and add a thumbs up (+1). This ensures that the issue has a better chance of making it onto the roadmap. Also feel
|
||||
free to add a comment with any additional justification for the feature.
|
||||
|
||||
* While discussion of new features is welcome, it's important to limit the scope of NetBox's feature set to avoid
|
||||
feature creep. For example, the following features would be firmly out of scope for NetBox:
|
||||
@@ -48,3 +49,19 @@ Even if it's not quite right for NetBox, we may be able to point you to a tool b
|
||||
* A use case for the feature; who would use it and what value it would add to NetBox
|
||||
* 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
|
||||
|
||||
* Be sure to open an issue before starting work on a pull request, and discuss your idea with the NetBox maintainers
|
||||
before beginning work. This will help prevent wasting time on something that might we might not be able to implement.
|
||||
When suggesting a new feature, also make sure it won't conflict with any work that's already in progress.
|
||||
|
||||
* 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
|
||||
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM ubuntu:14.04
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python2.7 \
|
||||
python-dev \
|
||||
git \
|
||||
python-pip \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
libffi-dev \
|
||||
graphviz \
|
||||
libpq-dev \
|
||||
build-essential \
|
||||
gunicorn \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /opt/netbox \
|
||||
&& cd /opt/netbox \
|
||||
&& git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \
|
||||
&& pip install -r requirements.txt \
|
||||
&& apt-get purge -y --auto-remove git build-essential
|
||||
|
||||
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py
|
||||
|
||||
ENTRYPOINT [ "/docker-entrypoint.sh" ]
|
||||
|
||||
ADD docker/gunicorn_config.py /opt/netbox/
|
||||
ADD docker/nginx.conf /etc/netbox-nginx/
|
||||
VOLUME ["/etc/netbox-nginx/"]
|
||||
69
README.md
69
README.md
@@ -1,55 +1,30 @@
|
||||
# NetBox
|
||||
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
|
||||
|
||||
The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
|
||||
|
||||
Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net**!
|
||||
|
||||
### Build Status
|
||||
|
||||
| | python 2.7 |
|
||||
|-------------|------------|
|
||||
| **master** | [](https://travis-ci.org/digitalocean/netbox) |
|
||||
| **develop** | [](https://travis-ci.org/digitalocean/netbox) |
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# Installation
|
||||
|
||||
Please see docs/getting-started.md for instructions on installing NetBox.
|
||||
|
||||
# Components
|
||||
|
||||
NetBox understands all of the physical and logical building blocks that comprise network infrastructure, and the manners in which they are all related.
|
||||
|
||||
## DCIM
|
||||
|
||||
DCIM comprises all the physical installations and connections which comprise a network. NetBox tracks where devices are installed, as well as their individual power, console, and network connections.
|
||||
|
||||
**Site:** A physical location (typically a building) where network devices are installed. Devices in different sites cannot be directly connected to one another.
|
||||
|
||||
**Rack:** An equipment rack into which devices are installed. Each rack belongs to a site.
|
||||
|
||||
**Device:** Any type of rack-mounted device. For example, routers, switches, servers, console servers, PDUs, etc. 0U (non-rack-mounted) devices are supported.
|
||||
|
||||
## IPAM
|
||||
|
||||
IPAM deals with the IP addressing and VLANs in use on a network. NetBox makes a distinction between IP prefixes (networks) and individual IP addresses.
|
||||
|
||||
Because NetBox is a combined DCIM/IPAM system, IP addresses can be assigned to device interfaces in the application just as they are in the real world.
|
||||
|
||||
**Aggregate:** A top-level aggregate of IP address space; for example, 10.0.0.0/8 or 2001:db8::/32. Each aggregate belongs to a regional Internet registry (RIR) like ARIN or RIPE, or to an authoritative standard such as RFC 1918.
|
||||
|
||||
**VRF:** A virtual routing table. VRF support is currently still under development.
|
||||
|
||||
**Prefix:** An IPv4 or IPv6 network. A prefix can be assigned to a VRF; if not, it is considered to belong to the global table. Prefixes are grouped by aggregates automatically and can optionally be assigned to sites.
|
||||
|
||||
**IP Address:** An individual IPv4 or IPv6 address (with CIDR mask). IP address can be assigned to device interfaces.
|
||||
|
||||
**VLAN:** VLANs are assigned to sites, and can optionally have one or more IP prefixes assigned to them. VLAN IDs are unique only within the scope of a site.
|
||||
|
||||
## Circuits
|
||||
|
||||
Long-distance data connections are typically referred to as _circuits_. NetBox provides a method for managing circuits and their providers. Individual circuits can be terminated to device interfaces.
|
||||
|
||||
**Provider:** An entity to which a network connects to. This can be a transit provider, peer, or some other organization.
|
||||
|
||||
**Circuit:** A data circuit which connects to a provider. The local end of a circuit can be assigned to a device interface.
|
||||
|
||||
## Secrets
|
||||
|
||||
NetBox provides encrypted storage of sensitive data it calls _secrets_. Each user may be issued an encryption key with which stored secrets can be retrieved.
|
||||
|
||||
Note that NetBox does not merely hash secrets, a function which is only useful for validation. It employs fully reversible AES-256 encryption so that secret data can be retrieved and consumed by other services.
|
||||
|
||||
**Secrets** Any piece of confidential data which must be retrievable. For example: passwords, SNMP communities, RADIUS shared secrets, etc.
|
||||
|
||||
**User Key:** An individual user's encrypted copy of the master key, which can be used to retrieve secret data.
|
||||
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
|
||||
|
||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.6
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_USER: netbox
|
||||
POSTGRES_PASSWORD: J5brHrAXFLQSif0K
|
||||
POSTGRES_DB: netbox
|
||||
netbox:
|
||||
build: .
|
||||
image: digitalocean/netbox
|
||||
links:
|
||||
- postgres
|
||||
container_name: netbox
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
SUPERUSER_NAME: admin
|
||||
SUPERUSER_EMAIL: admin@example.com
|
||||
SUPERUSER_PASSWORD: admin
|
||||
ALLOWED_HOSTS: localhost
|
||||
DB_NAME: netbox
|
||||
DB_USER: netbox
|
||||
DB_PASSWORD: J5brHrAXFLQSif0K
|
||||
DB_HOST: postgres
|
||||
SECRET_KEY: r8OwDznj!!dci#P9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
|
||||
EMAIL_SERVER: localhost
|
||||
EMAIL_PORT: 25
|
||||
EMAIL_USERNAME: foo
|
||||
EMAIL_PASSWORD: bar
|
||||
EMAIL_TIMEOUT: 10
|
||||
EMAIL_FROM: netbox@bar.com
|
||||
NETBOX_USERNAME: guest
|
||||
NETBOX_PASSWORD: guest
|
||||
volumes:
|
||||
- netbox-static-files:/opt/netbox/netbox/static
|
||||
nginx:
|
||||
image: nginx:1.11.1-alpine
|
||||
links:
|
||||
- netbox
|
||||
container_name: nginx
|
||||
command: nginx -g 'daemon off;' -c /etc/netbox-nginx/nginx.conf
|
||||
depends_on:
|
||||
- netbox
|
||||
ports:
|
||||
- 80:80
|
||||
volumes_from:
|
||||
- netbox
|
||||
volumes:
|
||||
netbox-static-files:
|
||||
driver: local
|
||||
22
docker/docker-entrypoint.sh
Executable file
22
docker/docker-entrypoint.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# run db migrations (retry on error)
|
||||
while ! /opt/netbox/netbox/manage.py migrate 2>&1; do
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# create superuser silently
|
||||
if [[ -z ${SUPERUSER_NAME} || -z ${SUPERUSER_EMAIL} || -z ${SUPERUSER_PASSWORD} ]]; then
|
||||
SUPERUSER_NAME='admin'
|
||||
SUPERUSER_EMAIL='admin@example.com'
|
||||
SUPERUSER_PASSWORD='admin'
|
||||
echo "Using defaults: Username: ${SUPERUSER_NAME}, E-Mail: ${SUPERUSER_EMAIL}, Password: ${SUPERUSER_PASSWORD}"
|
||||
fi
|
||||
echo "from django.contrib.auth.models import User; User.objects.create_superuser('${SUPERUSER_NAME}', '${SUPERUSER_EMAIL}', '${SUPERUSER_PASSWORD}')" | python /opt/netbox/netbox/manage.py shell
|
||||
|
||||
# copy static files
|
||||
/opt/netbox/netbox/manage.py collectstatic --no-input
|
||||
|
||||
# start unicorn
|
||||
gunicorn --log-level debug --debug --error-logfile /dev/stderr --log-file /dev/stdout -c /opt/netbox/gunicorn_config.py netbox.wsgi
|
||||
5
docker/gunicorn_config.py
Normal file
5
docker/gunicorn_config.py
Normal file
@@ -0,0 +1,5 @@
|
||||
command = '/usr/bin/gunicorn'
|
||||
pythonpath = '/opt/netbox/netbox'
|
||||
bind = '0.0.0.0:8001'
|
||||
workers = 3
|
||||
user = 'root'
|
||||
35
docker/nginx.conf
Normal file
35
docker/nginx.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
server_tokens off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name localhost;
|
||||
|
||||
access_log off;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/netbox/netbox/static/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://netbox:8001;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
|
||||
}
|
||||
}
|
||||
}
|
||||
19
docs/api-integration.md
Normal file
19
docs/api-integration.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# API Integration
|
||||
|
||||
NetBox features a read-only REST API which can be used to integrate it with
|
||||
other applications.
|
||||
|
||||
In the future, both read and write actions will be available via the API.
|
||||
|
||||
## Clients
|
||||
|
||||
The easiest way to start integrating your applications with NetBox is to make
|
||||
use of an API client. If you build or discover an API client that is not part
|
||||
of this list, please send a pull request!
|
||||
|
||||
- **Go**: [github.com/digitalocean/go-netbox](https://github.com/digitalocean/go-netbox)
|
||||
|
||||
## Documentation
|
||||
|
||||
If you wish to build a new API client or simply explore the NetBox API,
|
||||
Swagger documentation can be found at the URL `/api/docs/` on a NetBox server.
|
||||
45
docs/configuration/mandatory-settings.md
Normal file
45
docs/configuration/mandatory-settings.md
Normal file
@@ -0,0 +1,45 @@
|
||||
NetBox's local configuration is held in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
|
||||
|
||||
## ALLOWED_HOSTS
|
||||
|
||||
This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DATABASE
|
||||
|
||||
NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
|
||||
|
||||
* NAME - Database name
|
||||
* USER - PostgreSQL username
|
||||
* PASSWORD - PostgreSQL password
|
||||
* HOST - Name or IP address of the database server (use `localhost` if running locally)
|
||||
* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432)
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
DATABASE = {
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
|
||||
|
||||
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
|
||||
|
||||
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
|
||||
@@ -1,62 +1,6 @@
|
||||
<h1>Configuration</h1>
|
||||
The following are optional settings which may be declared in `netbox/netbox/configuration.py`.
|
||||
|
||||
NetBox's local configuration is held in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
|
||||
|
||||
[TOC]
|
||||
|
||||
# Mandatory Settings
|
||||
|
||||
---
|
||||
|
||||
#### ALLOWED_HOSTS
|
||||
|
||||
This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### DATABASE
|
||||
|
||||
NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
|
||||
|
||||
* NAME - Database name
|
||||
* USER - PostgreSQL username
|
||||
* PASSWORD - PostgreSQL password
|
||||
* HOST - Name or IP address of the database server (use `localhost` if running locally)
|
||||
* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432)
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
DATABASE = {
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### SECRET_KEY
|
||||
|
||||
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
|
||||
|
||||
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
|
||||
|
||||
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
|
||||
|
||||
# Optional Settings
|
||||
|
||||
---
|
||||
|
||||
#### ADMINS
|
||||
## ADMINS
|
||||
|
||||
NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example:
|
||||
|
||||
@@ -69,7 +13,20 @@ ADMINS = [
|
||||
|
||||
---
|
||||
|
||||
#### DEBUG
|
||||
## BANNER_TOP
|
||||
|
||||
## BANNER_BOTTOM
|
||||
|
||||
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. To replicate the content of the top banner in the bottom banner, set:
|
||||
|
||||
```
|
||||
BANNER_TOP = 'Your banner text'
|
||||
BANNER_BOTTOM = BANNER_TOP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEBUG
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -77,7 +34,7 @@ This setting enables debugging. This should be done only during development or t
|
||||
|
||||
---
|
||||
|
||||
#### EMAIL
|
||||
## EMAIL
|
||||
|
||||
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting:
|
||||
|
||||
@@ -90,7 +47,7 @@ In order to send email, NetBox needs an email server configured. The following i
|
||||
|
||||
---
|
||||
|
||||
#### LOGIN_REQUIRED
|
||||
## LOGIN_REQUIRED
|
||||
|
||||
Default: False,
|
||||
|
||||
@@ -98,7 +55,7 @@ Setting this to True will permit only authenticated users to access any part of
|
||||
|
||||
---
|
||||
|
||||
#### MAINTENANCE_MODE
|
||||
## MAINTENANCE_MODE
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -106,15 +63,15 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
|
||||
|
||||
---
|
||||
|
||||
#### NETBOX_USERNAME
|
||||
## NETBOX_USERNAME
|
||||
|
||||
#### NETBOX_PASSWORD
|
||||
## NETBOX_PASSWORD
|
||||
|
||||
If provided, NetBox will use these credentials to authenticate against devices when collecting data.
|
||||
|
||||
---
|
||||
|
||||
#### PAGINATE_COUNT
|
||||
## PAGINATE_COUNT
|
||||
|
||||
Default: 50
|
||||
|
||||
@@ -122,7 +79,15 @@ Determine how many objects to display per page within each list of objects.
|
||||
|
||||
---
|
||||
|
||||
#### TIME_ZONE
|
||||
## PREFER_IPV4
|
||||
|
||||
Default: False
|
||||
|
||||
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
|
||||
|
||||
---
|
||||
|
||||
## TIME_ZONE
|
||||
|
||||
Default: UTC
|
||||
|
||||
@@ -130,7 +95,7 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
|
||||
|
||||
---
|
||||
|
||||
#### Date and Time Formatting
|
||||
## Date and Time Formatting
|
||||
|
||||
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date).
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<h1>Circuits</h1>
|
||||
|
||||
The circuits component of NetBox deals with the management of long-haul Internet and private transit links and providers.
|
||||
|
||||
[TOC]
|
||||
|
||||
# Providers
|
||||
|
||||
A provider is any entity which provides some form of connectivity. This obviously includes carriers which offer Internet and private transit service. However, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.
|
||||
@@ -1,9 +1,5 @@
|
||||
<h1>DCIM</h1>
|
||||
|
||||
Data center infrastructure management (DCIM) entails all physical assets: sites, racks, devices, cabling, etc.
|
||||
|
||||
[TOC]
|
||||
|
||||
# Sites
|
||||
|
||||
How you define sites will depend on the nature of your organization, but typically a site will equate a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
|
||||
@@ -43,6 +39,7 @@ Each device type is assigned a number of component templates which describe the
|
||||
* Power port templates
|
||||
* Power outlet templates
|
||||
* Interface templates
|
||||
* Device bay templates
|
||||
|
||||
Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it:
|
||||
|
||||
@@ -59,12 +56,12 @@ Note that assignment of components from templates occurs only at the time of dev
|
||||
|
||||
# Devices
|
||||
|
||||
Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and whether they are full depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined.
|
||||
Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined.
|
||||
|
||||
When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8.
|
||||
|
||||
A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow.
|
||||
|
||||
Each device has a physical device type (make and model), which is discussed below.
|
||||
|
||||
### Roles
|
||||
|
||||
NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, device can only belong to one device role.
|
||||
@@ -81,16 +78,19 @@ A device can be assigned modules which represent internal components. Currently,
|
||||
|
||||
### Components
|
||||
|
||||
There are five types of device components which comprise all of the interconnection logic with NetBox:
|
||||
There are six types of device components which comprise all of the interconnection logic with NetBox:
|
||||
|
||||
* Console ports
|
||||
* Console server ports
|
||||
* Power ports
|
||||
* Power outlets
|
||||
* Interfaces
|
||||
* Device bays
|
||||
|
||||
Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.)
|
||||
|
||||
Each type of connection can be defined as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed.
|
||||
Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
|
||||
|
||||
In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
|
||||
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
|
||||
|
||||
Note that child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply.
|
||||
@@ -1,9 +1,5 @@
|
||||
<h1>Extras</h1>
|
||||
|
||||
This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value.
|
||||
|
||||
[TOC]
|
||||
|
||||
# Export Templates
|
||||
|
||||
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface.
|
||||
@@ -1,9 +1,5 @@
|
||||
<h1>IPAM</h1>
|
||||
|
||||
IP address management (IPAM) entails the allocation of IP networks, addresses, and related numeric resources.
|
||||
|
||||
[TOC]
|
||||
|
||||
# VRFs
|
||||
|
||||
A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain within a network. Each VRF is essentially a separate routing table: the same IP prefix or address can exist in multiple VRFs. VRFs are commonly used to isolate customers or organizations from one another within a network.
|
||||
@@ -32,11 +28,13 @@ Additionally, you might define an aggregate for each large swath of public IPv4
|
||||
|
||||
Any prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation.
|
||||
|
||||
Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix.
|
||||
|
||||
### RIRs
|
||||
|
||||
Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space.
|
||||
|
||||
Each aggregate must be assigned to one RIR. NetBox by default will be populated with the RIRs listed above, however you are free to remove these and/or create your own if you choose.
|
||||
Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own).
|
||||
|
||||
---
|
||||
|
||||
@@ -50,15 +48,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:
|
||||
@@ -69,7 +65,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<h1>Secrets</h1>
|
||||
|
||||
"Secrets" are small amounts of data that must be kept confidential; for example, passwords and SNMP community strings. NetBox provides encrypted storage of secret data.
|
||||
|
||||
[TOC]
|
||||
|
||||
# Secrets
|
||||
|
||||
A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext.
|
||||
@@ -20,6 +16,8 @@ Each secret is assigned a functional role which indicates what it is used for. T
|
||||
* IKE key strings
|
||||
* Routing protocol shared secrets
|
||||
|
||||
Roles are also used to control access to secrets. Each role is assigned an arbitrary number of groups and/or users. Only the users associated with a role have permission to decrypt the secrets assigned to that role. (A superuser has permission to decrypt all secrets, provided they have an active user key.)
|
||||
|
||||
---
|
||||
|
||||
# User Keys
|
||||
@@ -1,291 +0,0 @@
|
||||
<h1>Getting Started</h1>
|
||||
|
||||
This guide documents the process of installing NetBox on an Ubuntu 14.04 server with [nginx](https://www.nginx.com/) and [gunicorn](http://gunicorn.org/).
|
||||
|
||||
[TOC]
|
||||
|
||||
# PostgreSQL
|
||||
|
||||
## Installation
|
||||
|
||||
The following packages are needed to install PostgreSQL:
|
||||
|
||||
* postgresql
|
||||
* libpq-dev
|
||||
* python-psycopg2
|
||||
|
||||
```
|
||||
# apt-get install postgresql libpq-dev python-psycopg2
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
|
||||
|
||||
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
|
||||
|
||||
```
|
||||
# sudo -u postgres psql
|
||||
psql (9.3.13)
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
CREATE DATABASE
|
||||
postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
CREATE ROLE
|
||||
postgres=# GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
GRANT
|
||||
postgres=# \q
|
||||
```
|
||||
|
||||
You can verify that authentication works using the following command:
|
||||
|
||||
```
|
||||
# psql -U netbox -h localhost -W
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# NetBox
|
||||
|
||||
## Dependencies
|
||||
|
||||
* python2.7
|
||||
* python-dev
|
||||
* git
|
||||
* python-pip
|
||||
* libxml2-dev
|
||||
* libxslt1-dev
|
||||
* libffi-dev
|
||||
* 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.
|
||||
|
||||
## Clone the Git Repository
|
||||
|
||||
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
|
||||
|
||||
```
|
||||
# mkdir -p /opt/netbox/
|
||||
# cd /opt/netbox/
|
||||
```
|
||||
|
||||
Next, clone the NetBox git repository into the current directory:
|
||||
|
||||
```
|
||||
# git clone https://github.com/digitalocean/netbox.git .
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
remote: Compressing objects: 100% (150/150), done.
|
||||
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
|
||||
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
|
||||
Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
```
|
||||
|
||||
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.)
|
||||
|
||||
```
|
||||
# pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
|
||||
|
||||
```
|
||||
# cd netbox/netbox/
|
||||
# cp configuration.example.py configuration.py
|
||||
```
|
||||
|
||||
Open `configuration.py` with your preferred editor and set the following variables:
|
||||
|
||||
* ALLOWED_HOSTS
|
||||
* DATABASE
|
||||
* SECRET_KEY
|
||||
|
||||
### ALLOWED_HOSTS
|
||||
|
||||
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||
```
|
||||
|
||||
### DATABASE
|
||||
|
||||
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
DATABASE = {
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
}
|
||||
```
|
||||
|
||||
### SECRET_KEY
|
||||
|
||||
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
|
||||
|
||||
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
|
||||
|
||||
## Run Migrations
|
||||
|
||||
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
|
||||
|
||||
```
|
||||
# ./manage.py migrate
|
||||
Operations to perform:
|
||||
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
|
||||
Running migrations:
|
||||
Rendering model states... DONE
|
||||
Applying contenttypes.0001_initial... OK
|
||||
Applying auth.0001_initial... OK
|
||||
Applying admin.0001_initial... OK
|
||||
...
|
||||
```
|
||||
|
||||
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
|
||||
|
||||
## Create a Super User
|
||||
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
|
||||
|
||||
```
|
||||
# ./manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
Password:
|
||||
Password (again):
|
||||
Superuser created successfully.
|
||||
```
|
||||
|
||||
## Collect Static Files
|
||||
|
||||
```
|
||||
# ./manage.py collectstatic
|
||||
|
||||
You have requested to collect static files at the destination
|
||||
location as specified in your settings:
|
||||
|
||||
/opt/netbox/netbox/static
|
||||
|
||||
This will overwrite existing files!
|
||||
Are you sure you want to do this?
|
||||
|
||||
Type 'yes' to continue, or 'no' to cancel: yes
|
||||
```
|
||||
|
||||
## Test the Application
|
||||
|
||||
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
||||
|
||||
```
|
||||
# ./manage.py runserver 0.0.0.0:8000 --insecure
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
June 17, 2016 - 16:17:36
|
||||
Django version 1.9.7, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
|
||||
|
||||
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
|
||||
|
||||
# nginx and gunicorn
|
||||
|
||||
## Installation
|
||||
|
||||
We'll set up a simple HTTP front end using [nginx](https://www.nginx.com/resources/wiki/) and [gunicorn](http://gunicorn.org/) for the purposes of this guide. (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) for service persistence.
|
||||
|
||||
```
|
||||
# apt-get install nginx gunicorn supervisor
|
||||
```
|
||||
|
||||
## nginx Configuration
|
||||
|
||||
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
|
||||
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name netbox.example.com;
|
||||
|
||||
access_log off;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/netbox/netbox/static/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
|
||||
|
||||
```
|
||||
# cd /etc/nginx/sites-enabled/
|
||||
# rm default
|
||||
# ln -s /etc/nginx/sites-available/netbox
|
||||
```
|
||||
|
||||
Restart the nginx service to use the new configuration.
|
||||
|
||||
```
|
||||
# service nginx restart
|
||||
* Restarting nginx nginx
|
||||
```
|
||||
|
||||
## gunicorn Configuration
|
||||
|
||||
Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`.) as `gunicorn_config.py`. Be sure to update the `pythonpath` variable if needed.
|
||||
|
||||
```
|
||||
command = '/usr/local/bin/gunicorn'
|
||||
pythonpath = '/opt/netbox/netbox'
|
||||
bind = '127.0.0.1:8001'
|
||||
workers = 3
|
||||
user = 'www-data'
|
||||
```
|
||||
|
||||
## supervisord Configuration
|
||||
|
||||
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
|
||||
|
||||
```
|
||||
[program:netbox]
|
||||
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
|
||||
directory = /opt/netbox/netbox/
|
||||
user = www-data
|
||||
```
|
||||
|
||||
Finally, restart the supervisor service to detect and run the gunicorn service:
|
||||
|
||||
```
|
||||
# service supervisor restart
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -1,3 +1,53 @@
|
||||
# NetBox Documentation
|
||||
# What is NetBox?
|
||||
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) application.
|
||||
NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management:
|
||||
|
||||
* **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs
|
||||
* **Equipment racks** - Organized by group and site
|
||||
* **Devices** - Types of devices and where they are installed
|
||||
* **Connections** - Network, console, and power connections among devices
|
||||
* **Data circuits** - Long-haul communications circuits and providers
|
||||
* **Secrets** - Encrypted storage of sensitive credentials
|
||||
|
||||
# What NetBox Isn't
|
||||
|
||||
While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide:
|
||||
|
||||
* Network monitoring
|
||||
* DNS server
|
||||
* RADIUS server
|
||||
* Configuration management
|
||||
* Facilities management
|
||||
|
||||
That said, NetBox _can_ be used to great effect in populating external tools with the data they need to perform these functions.
|
||||
|
||||
# Design Philosophy
|
||||
|
||||
NetBox was designed with the following tenets foremost in mind.
|
||||
|
||||
## Replicate the Real World
|
||||
|
||||
Careful consideration has been given to the data model to ensure that it can accurately reflect a real-world network. For instance, IP addresses are assigned not to devices, but to specific interfaces attached to a device, and an interface may have multiple IP addresses assigned to it.
|
||||
|
||||
## Serve as a "Source of Truth"
|
||||
|
||||
NetBox intends to represent the _desired_ state of a network versus its _operational_ state. As such, automated import of live network state is strongly discouraged. All data created in NetBox should first be vetted by a human to ensure its integrity. NetBox can then be used to populate monitoring and provisioning systems with a high degree of confidence.
|
||||
|
||||
## Keep it Simple
|
||||
|
||||
When given a choice between a relatively simple [80% solution](https://en.wikipedia.org/wiki/Pareto_principle) and a much more complex complete solution, the former will typically be favored. This ensures a lean codebase with a low learning curve.
|
||||
|
||||
# Application Stack
|
||||
|
||||
NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server.
|
||||
|
||||
| Function | Component |
|
||||
|--------------|-------------------|
|
||||
| HTTP Service | nginx or Apache |
|
||||
| WSGI Service | gunicorn or uWSGI |
|
||||
| Application | Django/Python |
|
||||
| Database | PostgreSQL |
|
||||
|
||||
# Getting Started
|
||||
|
||||
See the [getting started](getting-started.md) guide for help with getting NetBox up and running quickly.
|
||||
|
||||
51
docs/installation/docker.md
Normal file
51
docs/installation/docker.md
Normal file
@@ -0,0 +1,51 @@
|
||||
This guide demonstrates how to build and run NetBox as a Docker container. It assumes that the latest versions of [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) are already installed in your host.
|
||||
|
||||
# Quickstart
|
||||
|
||||
To get NetBox up and running:
|
||||
|
||||
```
|
||||
git clone -b master https://github.com/digitalocean/netbox.git
|
||||
cd netbox
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The application will be available on http://localhost/ after a few minutes.
|
||||
|
||||
Default credentials:
|
||||
|
||||
* Username: **admin**
|
||||
* Password: **admin**
|
||||
|
||||
# Configuration
|
||||
|
||||
You can configure the app at runtime using variables (see `docker-compose.yml`). Possible environment variables include:
|
||||
|
||||
* SUPERUSER_NAME
|
||||
* SUPERUSER_EMAIL
|
||||
* SUPERUSER_PASSWORD
|
||||
* ALLOWED_HOSTS
|
||||
* DB_NAME
|
||||
* DB_USER
|
||||
* DB_PASSWORD
|
||||
* DB_HOST
|
||||
* DB_PORT
|
||||
* SECRET_KEY
|
||||
* EMAIL_SERVER
|
||||
* EMAIL_PORT
|
||||
* EMAIL_USERNAME
|
||||
* EMAIL_PASSWORD
|
||||
* EMAIL_TIMEOUT
|
||||
* EMAIL_FROM
|
||||
* LOGIN_REQUIRED
|
||||
* MAINTENANCE_MODE
|
||||
* NETBOX_USERNAME
|
||||
* NETBOX_PASSWORD
|
||||
* PAGINATE_COUNT
|
||||
* TIME_ZONE
|
||||
* DATE_FORMAT
|
||||
* SHORT_DATE_FORMAT
|
||||
* TIME_FORMAT
|
||||
* SHORT_TIME_FORMAT
|
||||
* DATETIME_FORMAT
|
||||
* SHORT_DATETIME_FORMAT
|
||||
101
docs/installation/ldap.md
Normal file
101
docs/installation/ldap.md
Normal file
@@ -0,0 +1,101 @@
|
||||
This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to
|
||||
built-in Django users in the event of a failure.
|
||||
|
||||
# Requirements
|
||||
|
||||
## Install openldap-devel
|
||||
|
||||
On Ubuntu:
|
||||
|
||||
```
|
||||
sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev
|
||||
```
|
||||
|
||||
On CentOS:
|
||||
|
||||
```
|
||||
sudo yum install -y python-devel openldap-devel
|
||||
```
|
||||
|
||||
## Install django-auth-ldap
|
||||
|
||||
```
|
||||
sudo pip install django-auth-ldap
|
||||
```
|
||||
|
||||
# Configuration
|
||||
|
||||
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`.
|
||||
|
||||
## General Server Configuration
|
||||
|
||||
```python
|
||||
import ldap
|
||||
|
||||
# Server URI
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com"
|
||||
|
||||
# The following may be needed if you are binding to Active Directory.
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0
|
||||
}
|
||||
|
||||
# Set the DN and password for the NetBox service account.
|
||||
AUTH_LDAP_BIND_DN = "CN=NETBOXSA, OU=Service Accounts,DC=example,DC=com"
|
||||
AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
|
||||
# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
LDAP_IGNORE_CERT_ERRORS = True
|
||||
```
|
||||
|
||||
## User Authentication
|
||||
|
||||
```python
|
||||
from django_auth_ldap.config import LDAPSearch
|
||||
|
||||
# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
|
||||
# username is not in their DN (Active Directory).
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(sAMAccountName=%(user)s)")
|
||||
|
||||
# If a user's DN is producible from their username, we don't need to search.
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com"
|
||||
|
||||
# You can map user attributes to Django attributes as so.
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn"
|
||||
}
|
||||
```
|
||||
|
||||
# User Groups for Permissions
|
||||
|
||||
```python
|
||||
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
|
||||
|
||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||
# heirarchy.
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)")
|
||||
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
|
||||
|
||||
# Define a group required to login.
|
||||
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
|
||||
|
||||
# Define special user types using groups. Exercise great caution when assigning superuser status.
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": "cn=active,ou=groups,dc=example,dc=com",
|
||||
"is_staff": "cn=staff,ou=groups,dc=example,dc=com",
|
||||
"is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
|
||||
}
|
||||
|
||||
# For more granular permissions, we can map LDAP groups to Django groups.
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Cache groups for one hour to reduce LDAP traffic
|
||||
AUTH_LDAP_CACHE_GROUPS = True
|
||||
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
|
||||
```
|
||||
184
docs/installation/netbox.md
Normal file
184
docs/installation/netbox.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Installation
|
||||
|
||||
NetBox requires following system dependencies:
|
||||
|
||||
* python2.7
|
||||
* python-dev
|
||||
* python-pip
|
||||
* libxml2-dev
|
||||
* libxslt1-dev
|
||||
* libffi-dev
|
||||
* graphviz
|
||||
* libpq-dev
|
||||
|
||||
```
|
||||
# sudo apt-get install -y python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev
|
||||
```
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
|
||||
## Option A: Download a Release
|
||||
|
||||
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive and 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-X.Y.Z/ 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`.
|
||||
|
||||
```
|
||||
# mkdir -p /opt/netbox/
|
||||
# cd /opt/netbox/
|
||||
```
|
||||
|
||||
If `git` is not already installed, install it:
|
||||
|
||||
```
|
||||
# sudo apt-get install -y git
|
||||
```
|
||||
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
|
||||
|
||||
```
|
||||
# git clone -b master https://github.com/digitalocean/netbox.git .
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
remote: Compressing objects: 100% (150/150), done.
|
||||
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
|
||||
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
|
||||
Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
```
|
||||
|
||||
## Install Python Packages
|
||||
|
||||
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
|
||||
|
||||
```
|
||||
# sudo pip install -r requirements.txt
|
||||
```
|
||||
|
||||
# Configuration
|
||||
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
|
||||
|
||||
```
|
||||
# cd netbox/netbox/
|
||||
# cp configuration.example.py configuration.py
|
||||
```
|
||||
|
||||
Open `configuration.py` with your preferred editor and set the following variables:
|
||||
|
||||
* ALLOWED_HOSTS
|
||||
* DATABASE
|
||||
* SECRET_KEY
|
||||
|
||||
## ALLOWED_HOSTS
|
||||
|
||||
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||
```
|
||||
|
||||
## DATABASE
|
||||
|
||||
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
DATABASE = {
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
}
|
||||
```
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
|
||||
|
||||
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
|
||||
|
||||
!!! note
|
||||
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
|
||||
|
||||
# Run Database Migrations
|
||||
|
||||
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
|
||||
|
||||
```
|
||||
# cd /opt/netbox/netbox/
|
||||
# ./manage.py migrate
|
||||
Operations to perform:
|
||||
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
|
||||
Running migrations:
|
||||
Rendering model states... DONE
|
||||
Applying contenttypes.0001_initial... OK
|
||||
Applying auth.0001_initial... OK
|
||||
Applying admin.0001_initial... OK
|
||||
...
|
||||
```
|
||||
|
||||
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
|
||||
|
||||
# Create a Super User
|
||||
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
|
||||
|
||||
```
|
||||
# ./manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
Password:
|
||||
Password (again):
|
||||
Superuser created successfully.
|
||||
```
|
||||
|
||||
# Collect Static Files
|
||||
|
||||
```
|
||||
# ./manage.py collectstatic
|
||||
|
||||
You have requested to collect static files at the destination
|
||||
location as specified in your settings:
|
||||
|
||||
/opt/netbox/netbox/static
|
||||
|
||||
This will overwrite existing files!
|
||||
Are you sure you want to do this?
|
||||
|
||||
Type 'yes' to continue, or 'no' to cancel: yes
|
||||
```
|
||||
|
||||
# Test the Application
|
||||
|
||||
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
||||
|
||||
```
|
||||
# ./manage.py runserver 0.0.0.0:8000 --insecure
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
June 17, 2016 - 16:17:36
|
||||
Django version 1.9.7, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
|
||||
|
||||
!!! warning
|
||||
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
|
||||
42
docs/installation/postgresql.md
Normal file
42
docs/installation/postgresql.md
Normal file
@@ -0,0 +1,42 @@
|
||||
NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).
|
||||
|
||||
# Installation
|
||||
|
||||
The following packages are needed to install PostgreSQL with Python support:
|
||||
|
||||
* postgresql
|
||||
* libpq-dev
|
||||
* python-psycopg2
|
||||
|
||||
```
|
||||
# sudo apt-get install -y postgresql libpq-dev python-psycopg2
|
||||
```
|
||||
|
||||
# Configuration
|
||||
|
||||
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
|
||||
|
||||
!!! danger
|
||||
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
|
||||
|
||||
```
|
||||
# sudo -u postgres psql
|
||||
psql (9.3.13)
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
CREATE DATABASE
|
||||
postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
CREATE ROLE
|
||||
postgres=# GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
GRANT
|
||||
postgres=# \q
|
||||
```
|
||||
|
||||
You can verify that authentication works issuing the following command and providing the configured password:
|
||||
|
||||
```
|
||||
# psql -U netbox -h localhost -W
|
||||
```
|
||||
|
||||
If successful, you will enter a `postgres` prompt. Type `\q` to exit.
|
||||
61
docs/installation/upgrading.md
Normal file
61
docs/installation/upgrading.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Install the Latest Code
|
||||
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
|
||||
Download and extract the latest version:
|
||||
|
||||
```
|
||||
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -sf netbox-X.Y.Z/ netbox
|
||||
```
|
||||
|
||||
Copy the 'configuration.py' you created when first installing to the new version:
|
||||
|
||||
```
|
||||
# cp /opt/netbox-X.Y.Z/configuration.py /opt/netbox/configuration.py
|
||||
```
|
||||
|
||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||
|
||||
```
|
||||
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
|
||||
```
|
||||
|
||||
## Option B: Clone the Git Repository (latest master release)
|
||||
|
||||
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
|
||||
|
||||
```
|
||||
# cd /opt/netbox
|
||||
# git checkout master
|
||||
# git pull origin master
|
||||
# git status
|
||||
```
|
||||
|
||||
# Run the Upgrade Script
|
||||
|
||||
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
|
||||
|
||||
# Restart the WSGI 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
|
||||
```
|
||||
132
docs/installation/web-server.md
Normal file
132
docs/installation/web-server.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Web Server Installation
|
||||
|
||||
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
|
||||
|
||||
```
|
||||
# sudo apt-get install -y gunicorn supervisor
|
||||
```
|
||||
|
||||
## Option A: nginx
|
||||
|
||||
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
|
||||
|
||||
```
|
||||
# sudo apt-get install -y nginx
|
||||
```
|
||||
|
||||
Once nginx is installed, proceed with the following configuration:
|
||||
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name netbox.example.com;
|
||||
|
||||
access_log off;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/netbox/netbox/static/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
|
||||
|
||||
```
|
||||
# cd /etc/nginx/sites-enabled/
|
||||
# rm default
|
||||
# ln -s /etc/nginx/sites-available/netbox
|
||||
```
|
||||
|
||||
Restart the nginx service to use the new configuration.
|
||||
|
||||
```
|
||||
# service nginx restart
|
||||
* Restarting nginx nginx
|
||||
```
|
||||
|
||||
To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04).
|
||||
|
||||
## Option B: Apache
|
||||
|
||||
```
|
||||
# sudo apt-get install -y apache2
|
||||
```
|
||||
|
||||
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
|
||||
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ProxyPreserveHost On
|
||||
|
||||
ServerName netbox.example.com
|
||||
|
||||
Alias /static /opt/netbox/netbox/static
|
||||
|
||||
<Directory /opt/netbox/netbox/static>
|
||||
Options Indexes FollowSymLinks MultiViews
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Location /static>
|
||||
ProxyPass !
|
||||
</Location>
|
||||
|
||||
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`, enable the `proxy` and `proxy_http` modules, and reload Apache:
|
||||
|
||||
```
|
||||
# a2enmod proxy
|
||||
# a2enmod proxy_http
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
||||
To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-14-04).
|
||||
|
||||
# gunicorn Installation
|
||||
|
||||
Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed.
|
||||
|
||||
```
|
||||
command = '/usr/bin/gunicorn'
|
||||
pythonpath = '/opt/netbox/netbox'
|
||||
bind = '127.0.0.1:8001'
|
||||
workers = 3
|
||||
user = 'www-data'
|
||||
```
|
||||
|
||||
# supervisord Installation
|
||||
|
||||
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
|
||||
|
||||
```
|
||||
[program:netbox]
|
||||
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
|
||||
directory = /opt/netbox/netbox/
|
||||
user = www-data
|
||||
```
|
||||
|
||||
Finally, restart the supervisor service to detect and run the gunicorn service:
|
||||
|
||||
```
|
||||
# service supervisor restart
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
!!! info
|
||||
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.
|
||||
BIN
docs/media/screenshot1.png
Normal file
BIN
docs/media/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/media/screenshot2.png
Normal file
BIN
docs/media/screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
BIN
docs/media/screenshot3.png
Normal file
BIN
docs/media/screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
4391
docs/schema.sql
4391
docs/schema.sql
File diff suppressed because it is too large
Load Diff
24
mkdocs.yml
Normal file
24
mkdocs.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
site_name: NetBox
|
||||
|
||||
pages:
|
||||
- 'Introduction': 'index.md'
|
||||
- 'Installation':
|
||||
- 'PostgreSQL': 'installation/postgresql.md'
|
||||
- 'NetBox': 'installation/netbox.md'
|
||||
- 'Web Server': 'installation/web-server.md'
|
||||
- 'LDAP (Optional)': 'installation/ldap.md'
|
||||
- 'Upgrading': 'installation/upgrading.md'
|
||||
- 'Alternate Install: Docker': 'installation/docker.md'
|
||||
- 'Configuration':
|
||||
- 'Mandatory Settings': 'configuration/mandatory-settings.md'
|
||||
- 'Optional Settings': 'configuration/optional-settings.md'
|
||||
- 'Data Model':
|
||||
- 'Circuits': 'data-model/circuits.md'
|
||||
- 'DCIM': 'data-model/dcim.md'
|
||||
- 'IPAM': 'data-model/ipam.md'
|
||||
- 'Secrets': 'data-model/secrets.md'
|
||||
- 'Extras': 'data-model/extras.md'
|
||||
- 'API Integration': 'api-integration.md'
|
||||
|
||||
markdown_extensions:
|
||||
- admonition:
|
||||
@@ -1,9 +1,40 @@
|
||||
import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Site
|
||||
from .models import Provider, Circuit, CircuitType
|
||||
|
||||
|
||||
class ProviderFilter(django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='circuits__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='circuits__site',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['q', 'name', 'account', 'asn']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(account__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CircuitFilter(django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
|
||||
@@ -59,6 +59,16 @@ class ProviderBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def provider_site_choices():
|
||||
site_choices = Site.objects.all()
|
||||
return [(s.slug, s.name) for s in site_choices]
|
||||
|
||||
|
||||
class ProviderFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
|
||||
|
||||
#
|
||||
# Circuit types
|
||||
#
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-13 19:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import dcim.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0002_auto_20160622_1821'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='asn',
|
||||
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Site, Interface
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
@@ -12,7 +13,7 @@ class Provider(CreatedUpdatedModel):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
|
||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||
account = models.CharField(max_length=30, blank=True, verbose_name='Account number')
|
||||
portal_url = models.URLField(blank=True, verbose_name='Portal')
|
||||
noc_contact = models.TextField(blank=True, verbose_name='NOC contact')
|
||||
|
||||
@@ -16,6 +16,8 @@ from .models import Circuit, CircuitType, Provider
|
||||
|
||||
class ProviderListView(ObjectListView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
|
||||
filter = filters.ProviderFilter
|
||||
filter_form = forms.ProviderFilterForm
|
||||
table = tables.ProviderTable
|
||||
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
|
||||
template_name = 'circuits/provider_list.html'
|
||||
|
||||
@@ -1 +1 @@
|
||||
default_app_config = 'dcim.apps.IPAMConfig'
|
||||
default_app_config = 'dcim.apps.DCIMConfig'
|
||||
|
||||
@@ -2,9 +2,9 @@ from django.contrib import admin
|
||||
from django.db.models import Count
|
||||
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
|
||||
Interface, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, Site,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ class InterfaceTemplateAdmin(admin.TabularInline):
|
||||
model = InterfaceTemplate
|
||||
|
||||
|
||||
class DeviceBayTemplateAdmin(admin.TabularInline):
|
||||
model = DeviceBayTemplate
|
||||
|
||||
|
||||
@admin.register(DeviceType)
|
||||
class DeviceTypeAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {
|
||||
@@ -72,9 +76,10 @@ class DeviceTypeAdmin(admin.ModelAdmin):
|
||||
PowerPortTemplateAdmin,
|
||||
PowerOutletTemplateAdmin,
|
||||
InterfaceTemplateAdmin,
|
||||
DeviceBayTemplateAdmin,
|
||||
]
|
||||
list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
|
||||
'power_outlets', 'interfaces']
|
||||
'power_outlets', 'interfaces', 'device_bays']
|
||||
list_filter = ['manufacturer']
|
||||
|
||||
def get_queryset(self, request):
|
||||
@@ -84,6 +89,7 @@ class DeviceTypeAdmin(admin.ModelAdmin):
|
||||
power_port_count=Count('power_port_templates', distinct=True),
|
||||
power_outlet_count=Count('power_outlet_templates', distinct=True),
|
||||
interface_count=Count('interface_templates', distinct=True),
|
||||
devicebay_count=Count('device_bay_templates', distinct=True),
|
||||
)
|
||||
|
||||
def console_ports(self, instance):
|
||||
@@ -101,6 +107,9 @@ class DeviceTypeAdmin(admin.ModelAdmin):
|
||||
def interfaces(self, instance):
|
||||
return instance.interface_count
|
||||
|
||||
def device_bays(self, instance):
|
||||
return instance.devicebay_count
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
@@ -144,6 +153,12 @@ class InterfaceAdmin(admin.TabularInline):
|
||||
model = Interface
|
||||
|
||||
|
||||
class DeviceBayAdmin(admin.TabularInline):
|
||||
model = DeviceBay
|
||||
fk_name = 'device'
|
||||
readonly_fields = ['installed_device']
|
||||
|
||||
|
||||
class ModuleAdmin(admin.TabularInline):
|
||||
model = Module
|
||||
readonly_fields = ['parent', 'discovered']
|
||||
@@ -157,6 +172,7 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
PowerPortAdmin,
|
||||
PowerOutletAdmin,
|
||||
InterfaceAdmin,
|
||||
DeviceBayAdmin,
|
||||
ModuleAdmin,
|
||||
]
|
||||
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
|
||||
@@ -164,4 +180,4 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(DeviceAdmin, self).get_queryset(request)
|
||||
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip', 'rack')
|
||||
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')
|
||||
|
||||
@@ -2,9 +2,9 @@ from rest_framework import serializers
|
||||
|
||||
from ipam.models import IPAddress
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceType, DeviceRole,
|
||||
Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate,
|
||||
PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
|
||||
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -221,16 +221,33 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
platform = PlatformNestedSerializer()
|
||||
rack = RackNestedSerializer()
|
||||
primary_ip = DeviceIPAddressNestedSerializer()
|
||||
primary_ip4 = DeviceIPAddressNestedSerializer()
|
||||
primary_ip6 = DeviceIPAddressNestedSerializer()
|
||||
parent_device = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
|
||||
'face', 'status', 'primary_ip', 'comments']
|
||||
'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
try:
|
||||
device_bay = obj.parent_bay
|
||||
except DeviceBay.DoesNotExist:
|
||||
return None
|
||||
return {
|
||||
'id': device_bay.device.pk,
|
||||
'name': device_bay.device.name,
|
||||
'device_bay': {
|
||||
'id': device_bay.pk,
|
||||
'name': device_bay.name,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DeviceNestedSerializer(DeviceSerializer):
|
||||
class DeviceNestedSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name', 'display_name']
|
||||
|
||||
@@ -319,7 +336,7 @@ class InterfaceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected']
|
||||
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected']
|
||||
|
||||
|
||||
class InterfaceNestedSerializer(InterfaceSerializer):
|
||||
@@ -333,10 +350,36 @@ class InterfaceDetailSerializer(InterfaceSerializer):
|
||||
connected_interface = InterfaceSerializer(source='get_connected_interface')
|
||||
|
||||
class Meta(InterfaceSerializer.Meta):
|
||||
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected',
|
||||
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',
|
||||
'connected_interface']
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBaySerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'device', 'name']
|
||||
|
||||
|
||||
class DeviceBayNestedSerializer(DeviceBaySerializer):
|
||||
installed_device = DeviceNestedSerializer()
|
||||
|
||||
class Meta(DeviceBaySerializer.Meta):
|
||||
fields = ['id', 'name', 'installed_device']
|
||||
|
||||
|
||||
class DeviceBayDetailSerializer(DeviceBaySerializer):
|
||||
installed_device = DeviceNestedSerializer()
|
||||
|
||||
class Meta(DeviceBaySerializer.Meta):
|
||||
fields = ['id', 'device', 'name', 'installed_device']
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
@@ -49,6 +49,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
|
||||
url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
|
||||
|
||||
# Console ports
|
||||
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
|
||||
|
||||
@@ -9,8 +9,8 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, InterfaceConnection,
|
||||
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
|
||||
InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
|
||||
)
|
||||
from dcim import filters
|
||||
from .exceptions import MissingFilterException
|
||||
@@ -194,7 +194,7 @@ class DeviceListView(generics.ListAPIView):
|
||||
List devices (filterable)
|
||||
"""
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
|
||||
.prefetch_related('primary_ip__nat_outside')
|
||||
.prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside')
|
||||
serializer_class = serializers.DeviceSerializer
|
||||
filter_class = filters.DeviceFilter
|
||||
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
|
||||
@@ -326,6 +326,33 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBayListView(generics.ListAPIView):
|
||||
"""
|
||||
List device bays (by device)
|
||||
"""
|
||||
serializer_class = serializers.DeviceBayNestedSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
queryset = DeviceBay.objects.filter(device=device).select_related('installed_device')
|
||||
|
||||
# Filter by type (physical or virtual)
|
||||
iface_type = self.request.query_params.get('type')
|
||||
if iface_type == 'physical':
|
||||
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
|
||||
elif iface_type == 'virtual':
|
||||
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
|
||||
elif iface_type is not None:
|
||||
queryset = queryset.empty()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
#
|
||||
# Live queries
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IPAMConfig(AppConfig):
|
||||
class DCIMConfig(AppConfig):
|
||||
name = "dcim"
|
||||
verbose_name = "DCIM"
|
||||
|
||||
53
netbox/dcim/fields.py
Normal file
53
netbox/dcim/fields.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from netaddr import EUI, mac_unix_expanded
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
|
||||
from .formfields import MACAddressFormField
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
description = "32-bit ASN field"
|
||||
default_validators = [
|
||||
MinValueValidator(1),
|
||||
MaxValueValidator(4294967295),
|
||||
]
|
||||
|
||||
|
||||
class mac_unix_expanded_uppercase(mac_unix_expanded):
|
||||
word_fmt = '%.2X'
|
||||
|
||||
|
||||
class MACAddressField(models.Field):
|
||||
description = "PostgreSQL MAC Address field"
|
||||
|
||||
def python_type(self):
|
||||
return EUI
|
||||
|
||||
def from_db_value(self, value, expression, connection, context):
|
||||
return self.to_python(value)
|
||||
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except ValueError as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr'
|
||||
|
||||
def get_prep_value(self, value):
|
||||
if not value:
|
||||
return None
|
||||
return str(self.to_python(value))
|
||||
|
||||
def form_class(self):
|
||||
return MACAddressFormField
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'form_class': self.form_class()}
|
||||
defaults.update(kwargs)
|
||||
return super(MACAddressField, self).formfield(**defaults)
|
||||
@@ -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',
|
||||
@@ -47,6 +68,12 @@ class RackFilter(django_filters.FilterSet):
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
@@ -95,6 +122,11 @@ class DeviceFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
rack_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Rack group (ID)',
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
|
||||
@@ -1919,7 +1919,8 @@
|
||||
"position": 1,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": 1,
|
||||
"primary_ip4": 1,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -1938,7 +1939,8 @@
|
||||
"position": 17,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": 5,
|
||||
"primary_ip4": 5,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -1957,7 +1959,8 @@
|
||||
"position": 33,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -1976,7 +1979,8 @@
|
||||
"position": 34,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -1995,7 +1999,8 @@
|
||||
"position": 34,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -2014,7 +2019,8 @@
|
||||
"position": 33,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -2033,7 +2039,8 @@
|
||||
"position": 1,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": 3,
|
||||
"primary_ip4": 3,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -2052,7 +2059,8 @@
|
||||
"position": 17,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": 19,
|
||||
"primary_ip4": 19,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -2071,7 +2079,8 @@
|
||||
"position": 42,
|
||||
"face": 0,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -2090,7 +2099,8 @@
|
||||
"position": null,
|
||||
"face": null,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -2109,7 +2119,8 @@
|
||||
"position": null,
|
||||
"face": null,
|
||||
"status": true,
|
||||
"primary_ip": null,
|
||||
"primary_ip4": null,
|
||||
"primary_ip6": null,
|
||||
"comments": ""
|
||||
}
|
||||
},
|
||||
@@ -3419,6 +3430,7 @@
|
||||
"fields": {
|
||||
"device": 3,
|
||||
"name": "em0",
|
||||
"mac_address": "00-00-00-AA-BB-CC",
|
||||
"form_factor": 800,
|
||||
"mgmt_only": true,
|
||||
"description": ""
|
||||
@@ -3772,6 +3784,7 @@
|
||||
"device": 4,
|
||||
"name": "em0",
|
||||
"form_factor": 1000,
|
||||
"mac_address": "ff-ee-dd-33-22-11",
|
||||
"mgmt_only": true,
|
||||
"description": ""
|
||||
}
|
||||
@@ -5686,6 +5699,7 @@
|
||||
"device": 9,
|
||||
"name": "eth0",
|
||||
"form_factor": 1000,
|
||||
"mac_address": "44-55-66-77-88-99",
|
||||
"mgmt_only": true,
|
||||
"description": ""
|
||||
}
|
||||
|
||||
26
netbox/dcim/formfields.py
Normal file
26
netbox/dcim/formfields.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from netaddr import EUI, AddrFormatError
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
#
|
||||
# Form fields
|
||||
#
|
||||
|
||||
class MACAddressFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid MAC address.",
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, EUI):
|
||||
return value
|
||||
|
||||
try:
|
||||
return EUI(value, version=48)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid MAC address.")
|
||||
@@ -10,10 +10,10 @@ from utilities.forms import (
|
||||
)
|
||||
|
||||
from .models import (
|
||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate,
|
||||
ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_VIRTUAL,
|
||||
InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES
|
||||
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
|
||||
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
)
|
||||
|
||||
|
||||
@@ -138,24 +138,23 @@ class RackForm(forms.ModelForm, BootstrapMixin):
|
||||
class RackFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Group not found.'})
|
||||
group_name = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'u_height']
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'u_height']
|
||||
|
||||
def clean(self):
|
||||
|
||||
site = self.cleaned_data.get('site')
|
||||
group = self.cleaned_data.get('group')
|
||||
group = self.cleaned_data.get('group_name')
|
||||
|
||||
# Validate device type
|
||||
# Validate rack group
|
||||
if site and group:
|
||||
try:
|
||||
self.instance.group = RackGroup.objects.get(site=site, name=group)
|
||||
except RackGroup.DoesNotExist:
|
||||
self.add_error('group', "Invalid rack group ({})".format(group))
|
||||
self.add_error('group_name', "Invalid rack group ({})".format(group))
|
||||
|
||||
|
||||
class RackImportForm(BulkImportForm, BootstrapMixin):
|
||||
@@ -187,7 +186,7 @@ def rack_group_choices():
|
||||
class RackFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices,
|
||||
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
|
||||
|
||||
@@ -217,7 +216,7 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
|
||||
'is_network_device']
|
||||
'is_network_device', 'subdevice_role']
|
||||
|
||||
|
||||
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
|
||||
@@ -284,6 +283,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name_pattern', 'form_factor', 'mgmt_only']
|
||||
|
||||
|
||||
class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['name_pattern']
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
@@ -327,13 +334,13 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'position'}
|
||||
))
|
||||
position = forms.TypedChoiceField(required=False, empty_value=None, widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
|
||||
disabled_indicator='device',
|
||||
))
|
||||
position = forms.TypedChoiceField(required=False, empty_value=None,
|
||||
help_text="For multi-U devices, this is the lowest occupied rack unit.",
|
||||
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
|
||||
disabled_indicator='device'))
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
|
||||
widget=forms.Select(attrs={'filter-for': 'device_type'}))
|
||||
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Model', widget=APISelect(
|
||||
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Device type', widget=APISelect(
|
||||
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
|
||||
display_field='model'
|
||||
))
|
||||
@@ -342,7 +349,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
|
||||
'platform', 'primary_ip', 'comments']
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
'serial': "Chassis serial number",
|
||||
@@ -362,20 +369,23 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
self.initial['site'] = self.instance.rack.site
|
||||
self.initial['manufacturer'] = self.instance.device_type.manufacturer
|
||||
|
||||
# Compile list of IPs assigned to this device
|
||||
primary_ip_choices = []
|
||||
interface_ips = IPAddress.objects.filter(interface__device=self.instance)
|
||||
primary_ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
nat_ips = IPAddress.objects.filter(nat_inside__interface__device=self.instance)\
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = []
|
||||
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
|
||||
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
|
||||
.select_related('nat_inside__interface')
|
||||
primary_ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
self.fields['primary_ip'].choices = [(None, '---------')] + primary_ip_choices
|
||||
ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
|
||||
|
||||
else:
|
||||
|
||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||
self.fields['primary_ip'].choices = []
|
||||
self.fields['primary_ip'].widget.attrs['readonly'] = True
|
||||
self.fields['primary_ip4'].choices = []
|
||||
self.fields['primary_ip4'].widget.attrs['readonly'] = True
|
||||
self.fields['primary_ip6'].choices = []
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
# Limit rack choices
|
||||
if self.is_bound:
|
||||
@@ -387,10 +397,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:
|
||||
@@ -425,7 +438,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
|
||||
@@ -444,7 +457,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:
|
||||
@@ -455,11 +468,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):
|
||||
@@ -485,6 +502,11 @@ def device_site_choices():
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
|
||||
|
||||
|
||||
def device_rack_group_choices():
|
||||
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
|
||||
return [(g.pk, '{} ({})'.format(g, g.device_count)) for g in group_choices]
|
||||
|
||||
|
||||
def device_role_choices():
|
||||
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices]
|
||||
@@ -503,6 +525,8 @@ def device_platform_choices():
|
||||
class DeviceFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
|
||||
@@ -911,7 +935,7 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['device', 'name', 'form_factor', 'mgmt_only', 'description']
|
||||
fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
@@ -922,7 +946,7 @@ class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
|
||||
fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
|
||||
@@ -1037,20 +1061,29 @@ class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
return
|
||||
|
||||
connection_list = []
|
||||
occupied_interfaces = []
|
||||
|
||||
for i, record in enumerate(records, start=1):
|
||||
form = self.fields['csv'].csv_form(data=record)
|
||||
if form.is_valid():
|
||||
interface_a = Interface.objects.get(device=form.cleaned_data['device_a'],
|
||||
name=form.cleaned_data['interface_a'])
|
||||
if interface_a in occupied_interfaces:
|
||||
raise forms.ValidationError("{} {} found in multiple connections"
|
||||
.format(interface_a.device.name, interface_a.name))
|
||||
interface_b = Interface.objects.get(device=form.cleaned_data['device_b'],
|
||||
name=form.cleaned_data['interface_b'])
|
||||
if interface_b in occupied_interfaces:
|
||||
raise forms.ValidationError("{} {} found in multiple connections"
|
||||
.format(interface_b.device.name, interface_b.name))
|
||||
connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b)
|
||||
if form.cleaned_data['status'] == 'planned':
|
||||
connection.connection_status = CONNECTION_STATUS_PLANNED
|
||||
else:
|
||||
connection.connection_status = CONNECTION_STATUS_CONNECTED
|
||||
connection_list.append(connection)
|
||||
occupied_interfaces.append(interface_a)
|
||||
occupied_interfaces.append(interface_b)
|
||||
else:
|
||||
for field, errors in form.errors.items():
|
||||
for e in errors:
|
||||
@@ -1065,6 +1098,41 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBayForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['device', 'name']
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(forms.Form, BootstrapMixin):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
|
||||
installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
|
||||
help_text="Child devices must first be created within the rack occupied "
|
||||
"by the parent device. Then they can be assigned to a bay.")
|
||||
|
||||
def __init__(self, device_bay, *args, **kwargs):
|
||||
|
||||
super(PopulateDeviceBayForm, self).__init__(*args, **kwargs)
|
||||
|
||||
children_queryset = Device.objects.filter(rack=device_bay.device.rack,
|
||||
parent_bay__isnull=True,
|
||||
device_type__u_height=0,
|
||||
device_type__subdevice_role=SUBDEVICE_ROLE_CHILD)\
|
||||
.exclude(pk=device_bay.device.pk)
|
||||
self.fields['installed_device'].queryset = children_queryset
|
||||
|
||||
|
||||
#
|
||||
# Connections
|
||||
#
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
56
netbox/dcim/migrations/0004_auto_20160701_2049.py
Normal file
56
netbox/dcim/migrations/0004_auto_20160701_2049.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-01 20:49
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0003_auto_20160628_1721'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DeviceBay',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, verbose_name=b'Name')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bays', to='dcim.Device')),
|
||||
('installed_device', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_bay', to='dcim.Device')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['device', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceBayTemplate',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=30)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['device_type', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='subdevice_role',
|
||||
field=models.NullBooleanField(choices=[(None, b'N/A'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebaytemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bay_templates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='devicebaytemplate',
|
||||
unique_together=set([('device_type', 'name')]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='devicebay',
|
||||
unique_together=set([('device', 'name')]),
|
||||
),
|
||||
]
|
||||
26
netbox/dcim/migrations/0005_auto_20160706_1722.py
Normal file
26
netbox/dcim/migrations/0005_auto_20160706_1722.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-06 17:22
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import dcim.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0004_auto_20160701_2049'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='mac_address',
|
||||
field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name=b'MAC Address'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='subdevice_role',
|
||||
field=models.NullBooleanField(choices=[(None, b'None'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'),
|
||||
),
|
||||
]
|
||||
27
netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py
Normal file
27
netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-11 18:40
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0001_initial'),
|
||||
('dcim', '0005_auto_20160706_1722'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='primary_ip4',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name=b'Primary IPv4'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='primary_ip6',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name=b'Primary IPv6'),
|
||||
),
|
||||
]
|
||||
41
netbox/dcim/migrations/0007_device_copy_primary_ip.py
Normal file
41
netbox/dcim/migrations/0007_device_copy_primary_ip.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-11 18:40
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_primary_ip(apps, schema_editor):
|
||||
Device = apps.get_model('dcim', 'Device')
|
||||
for d in Device.objects.select_related('primary_ip'):
|
||||
if not d.primary_ip:
|
||||
continue
|
||||
if d.primary_ip.family == 4:
|
||||
d.primary_ip4 = d.primary_ip
|
||||
elif d.primary_ip.family == 6:
|
||||
d.primary_ip6 = d.primary_ip
|
||||
d.save()
|
||||
|
||||
|
||||
def restore_primary_ip(apps, schema_editor):
|
||||
Device = apps.get_model('dcim', 'Device')
|
||||
for d in Device.objects.select_related('primary_ip4', 'primary_ip6'):
|
||||
if d.primary_ip:
|
||||
continue
|
||||
# Prefer IPv6 over IPv4
|
||||
if d.primary_ip6:
|
||||
d.primary_ip = d.primary_ip6
|
||||
elif d.primary_ip4:
|
||||
d.primary_ip = d.primary_ip4
|
||||
d.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0006_add_device_primary_ip4_ip6'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_primary_ip, restore_primary_ip),
|
||||
]
|
||||
19
netbox/dcim/migrations/0008_device_remove_primary_ip.py
Normal file
19
netbox/dcim/migrations/0008_device_remove_primary_ip.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-11 19:01
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0007_device_copy_primary_ip'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='device',
|
||||
name='primary_ip',
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0009_site_32bit_asn_support.py
Normal file
21
netbox/dcim/migrations/0009_site_32bit_asn_support.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-13 19:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import dcim.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0008_device_remove_primary_ip'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='asn',
|
||||
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
|
||||
),
|
||||
]
|
||||
@@ -1,15 +1,17 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q, ObjectDoesNotExist
|
||||
from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
|
||||
from extras.rpc import RPC_CLIENTS
|
||||
from utilities.fields import NullableCharField
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
from .fields import ASNField, MACAddressField
|
||||
|
||||
RACK_FACE_FRONT = 0
|
||||
RACK_FACE_REAR = 1
|
||||
@@ -18,6 +20,14 @@ RACK_FACE_CHOICES = [
|
||||
[RACK_FACE_REAR, 'Rear'],
|
||||
]
|
||||
|
||||
SUBDEVICE_ROLE_PARENT = True
|
||||
SUBDEVICE_ROLE_CHILD = False
|
||||
SUBDEVICE_ROLE_CHOICES = (
|
||||
(None, 'None'),
|
||||
(SUBDEVICE_ROLE_PARENT, 'Parent'),
|
||||
(SUBDEVICE_ROLE_CHILD, 'Child'),
|
||||
)
|
||||
|
||||
COLOR_TEAL = 'teal'
|
||||
COLOR_GREEN = 'green'
|
||||
COLOR_BLUE = 'blue'
|
||||
@@ -45,14 +55,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 +95,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
|
||||
@@ -91,7 +145,7 @@ class Site(CreatedUpdatedModel):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
facility = models.CharField(max_length=50, blank=True)
|
||||
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
|
||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||
physical_address = models.CharField(max_length=200, blank=True)
|
||||
shipping_address = models.CharField(max_length=200, blank=True)
|
||||
comments = models.TextField(blank=True)
|
||||
@@ -183,6 +237,17 @@ class Rack(CreatedUpdatedModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that Rack is tall enough to house the installed Devices
|
||||
if self.pk:
|
||||
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
|
||||
if top_device:
|
||||
min_height = top_device.position + top_device.device_type.u_height - 1
|
||||
if self.u_height < min_height:
|
||||
raise ValidationError("Rack must be at least {}U tall with currently installed devices."
|
||||
.format(min_height))
|
||||
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
self.site.name,
|
||||
@@ -199,15 +264,16 @@ class Rack(CreatedUpdatedModel):
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.facility_id:
|
||||
return "{} ({})".format(self.name, self.facility_id)
|
||||
return u"{} ({})".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
|
||||
"""
|
||||
|
||||
@@ -218,7 +284,10 @@ 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)):
|
||||
.annotate(devicebay_count=Count('device_bays'))\
|
||||
.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):
|
||||
@@ -322,6 +391,10 @@ class DeviceType(models.Model):
|
||||
help_text="This type of device has power outlets")
|
||||
is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
|
||||
help_text="This type of device has network interfaces")
|
||||
subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status',
|
||||
choices=SUBDEVICE_ROLE_CHOICES,
|
||||
help_text="Parent devices house child devices in device bays. Select "
|
||||
"\"None\" if this device type is neither a parent nor a child.")
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'model']
|
||||
@@ -331,11 +404,40 @@ class DeviceType(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return "{0} {1}".format(self.manufacturer, self.model)
|
||||
return "{} {}".format(self.manufacturer, self.model)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
if not self.is_console_server and self.cs_port_templates.count():
|
||||
raise ValidationError("Must delete all console server port templates associated with this device before "
|
||||
"declassifying it as a console server.")
|
||||
|
||||
if not self.is_pdu and self.power_outlet_templates.count():
|
||||
raise ValidationError("Must delete all power outlet templates associated with this device before "
|
||||
"declassifying it as a PDU.")
|
||||
|
||||
if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count():
|
||||
raise ValidationError("Must delete all non-management-only interface templates associated with this device "
|
||||
"before declassifying it as a network device.")
|
||||
|
||||
if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
|
||||
raise ValidationError("Must delete all device bay templates associated with this device before "
|
||||
"declassifying it as a parent device.")
|
||||
|
||||
if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
|
||||
raise ValidationError("Child device types must be 0U.")
|
||||
|
||||
@property
|
||||
def is_parent_device(self):
|
||||
return bool(self.subdevice_role)
|
||||
|
||||
@property
|
||||
def is_child_device(self):
|
||||
return bool(self.subdevice_role is False)
|
||||
|
||||
|
||||
class ConsolePortTemplate(models.Model):
|
||||
"""
|
||||
@@ -397,6 +499,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.
|
||||
@@ -406,6 +515,23 @@ 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']
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class DeviceBayTemplate(models.Model):
|
||||
"""
|
||||
A template for a DeviceBay to be created for a new parent Device.
|
||||
"""
|
||||
device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=30)
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
unique_together = ['device_type', 'name']
|
||||
@@ -480,8 +606,10 @@ class Device(CreatedUpdatedModel):
|
||||
help_text='Number of the lowest U position occupied by the device')
|
||||
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
||||
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
|
||||
primary_ip = models.OneToOneField('ipam.IPAddress', related_name='primary_for', on_delete=models.SET_NULL,
|
||||
blank=True, null=True, verbose_name='Primary IP')
|
||||
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
|
||||
blank=True, null=True, verbose_name='Primary IPv4')
|
||||
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
|
||||
blank=True, null=True, verbose_name='Primary IPv6')
|
||||
comments = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
@@ -496,12 +624,19 @@ class Device(CreatedUpdatedModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Child devices cannot be assigned to a rack face/unit
|
||||
if self.device_type.is_child_device and (self.face is not None or self.position):
|
||||
raise ValidationError("Child device types cannot be assigned a rack face or position.")
|
||||
|
||||
# Validate position/face combination
|
||||
if self.position and self.face is None:
|
||||
raise ValidationError("Must specify rack face with rack position.")
|
||||
|
||||
# Validate rack space
|
||||
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,
|
||||
@@ -540,6 +675,10 @@ class Device(CreatedUpdatedModel):
|
||||
[Interface(device=self, name=template.name, form_factor=template.form_factor,
|
||||
mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
|
||||
)
|
||||
DeviceBay.objects.bulk_create(
|
||||
[DeviceBay(device=self, name=template.name) for template in
|
||||
self.device_type.device_bay_templates.all()]
|
||||
)
|
||||
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
@@ -560,9 +699,9 @@ class Device(CreatedUpdatedModel):
|
||||
if self.name:
|
||||
return self.name
|
||||
elif self.position:
|
||||
return "{} ({} U{})".format(self.device_type, self.rack.name, self.position)
|
||||
return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position)
|
||||
else:
|
||||
return "{} ({})".format(self.device_type, self.rack.name)
|
||||
return u"{} ({})".format(self.device_type, self.rack.name)
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
@@ -573,6 +712,23 @@ class Device(CreatedUpdatedModel):
|
||||
return self.name
|
||||
return '{{{}}}'.format(self.pk)
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
if settings.PREFER_IPV4 and self.primary_ip4:
|
||||
return self.primary_ip4
|
||||
elif self.primary_ip6:
|
||||
return self.primary_ip6
|
||||
elif self.primary_ip4:
|
||||
return self.primary_ip4
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_children(self):
|
||||
"""
|
||||
Return the set of child Devices installed in DeviceBays within this Device.
|
||||
"""
|
||||
return Device.objects.filter(parent_bay__device=self.pk)
|
||||
|
||||
def get_rpc_client(self):
|
||||
"""
|
||||
Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
|
||||
@@ -697,18 +853,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)
|
||||
@@ -725,6 +871,7 @@ class Interface(models.Model):
|
||||
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=30)
|
||||
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
|
||||
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
|
||||
mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
|
||||
help_text="This interface is used only for out-of-band management")
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
@@ -800,6 +947,33 @@ class InterfaceConnection(models.Model):
|
||||
])
|
||||
|
||||
|
||||
class DeviceBay(models.Model):
|
||||
"""
|
||||
An empty space within a Device which can house a child device
|
||||
"""
|
||||
device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=50, verbose_name='Name')
|
||||
installed_device = models.OneToOneField('Device', related_name='parent_bay', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'name']
|
||||
unique_together = ['device', 'name']
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
raise ValidationError("This type of device ({}) does not support device bays."
|
||||
.format(self.device.device_type))
|
||||
|
||||
# Cannot install a device into itself, obviously
|
||||
if self.device == self.installed_device:
|
||||
raise ValidationError("Cannot install a device into itself.")
|
||||
|
||||
|
||||
class Module(models.Model):
|
||||
"""
|
||||
A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
|
||||
|
||||
@@ -4,8 +4,9 @@ from django_tables2.utils import Accessor
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, InterfaceTemplate,
|
||||
Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
|
||||
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -201,6 +202,19 @@ class InterfaceTemplateTable(tables.Table):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateTable(tables.Table):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ('pk', 'name')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover panel-body',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
@@ -305,5 +319,5 @@ class InterfaceConnectionTable(BaseTable):
|
||||
interface_b = tables.Column(verbose_name='Interface B')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
model = Interface
|
||||
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
|
||||
|
||||
@@ -47,7 +47,7 @@ class SiteTest(APITestCase):
|
||||
graph_fields = [
|
||||
'name',
|
||||
'embed_url',
|
||||
'link',
|
||||
'embed_link',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/sites/'):
|
||||
@@ -315,8 +315,11 @@ class DeviceTest(APITestCase):
|
||||
'rack',
|
||||
'position',
|
||||
'face',
|
||||
'parent_device',
|
||||
'status',
|
||||
'primary_ip',
|
||||
'primary_ip4',
|
||||
'primary_ip6',
|
||||
'comments',
|
||||
]
|
||||
|
||||
@@ -366,6 +369,7 @@ class DeviceTest(APITestCase):
|
||||
'face',
|
||||
'id',
|
||||
'name',
|
||||
'parent_device',
|
||||
'platform_id',
|
||||
'platform_name',
|
||||
'platform_slug',
|
||||
@@ -373,6 +377,10 @@ class DeviceTest(APITestCase):
|
||||
'primary_ip_address',
|
||||
'primary_ip_family',
|
||||
'primary_ip_id',
|
||||
'primary_ip4_address',
|
||||
'primary_ip4_family',
|
||||
'primary_ip4_id',
|
||||
'primary_ip6',
|
||||
'rack_display_name',
|
||||
'rack_facility_id',
|
||||
'rack_id',
|
||||
@@ -527,6 +535,7 @@ class InterfaceTest(APITestCase):
|
||||
'device',
|
||||
'name',
|
||||
'form_factor',
|
||||
'mac_address',
|
||||
'mgmt_only',
|
||||
'description',
|
||||
'is_connected'
|
||||
@@ -539,6 +548,7 @@ class InterfaceTest(APITestCase):
|
||||
'device',
|
||||
'name',
|
||||
'form_factor',
|
||||
'mac_address',
|
||||
'mgmt_only',
|
||||
'description',
|
||||
'is_connected',
|
||||
|
||||
@@ -4,7 +4,8 @@ from secrets.views import secret_add
|
||||
|
||||
from . import views
|
||||
from .models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, PowerPortTemplate, PowerOutletTemplate, InterfaceTemplate,
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
|
||||
InterfaceTemplate,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +71,10 @@ urlpatterns = [
|
||||
name='devicetype_add_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.component_template_delete,
|
||||
{'model': InterfaceTemplate}, name='devicetype_delete_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(),
|
||||
name='devicetype_add_devicebay'),
|
||||
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.component_template_delete,
|
||||
{'model': DeviceBayTemplate}, name='devicetype_delete_devicebay'),
|
||||
|
||||
# Device roles
|
||||
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
|
||||
@@ -125,6 +130,13 @@ urlpatterns = [
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
|
||||
|
||||
# Device bays
|
||||
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
||||
|
||||
# Console/power/interface connections
|
||||
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
|
||||
url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import re
|
||||
from natsort import natsorted
|
||||
from operator import attrgetter
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
@@ -24,8 +26,9 @@ from utilities.views import (
|
||||
from . import filters, forms, tables
|
||||
from .models import (
|
||||
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
|
||||
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,6 +64,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 +79,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,
|
||||
})
|
||||
|
||||
@@ -150,7 +156,8 @@ def rack(request, pk):
|
||||
|
||||
rack = get_object_or_404(Rack, pk=pk)
|
||||
|
||||
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)
|
||||
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)\
|
||||
.select_related('device_type__manufacturer')
|
||||
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
|
||||
|
||||
@@ -254,18 +261,33 @@ def devicetype(request, pk):
|
||||
devicetype = get_object_or_404(DeviceType, pk=pk)
|
||||
|
||||
# Component tables
|
||||
consoleport_table = tables.ConsolePortTemplateTable(ConsolePortTemplate.objects.filter(device_type=devicetype))
|
||||
consoleserverport_table = tables.ConsoleServerPortTemplateTable(ConsoleServerPortTemplate.objects
|
||||
.filter(device_type=devicetype))
|
||||
powerport_table = tables.PowerPortTemplateTable(PowerPortTemplate.objects.filter(device_type=devicetype))
|
||||
poweroutlet_table = tables.PowerOutletTemplateTable(PowerOutletTemplate.objects.filter(device_type=devicetype))
|
||||
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype))
|
||||
consoleport_table = tables.ConsolePortTemplateTable(
|
||||
natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
|
||||
)
|
||||
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
|
||||
natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
|
||||
)
|
||||
powerport_table = tables.PowerPortTemplateTable(
|
||||
natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
|
||||
)
|
||||
poweroutlet_table = tables.PowerOutletTemplateTable(
|
||||
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
|
||||
)
|
||||
mgmt_interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
|
||||
mgmt_only=True))
|
||||
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
|
||||
mgmt_only=False))
|
||||
devicebay_table = tables.DeviceBayTemplateTable(
|
||||
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
|
||||
)
|
||||
if request.user.has_perm('dcim.change_devicetype'):
|
||||
consoleport_table.base_columns['pk'].visible = True
|
||||
consoleserverport_table.base_columns['pk'].visible = True
|
||||
powerport_table.base_columns['pk'].visible = True
|
||||
poweroutlet_table.base_columns['pk'].visible = True
|
||||
mgmt_interface_table.base_columns['pk'].visible = True
|
||||
interface_table.base_columns['pk'].visible = True
|
||||
devicebay_table.base_columns['pk'].visible = True
|
||||
|
||||
return render(request, 'dcim/devicetype.html', {
|
||||
'devicetype': devicetype,
|
||||
@@ -273,7 +295,9 @@ def devicetype(request, pk):
|
||||
'consoleserverport_table': consoleserverport_table,
|
||||
'powerport_table': powerport_table,
|
||||
'poweroutlet_table': poweroutlet_table,
|
||||
'mgmt_interface_table': mgmt_interface_table,
|
||||
'interface_table': interface_table,
|
||||
'devicebay_table': devicebay_table,
|
||||
})
|
||||
|
||||
|
||||
@@ -329,7 +353,7 @@ class ComponentTemplateCreateView(View):
|
||||
return render(request, 'dcim/component_template_add.html', {
|
||||
'devicetype': devicetype,
|
||||
'component_type': self.model._meta.verbose_name,
|
||||
'form': self.form(),
|
||||
'form': self.form(initial=request.GET),
|
||||
'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
|
||||
})
|
||||
|
||||
@@ -353,7 +377,7 @@ class ComponentTemplateCreateView(View):
|
||||
|
||||
if not form.errors:
|
||||
self.model.objects.bulk_create(component_templates)
|
||||
messages.success(request, "Added {} compontent(s) to {}".format(len(component_templates), devicetype))
|
||||
messages.success(request, "Added {} component(s) to {}".format(len(component_templates), devicetype))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.path)
|
||||
else:
|
||||
@@ -392,6 +416,11 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
|
||||
form = forms.InterfaceTemplateForm
|
||||
|
||||
|
||||
class DeviceBayTemplateAddView(ComponentTemplateCreateView):
|
||||
model = DeviceBayTemplate
|
||||
form = forms.DeviceBayTemplateForm
|
||||
|
||||
|
||||
def component_template_delete(request, pk, model):
|
||||
|
||||
devicetype = get_object_or_404(DeviceType, pk=pk)
|
||||
@@ -418,7 +447,7 @@ def component_template_delete(request, pk, model):
|
||||
else:
|
||||
form = ComponentTemplateBulkDeleteForm(initial={'pk': request.POST.getlist('pk')})
|
||||
|
||||
selected_objects = model.objects.filter(pk__in=form.initial.get('pk'))
|
||||
selected_objects = model.objects.filter(pk__in=request.POST.getlist('pk'))
|
||||
if not selected_objects:
|
||||
messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
|
||||
return redirect('dcim:devicetype', pk=devicetype.pk)
|
||||
@@ -488,7 +517,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class DeviceListView(ObjectListView):
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip')
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip4',
|
||||
'primary_ip6')
|
||||
filter = filters.DeviceFilter
|
||||
filter_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
@@ -499,14 +529,26 @@ class DeviceListView(ObjectListView):
|
||||
def device(request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
|
||||
cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
|
||||
power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
|
||||
power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port')
|
||||
console_ports = natsorted(
|
||||
ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
|
||||
)
|
||||
cs_ports = natsorted(
|
||||
ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name')
|
||||
)
|
||||
power_ports = natsorted(
|
||||
PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name')
|
||||
)
|
||||
power_outlets = natsorted(
|
||||
PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
|
||||
)
|
||||
interfaces = Interface.objects.filter(device=device, mgmt_only=False)\
|
||||
.select_related('connected_as_a', 'connected_as_b', 'circuit')
|
||||
mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
|
||||
.select_related('connected_as_a', 'connected_as_b', 'circuit')
|
||||
device_bays = natsorted(
|
||||
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
|
||||
key=attrgetter('name')
|
||||
)
|
||||
|
||||
# Gather any secrets which belong to this device
|
||||
secrets = device.secrets.all()
|
||||
@@ -537,6 +579,7 @@ def device(request, pk):
|
||||
'power_outlets': power_outlets,
|
||||
'interfaces': interfaces,
|
||||
'mgmt_interfaces': mgmt_interfaces,
|
||||
'device_bays': device_bays,
|
||||
'ip_addresses': ip_addresses,
|
||||
'secrets': secrets,
|
||||
'related_devices': related_devices,
|
||||
@@ -547,7 +590,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_device'
|
||||
model = Device
|
||||
form_class = forms.DeviceForm
|
||||
fields_initial = ['site', 'rack', 'position', 'face']
|
||||
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
|
||||
template_name = 'dcim/device_edit.html'
|
||||
cancel_url = 'dcim:device_list'
|
||||
|
||||
@@ -1237,6 +1280,7 @@ def interface_add(request, pk):
|
||||
'device': device.pk,
|
||||
'name': name,
|
||||
'form_factor': form.cleaned_data['form_factor'],
|
||||
'mac_address': form.cleaned_data['mac_address'],
|
||||
'mgmt_only': form.cleaned_data['mgmt_only'],
|
||||
'description': form.cleaned_data['description'],
|
||||
})
|
||||
@@ -1324,6 +1368,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
|
||||
iface_form = forms.InterfaceForm({
|
||||
'device': device.pk,
|
||||
'name': name,
|
||||
'mac_address': form.cleaned_data['mac_address'],
|
||||
'form_factor': form.cleaned_data['form_factor'],
|
||||
'mgmt_only': form.cleaned_data['mgmt_only'],
|
||||
'description': form.cleaned_data['description'],
|
||||
@@ -1339,6 +1384,143 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
|
||||
len(selected_devices)))
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
@permission_required('dcim.add_devicebay')
|
||||
def devicebay_add(request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.DeviceBayCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
device_bays = []
|
||||
for name in form.cleaned_data['name_pattern']:
|
||||
devicebay_form = forms.DeviceBayForm({
|
||||
'device': device.pk,
|
||||
'name': name,
|
||||
})
|
||||
if devicebay_form.is_valid():
|
||||
device_bays.append(devicebay_form.save(commit=False))
|
||||
else:
|
||||
for err in devicebay_form.errors.get('__all__', []):
|
||||
form.add_error('name_pattern', err)
|
||||
|
||||
if not form.errors:
|
||||
DeviceBay.objects.bulk_create(device_bays)
|
||||
messages.success(request, "Added {} device bay(s) to {}".format(len(device_bays), device))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:devicebay_add', pk=device.pk)
|
||||
else:
|
||||
return redirect('dcim:device', pk=device.pk)
|
||||
|
||||
else:
|
||||
form = forms.DeviceBayCreateForm()
|
||||
|
||||
return render(request, 'dcim/devicebay_edit.html', {
|
||||
'device': device,
|
||||
'form': form,
|
||||
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required('dcim.change_devicebay')
|
||||
def devicebay_edit(request, pk):
|
||||
|
||||
devicebay = get_object_or_404(DeviceBay, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.DeviceBayForm(request.POST, instance=devicebay)
|
||||
if form.is_valid():
|
||||
devicebay = form.save()
|
||||
messages.success(request, "Modified {} bay {}".format(devicebay.device.name, devicebay.name))
|
||||
return redirect('dcim:device', pk=devicebay.device.pk)
|
||||
|
||||
else:
|
||||
form = forms.DeviceBayForm(instance=devicebay)
|
||||
|
||||
return render(request, 'dcim/devicebay_edit.html', {
|
||||
'devicebay': devicebay,
|
||||
'form': form,
|
||||
'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required('dcim.delete_devicebay')
|
||||
def devicebay_delete(request, pk):
|
||||
|
||||
devicebay = get_object_or_404(DeviceBay, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
devicebay.delete()
|
||||
messages.success(request, "Device bay {} has been deleted from {}".format(devicebay, devicebay.device))
|
||||
return redirect('dcim:device', pk=devicebay.device.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'dcim/devicebay_delete.html', {
|
||||
'devicebay': devicebay,
|
||||
'form': form,
|
||||
'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required('dcim.change_devicebay')
|
||||
def devicebay_populate(request, pk):
|
||||
|
||||
device_bay = get_object_or_404(DeviceBay, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
device_bay.installed_device = form.cleaned_data['installed_device']
|
||||
device_bay.save()
|
||||
|
||||
if not form.errors:
|
||||
messages.success(request, "Added {} to {}".format(device_bay.installed_device, device_bay))
|
||||
return redirect('dcim:device', pk=device_bay.device.pk)
|
||||
|
||||
else:
|
||||
form = forms.PopulateDeviceBayForm(device_bay)
|
||||
|
||||
return render(request, 'dcim/devicebay_populate.html', {
|
||||
'device_bay': device_bay,
|
||||
'form': form,
|
||||
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required('dcim.change_devicebay')
|
||||
def devicebay_depopulate(request, pk):
|
||||
|
||||
device_bay = get_object_or_404(DeviceBay, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
removed_device = device_bay.installed_device
|
||||
device_bay.installed_device = None
|
||||
device_bay.save()
|
||||
messages.success(request, "{} has been removed from {}".format(removed_device, device_bay))
|
||||
return redirect('dcim:device', pk=device_bay.device.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'dcim/devicebay_depopulate.html', {
|
||||
'device_bay': device_bay,
|
||||
'form': form,
|
||||
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
@@ -1480,7 +1662,10 @@ def ipaddress_assign(request, pk):
|
||||
ipaddress.interface))
|
||||
|
||||
if form.cleaned_data['set_as_primary']:
|
||||
device.primary_ip = ipaddress
|
||||
if ipaddress.family == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
elif ipaddress.family == 6:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
@@ -1514,6 +1699,9 @@ def module_add(request, pk):
|
||||
module.device = device
|
||||
module.save()
|
||||
messages.success(request, "Added module {} to {}".format(module.name, module.device.name))
|
||||
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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -10,6 +10,11 @@ from .lookups import (
|
||||
)
|
||||
|
||||
|
||||
def prefix_validator(prefix):
|
||||
if prefix.ip != prefix.cidr.ip:
|
||||
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
|
||||
|
||||
|
||||
class BaseIPField(models.Field):
|
||||
|
||||
def python_type(self):
|
||||
@@ -45,6 +50,7 @@ class IPNetworkField(BaseIPField):
|
||||
IP prefix (network and mask)
|
||||
"""
|
||||
description = "PostgreSQL CIDR field"
|
||||
default_validators = [prefix_validator]
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'cidr'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -324,7 +329,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class IPAddressFromCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
error_messages={'invalid_choice': 'VRF not found.'})
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found.'})
|
||||
interface_name = forms.CharField(required=False)
|
||||
@@ -363,7 +368,10 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
name=self.cleaned_data['interface_name'])
|
||||
# Set as primary for device
|
||||
if self.cleaned_data['is_primary']:
|
||||
self.instance.primary_for = self.cleaned_data['device']
|
||||
if self.instance.family == 4:
|
||||
self.instance.primary_ip4_for = self.cleaned_data['device']
|
||||
elif self.instance.family == 6:
|
||||
self.instance.primary_ip6_for = self.cleaned_data['device']
|
||||
|
||||
return super(IPAddressFromCSVForm, self).save(commit=commit)
|
||||
|
||||
@@ -444,6 +452,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)
|
||||
|
||||
|
||||
|
||||
@@ -121,6 +121,14 @@ class Aggregate(CreatedUpdatedModel):
|
||||
raise ValidationError("{} is already covered by an existing aggregate ({})"
|
||||
.format(self.prefix, covering_aggregates[0]))
|
||||
|
||||
# Ensure that the aggregate being added does not cover an existing aggregate
|
||||
covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
|
||||
if self.pk:
|
||||
covered_aggregates = covered_aggregates.exclude(pk=self.pk)
|
||||
if covered_aggregates:
|
||||
raise ValidationError("{} is overlaps with an existing aggregate ({})"
|
||||
.format(self.prefix, covered_aggregates[0]))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.prefix:
|
||||
# Infer address family from IPNetwork object
|
||||
@@ -308,12 +316,20 @@ class IPAddress(CreatedUpdatedModel):
|
||||
super(IPAddress, self).save(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
|
||||
# Determine if this IP is primary for a Device
|
||||
is_primary = False
|
||||
if self.family == 4 and getattr(self, 'primary_ip4_for', False):
|
||||
is_primary = True
|
||||
elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
|
||||
is_primary = True
|
||||
|
||||
return ','.join([
|
||||
str(self.address),
|
||||
self.vrf.rd if self.vrf else '',
|
||||
self.device.identifier if self.device else '',
|
||||
self.interface.name if self.interface else '',
|
||||
'True' if getattr(self, 'primary_for', False) else '',
|
||||
'True' if is_primary else '',
|
||||
self.description,
|
||||
])
|
||||
|
||||
@@ -361,7 +377,7 @@ class VLAN(CreatedUpdatedModel):
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return "{} ({})".format(self.vid, self.name)
|
||||
return u"{} ({})".format(self.vid, self.name)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
@@ -364,7 +364,7 @@ def prefix_ipaddresses(request, pk):
|
||||
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\
|
||||
.select_related('vrf', 'interface__device', 'primary_for')
|
||||
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
ip_table.model = IPAddress
|
||||
@@ -383,7 +383,7 @@ def prefix_ipaddresses(request, pk):
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_for')
|
||||
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
|
||||
filter = filters.IPAddressFilter
|
||||
filter_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressTable
|
||||
@@ -395,16 +395,24 @@ def ipaddress(request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
|
||||
related_ips = IPAddress.objects.select_related('interface__device').exclude(pk=ipaddress.pk)\
|
||||
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
|
||||
parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes)
|
||||
|
||||
# Duplicate IPs table
|
||||
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
|
||||
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
|
||||
duplicate_ips_table = tables.IPAddressBriefTable(duplicate_ips)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
|
||||
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
|
||||
related_ips_table = tables.IPAddressBriefTable(related_ips)
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(related_ips_table)
|
||||
|
||||
return render(request, 'ipam/ipaddress.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'parent_prefixes': parent_prefixes,
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'related_ips_table': related_ips_table,
|
||||
})
|
||||
|
||||
@@ -435,8 +443,13 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
obj.save()
|
||||
# Update primary IP for device if needed
|
||||
try:
|
||||
device = obj.primary_for
|
||||
device.primary_ip = obj
|
||||
if obj.family == 4 and obj.primary_ip4_for:
|
||||
device = obj.primary_ip4_for
|
||||
device.primary_ip4 = obj
|
||||
device.save()
|
||||
elif obj.family == 6 and obj.primary_ip6_for:
|
||||
device = obj.primary_ip6_for
|
||||
device.primary_ip6 = obj
|
||||
device.save()
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
|
||||
75
netbox/netbox/configuration.docker.py
Normal file
75
netbox/netbox/configuration.docker.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import os
|
||||
#########################
|
||||
# #
|
||||
# Required settings #
|
||||
# #
|
||||
#########################
|
||||
|
||||
# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write
|
||||
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
|
||||
#
|
||||
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
|
||||
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')]
|
||||
|
||||
# PostgreSQL database configuration.
|
||||
DATABASE = {
|
||||
'NAME': os.environ.get('DB_NAME', 'netbox'), # Database name
|
||||
'USER': os.environ.get('DB_USER', ''), # PostgreSQL username
|
||||
'PASSWORD': os.environ.get('DB_PASSWORD', ''), # PostgreSQL password
|
||||
'HOST': os.environ.get('DB_HOST', 'localhost'), # Database server
|
||||
'PORT': os.environ.get('DB_PORT', ''), # Database port (leave blank for default)
|
||||
}
|
||||
|
||||
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
|
||||
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
|
||||
# symbols. NetBox will not run without this defined. For more information, see
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', '')
|
||||
|
||||
#########################
|
||||
# #
|
||||
# Optional settings #
|
||||
# #
|
||||
#########################
|
||||
|
||||
# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of
|
||||
# application errors (assuming correct email settings are provided).
|
||||
ADMINS = [
|
||||
# ['John Doe', 'jdoe@example.com'],
|
||||
]
|
||||
|
||||
# Email settings
|
||||
EMAIL = {
|
||||
'SERVER': os.environ.get('EMAIL_SERVER', 'localhost'),
|
||||
'PORT': os.environ.get('EMAIL_PORT', 25),
|
||||
'USERNAME': os.environ.get('EMAIL_USERNAME', ''),
|
||||
'PASSWORD': os.environ.get('EMAIL_PASSWORD', ''),
|
||||
'TIMEOUT': os.environ.get('EMAIL_TIMEOUT', 10), # seconds
|
||||
'FROM_EMAIL': os.environ.get('EMAIL_FROM', ''),
|
||||
}
|
||||
|
||||
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
|
||||
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
|
||||
LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False)
|
||||
|
||||
# Setting this to True will display a "maintenance mode" banner at the top of every page.
|
||||
MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False)
|
||||
|
||||
# Credentials that NetBox will use to access live devices.
|
||||
NETBOX_USERNAME = os.environ.get('NETBOX_USERNAME', '')
|
||||
NETBOX_PASSWORD = os.environ.get('NETBOX_PASSWORD', '')
|
||||
|
||||
# Determine how many objects to display per page within a list. (Default: 50)
|
||||
PAGINATE_COUNT = os.environ.get('PAGINATE_COUNT', 50)
|
||||
|
||||
# Time zone (default: UTC)
|
||||
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
|
||||
|
||||
# Date/time formatting. See the following link for supported formats:
|
||||
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
|
||||
DATE_FORMAT = os.environ.get('DATE_FORMAT', 'N j, Y')
|
||||
SHORT_DATE_FORMAT = os.environ.get('SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
TIME_FORMAT = os.environ.get('TIME_FORMAT', 'g:i a')
|
||||
SHORT_TIME_FORMAT = os.environ.get('SHORT_TIME_FORMAT', 'H:i:s')
|
||||
DATETIME_FORMAT = os.environ.get('DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
SHORT_DATETIME_FORMAT = os.environ.get('SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
@@ -73,3 +73,12 @@ TIME_FORMAT = 'g:i a'
|
||||
SHORT_TIME_FORMAT = 'H:i:s'
|
||||
DATETIME_FORMAT = 'N j, Y g:i a'
|
||||
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
|
||||
|
||||
# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
|
||||
# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
|
||||
BANNER_TOP = ''
|
||||
BANNER_BOTTOM = ''
|
||||
|
||||
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
|
||||
# prefer IPv4 instead.
|
||||
PREFER_IPV4 = False
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
@@ -11,6 +12,8 @@ except ImportError:
|
||||
"the documentation.")
|
||||
|
||||
|
||||
VERSION = '1.2.3-dev'
|
||||
|
||||
# Import local configuration
|
||||
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
|
||||
try:
|
||||
@@ -35,8 +38,40 @@ TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
|
||||
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
|
||||
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
# Attempt to import LDAP configuration if it has been defined
|
||||
LDAP_IGNORE_CERT_ERRORS = False
|
||||
try:
|
||||
from ldap_config import *
|
||||
LDAP_CONFIGURED = True
|
||||
except ImportError:
|
||||
LDAP_CONFIGURED = False
|
||||
|
||||
# LDAP configuration (optional)
|
||||
if LDAP_CONFIGURED:
|
||||
try:
|
||||
import ldap
|
||||
import django_auth_ldap
|
||||
# Prepend LDAPBackend to the default ModelBackend
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'django_auth_ldap.backend.LDAPBackend',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
# Optionally disable strict certificate checking
|
||||
if LDAP_IGNORE_CERT_ERRORS:
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
# Enable logging for django_auth_ldap
|
||||
logger = logging.getLogger('django_auth_ldap')
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
logger.setLevel(logging.DEBUG)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
|
||||
"You can remove netbox/ldap.py to disable LDAP.")
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Database
|
||||
@@ -103,7 +138,6 @@ TEMPLATES = [
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'utilities.context_processors.settings',
|
||||
'django.core.context_processors.request',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.defaults import page_not_found
|
||||
|
||||
from views import home, docs, trigger_500
|
||||
from views import home, trigger_500
|
||||
from users.views import login, logout
|
||||
|
||||
|
||||
@@ -30,10 +30,6 @@ urlpatterns = [
|
||||
url(r'^api/docs/', include('rest_framework_swagger.urls')),
|
||||
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
|
||||
# Dcoumentation
|
||||
url(r'^docs/$', docs, kwargs={'path': 'index'}, name='docs_root'),
|
||||
url(r'^docs/(?P<path>[\w-]+)/$', docs, name='docs'),
|
||||
|
||||
# Error testing
|
||||
url(r'^404/$', page_not_found),
|
||||
url(r'^500/$', trigger_500),
|
||||
|
||||
@@ -41,26 +41,7 @@ def home(request):
|
||||
|
||||
return render(request, 'home.html', {
|
||||
'stats': stats,
|
||||
'recent_activity': UserAction.objects.all()[:15]
|
||||
})
|
||||
|
||||
|
||||
def docs(request, path):
|
||||
"""
|
||||
Display a page of Markdown-formatted documentation.
|
||||
"""
|
||||
filename = '{}/docs/{}.md'.format(settings.BASE_DIR.rsplit('/', 1)[0], path)
|
||||
try:
|
||||
with open(filename, 'r') as docfile:
|
||||
markup = docfile.read()
|
||||
except:
|
||||
raise Http404
|
||||
|
||||
content = mark_safe(markdown(markup, extensions=['mdx_gfm', 'toc']))
|
||||
|
||||
return render(request, 'docs.html', {
|
||||
'content': content,
|
||||
'path': path,
|
||||
'recent_activity': UserAction.objects.select_related('user')[:15]
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -9,7 +12,8 @@ body {
|
||||
padding-top: 70px;
|
||||
}
|
||||
.container {
|
||||
width: 1340px;
|
||||
width: auto;
|
||||
max-width: 1340px;
|
||||
}
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
@@ -27,6 +31,42 @@ body {
|
||||
footer p {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@media (max-width: 1120px) {
|
||||
.navbar-header {
|
||||
float: none;
|
||||
}
|
||||
.navbar-left,.navbar-right {
|
||||
float: none !important;
|
||||
}
|
||||
.navbar-toggle {
|
||||
display: block;
|
||||
}
|
||||
.navbar-collapse {
|
||||
border-top: 1px solid transparent;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.navbar-fixed-top {
|
||||
top: 0;
|
||||
border-width: 0 0 1px;
|
||||
}
|
||||
.navbar-collapse.collapse {
|
||||
display: none!important;
|
||||
}
|
||||
.navbar-nav {
|
||||
float: none!important;
|
||||
margin-top: 7.5px;
|
||||
}
|
||||
.navbar-nav>li {
|
||||
float: none;
|
||||
}
|
||||
.navbar-nav>li>a {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.collapse.in {
|
||||
display:block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
label {
|
||||
@@ -258,6 +298,9 @@ ul.rack_near_face li.empty:hover a {
|
||||
.dark_gray:hover { background-color: #2c3e50; }
|
||||
|
||||
/* Misc */
|
||||
.banner-bottom {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.panel table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// "Select all" checkbox in a table header
|
||||
$('th input:checkbox').click(function (event) {
|
||||
$('th input:checkbox[name=_all]').click(function (event) {
|
||||
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
|
||||
});
|
||||
// Uncheck the "select all" checkbox if an item is unchecked
|
||||
$('input:checkbox[name=pk]').click(function (event) {
|
||||
if (!$(this).attr('checked')) {
|
||||
$(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Slugify
|
||||
function slugify(s, num_chars) {
|
||||
s = s.replace(/[^-\.\+\w\s]/g, ''); // Remove unneeded chars
|
||||
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
|
||||
s = s.replace(/^\s+|\s+$/g, ''); // Trim leading/trailing spaces
|
||||
s = s.replace(/[-\s]+/g, '-'); // Convert spaces to hyphens
|
||||
s = s.replace(/[\-\.\s]+/g, '-'); // Convert spaces and decimals to hyphens
|
||||
s = s.toLowerCase(); // Convert to lowercase
|
||||
return s.substring(0, num_chars); // Trim to first num_chars chars
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
@@ -41,7 +42,7 @@ class SecretListView(generics.GenericAPIView):
|
||||
"""
|
||||
List secrets (filterable). If a private key is POSTed, attempt to decrypt each Secret.
|
||||
"""
|
||||
queryset = Secret.objects.select_related('device__primary_ip', 'role')\
|
||||
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\
|
||||
.prefetch_related('role__users', 'role__groups')
|
||||
serializer_class = serializers.SecretSerializer
|
||||
filter_class = SecretFilter
|
||||
@@ -51,7 +52,7 @@ class SecretListView(generics.GenericAPIView):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Attempt to decrypt each Secret if a private key was provided.
|
||||
if private_key is not None:
|
||||
if private_key:
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
@@ -86,7 +87,7 @@ class SecretDetailView(generics.GenericAPIView):
|
||||
"""
|
||||
Retrieve a single Secret. If a private key is POSTed, attempt to decrypt the Secret.
|
||||
"""
|
||||
queryset = Secret.objects.select_related('device__primary_ip', 'role')\
|
||||
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\
|
||||
.prefetch_related('role__users', 'role__groups')
|
||||
serializer_class = serializers.SecretSerializer
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
@@ -95,7 +96,7 @@ class SecretDetailView(generics.GenericAPIView):
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
|
||||
# Attempt to decrypt the Secret if a private key was provided.
|
||||
if private_key is not None:
|
||||
if private_key:
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
@@ -108,7 +109,8 @@ class SecretDetailView(generics.GenericAPIView):
|
||||
{'error': ERR_USERKEY_INACTIVE},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if secret.decryptable_by(request.user):
|
||||
if not secret.decryptable_by(request.user):
|
||||
raise PermissionDenied(detail="You do not have permission to decrypt this secret.")
|
||||
master_key = uk.get_master_key(private_key)
|
||||
if master_key is None:
|
||||
return Response(
|
||||
|
||||
@@ -182,6 +182,14 @@ class SecretRole(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
|
||||
|
||||
def has_member(self, user):
|
||||
"""
|
||||
Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles.
|
||||
"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
|
||||
|
||||
|
||||
class Secret(CreatedUpdatedModel):
|
||||
"""
|
||||
@@ -304,4 +312,4 @@ class Secret(CreatedUpdatedModel):
|
||||
"""
|
||||
Check whether the given user has permission to decrypt this Secret.
|
||||
"""
|
||||
return user in self.role.users.all() or user.groups.filter(pk__in=self.role.groups.all()).exists()
|
||||
return self.role.has_member(user)
|
||||
|
||||
0
netbox/secrets/templatetags/__init__.py
Normal file
0
netbox/secrets/templatetags/__init__.py
Normal file
12
netbox/secrets/templatetags/secret_helpers.py
Normal file
12
netbox/secrets/templatetags/secret_helpers.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter()
|
||||
def decryptable_by(secret, user):
|
||||
"""
|
||||
Determine whether a given User is permitted to decrypt a Secret.
|
||||
"""
|
||||
return secret.decryptable_by(user)
|
||||
@@ -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>
|
||||
|
||||
@@ -13,9 +13,16 @@
|
||||
<nav class="navbar navbar-default navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">NetBox</a>
|
||||
</div>
|
||||
<div id="navbar" class="navbar-collapse collapse">
|
||||
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/sites/' %} active{% endif %}">
|
||||
{% if perms.dcim.add_site %}
|
||||
@@ -201,11 +208,12 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if request.user.is_staff %}
|
||||
<li><a href="{% url 'admin:index' %}"><i class="glyphicon glyphicon-cog" aria-hidden="true"></i> Admin</a></li>
|
||||
{% endif %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<li><a href="{% url 'users:profile' %}"><i class="glyphicon glyphicon-user" aria-hidden="true"></i> Profile</a></li>
|
||||
<li><a href="{% url 'logout' %}"><i class="glyphicon glyphicon-log-out" aria-hidden="true"></i> Log out</a></li>
|
||||
{% else %}
|
||||
@@ -216,10 +224,15 @@
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container wrapper">
|
||||
{% if settings.BANNER_TOP %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
{{ settings.BANNER_TOP|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if settings.MAINTENANCE_MODE %}
|
||||
<div class="alert alert-warning text-center" role="alert">
|
||||
<h4><i class="fa fa-exclamation-triangle"></i> Maintenance Mode</h4>
|
||||
<p>The application is currently in maintenance mode.</p>
|
||||
<p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for message in messages %}
|
||||
@@ -232,21 +245,26 @@
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
<div class="push"></div>
|
||||
{% if settings.BANNER_BOTTOM %}
|
||||
<div class="alert alert-info text-center banner-bottom" role="alert">
|
||||
{{ settings.BANNER_BOTTOM|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<p class="text-muted">{{ settings.HOSTNAME }}</p>
|
||||
<div class="col-xs-4">
|
||||
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="col-xs-4 text-center">
|
||||
<p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-right">
|
||||
<div class="col-xs-4 text-right">
|
||||
<p class="text-muted">
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="{% url 'docs_root' %}">Docs</a> ·
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="/api/docs/">API</a> ·
|
||||
<i class="fa fa-fw fa-code text-primary"></i><a href="https://github.com/digitalocean/netbox">Code</a>
|
||||
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,28 @@
|
||||
</div>
|
||||
<h1>Providers</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-9">
|
||||
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
|
||||
</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 'circuits:provider_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>
|
||||
{% include 'inc/filter_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,7 +31,12 @@
|
||||
<li class="occupied h{{ u.device.device_type.u_height }}u{% ifequal u.device.face face_id %} {{ u.device.device_role.color }}{% endifequal %}">
|
||||
{% ifequal u.device.face face_id %}
|
||||
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
|
||||
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">{{ u.device.name|default:u.device.device_role }}</a>
|
||||
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">
|
||||
{{ u.device.name|default:u.device.device_role }}
|
||||
{% if u.device.devicebay_count %}
|
||||
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ u.device.name|default:u.device.device_role }}</span>
|
||||
{% endifequal %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete devie type components?{% endblock %}
|
||||
{% block title %}Delete device type components?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to delete these components from <strong>{{ devicetype }}</strong>?</p>
|
||||
|
||||
@@ -29,7 +29,12 @@
|
||||
<tr>
|
||||
<td>Position</td>
|
||||
<td>
|
||||
{% if device.position %}
|
||||
{% if device.parent_bay %}
|
||||
{% with device.parent_bay.device as parent %}
|
||||
<span>U{{ parent.position }} / {{ parent.get_face_display }}
|
||||
(<a href="{{ parent.get_absolute_url }}">{{ parent }}</a> - {{ device.parent_bay.name }})</span>
|
||||
{% endwith %}
|
||||
{% elif device.position %}
|
||||
<span>U{{ device.position }} / {{ device.get_face_display }}</span>
|
||||
{% elif device.device_type.u_height %}
|
||||
<span class="label label-warning">Not racked</span>
|
||||
@@ -96,14 +101,29 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Primary IP</td>
|
||||
<td>Primary IPv4</td>
|
||||
<td>
|
||||
{% if device.primary_ip %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=device.primary_ip.pk %}">{{ device.primary_ip.address.ip }}</a>
|
||||
{% if device.primary_ip.nat_inside %}
|
||||
<span>(NAT for {{ device.primary_ip.nat_inside.address.ip }})</span>
|
||||
{% elif device.primary_ip.nat_outside %}
|
||||
<span>(NAT: {{ device.primary_ip.nat_outside.address.ip }})</span>
|
||||
{% if device.primary_ip4 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=device.primary_ip4.pk %}">{{ device.primary_ip4.address.ip }}</a>
|
||||
{% if device.primary_ip4.nat_inside %}
|
||||
<span>(NAT for {{ device.primary_ip4.nat_inside.address.ip }})</span>
|
||||
{% elif device.primary_ip4.nat_outside %}
|
||||
<span>(NAT: {{ device.primary_ip4.nat_outside.address.ip }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not defined</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Primary IPv6</td>
|
||||
<td>
|
||||
{% if device.primary_ip6 %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=device.primary_ip6.pk %}">{{ device.primary_ip6.address.ip }}</a>
|
||||
{% if device.primary_ip6.nat_inside %}
|
||||
<span>(NAT for {{ device.primary_ip6.nat_inside.address.ip }})</span>
|
||||
{% elif device.primary_ip6.nat_outside %}
|
||||
<span>(NAT: {{ device.primary_ip6.nat_outside.address.ip }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not defined</span>
|
||||
@@ -160,7 +180,7 @@
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Assign IP Address
|
||||
Assign IP address
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -174,7 +194,7 @@
|
||||
{% include 'dcim/inc/_interface.html' with icon='wrench' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="alert-warning">
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No management interfaces defined!
|
||||
{% if perms.dcim.add_interface %}
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
@@ -186,7 +206,7 @@
|
||||
{% include 'dcim/inc/_consoleport.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="alert-warning">
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No console ports defined!
|
||||
{% if perms.dcim.add_consoleport %}
|
||||
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
@@ -199,7 +219,7 @@
|
||||
{% empty %}
|
||||
{% if not device.device_type.is_pdu %}
|
||||
<tr>
|
||||
<td colspan="4" class="alert-warning">
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No power ports defined!
|
||||
{% if perms.dcim.add_powerport %}
|
||||
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
@@ -268,12 +288,33 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if device_bays or device.device_type.is_parent_device %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Device Bays</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for devicebay in device_bays %}
|
||||
{% include 'dcim/inc/_devicebay.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No device bays defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add device bays
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if interfaces or device.device_type.is_network_device %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% if perms.dcim.add_interface %}
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces</a>
|
||||
{% endif %}
|
||||
<strong>Interfaces</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
@@ -285,14 +326,19 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add interface
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cs_ports or device.device_type.is_console_server %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Console Server Ports</a>
|
||||
{% endif %}
|
||||
<strong>Console Server Ports</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
@@ -304,14 +350,19 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add console server ports
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if power_outlets or device.device_type.is_pdu %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Power Outlets</a>
|
||||
{% endif %}
|
||||
<strong>Power Outlets</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
@@ -323,6 +374,14 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add power outlets
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.platform %}
|
||||
{% render_field form.status %}
|
||||
{% if obj %}{% render_field form.primary_ip %}{% endif %}
|
||||
{% if obj %}
|
||||
{% render_field form.primary_ip4 %}
|
||||
{% render_field form.primary_ip6 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
||||
@@ -68,18 +68,18 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Position (U)</td>
|
||||
<td>Numeric rack position (optional)</td>
|
||||
<td>Lowest rack unit occupied by the device (optional)</td>
|
||||
<td>21</td>
|
||||
</tr>
|
||||
<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 %}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
<strong>Search</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
8
netbox/templates/dcim/devicebay_delete.html
Normal file
8
netbox/templates/dcim/devicebay_delete.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete device bay {{ devicebay }}?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?</p>
|
||||
{% endblock %}
|
||||
8
netbox/templates/dcim/devicebay_depopulate.html
Normal file
8
netbox/templates/dcim/devicebay_depopulate.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to remove <strong>{{ device_bay.installed_device }}</strong> from <strong>{{ device_bay }}</strong>?</p>
|
||||
{% endblock %}
|
||||
51
netbox/templates/dcim/devicebay_edit.html
Normal file
51
netbox/templates/dcim/devicebay_edit.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% if devicebay.pk %}Editing {{ devicebay.device }} {{ devicebay }}{% else %}Add a Device Bay ({{ device }}){% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% if poweroutlet.pk %}
|
||||
<strong>Editing {{ devicebay }}</strong>
|
||||
{% else %}
|
||||
<strong>Add a Device Bay</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{% if devicebay %}{{ devicebay.device }}{% else %}{{ device }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
{% if devicebay.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Save</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
46
netbox/templates/dcim/devicebay_populate.html
Normal file
46
netbox/templates/dcim/devicebay_populate.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Populate {{ device_bay }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Populate {{ device_bay }}</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Parent Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ device_bay.device }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Bay</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ device_bay.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<button type="submit" name="_update" class="btn btn-primary">Save</button>
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -14,8 +14,10 @@
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.dcim.change_devicetype %}
|
||||
|
||||
{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_devicetype %}
|
||||
<a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
|
||||
Edit this device type
|
||||
@@ -26,8 +28,10 @@
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
|
||||
Delete this device type
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h1>{{ devicetype }}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
@@ -38,7 +42,7 @@
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<td>Manufacturer</td>
|
||||
<td>{{ devicetype.manufacturer }}</td>
|
||||
<td><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Model Name</td>
|
||||
@@ -50,7 +54,13 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Full Depth</td>
|
||||
<td>{{ devicetype.is_full_depth|yesno|capfirst }}</td>
|
||||
<td>
|
||||
{% if devicetype.is_full_depth %}
|
||||
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
|
||||
{% else %}
|
||||
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -60,26 +70,84 @@
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<td>Is a Console Server</td>
|
||||
<td>{{ devicetype.is_console_server|yesno|capfirst }}</td>
|
||||
<td class="text-right">
|
||||
{% if devicetype.is_console_server %}
|
||||
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
|
||||
{% else %}
|
||||
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>Console Server</strong><br />
|
||||
<small class="text-muted">This device {% if devicetype.is_console_server %}has{% else %}does not have{% endif %} console server ports</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Is a PDU</td>
|
||||
<td>{{ devicetype.is_pdu|yesno|capfirst }}</td>
|
||||
<td class="text-right">
|
||||
{% if devicetype.is_pdu %}
|
||||
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
|
||||
{% else %}
|
||||
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>PDU</strong><br />
|
||||
<small class="text-muted">This device {% if devicetype.is_pdu %}has{% else %}does not have{% endif %} power outlets</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Is a Network Device</td>
|
||||
<td>{{ devicetype.is_network_device|yesno|capfirst }}</td>
|
||||
<td class="text-right">
|
||||
{% if devicetype.is_network_device %}
|
||||
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
|
||||
{% else %}
|
||||
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>Network Device</strong><br />
|
||||
<small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} non-management network interfaces</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-right">
|
||||
{% if devicetype.subdevice_role == True %}
|
||||
<label class="label label-primary">Parent</label>
|
||||
{% elif devicetype.subdevice_role == False %}
|
||||
<label class="label label-info">Child</label>
|
||||
{% else %}
|
||||
<label class="label label-default">None</label>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>Parent/Child</strong><br />
|
||||
{% if devicetype.subdevice_role == True %}
|
||||
<small class="text-muted">This device has device bays for mounting child devices</small>
|
||||
{% elif devicetype.subdevice_role == False %}
|
||||
<small class="text-muted">This device can only be mounted in a parent device</small>
|
||||
{% else %}
|
||||
<small class="text-muted">This device does not have device bays</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' delete_url='dcim:devicetype_delete_interface' %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if devicetype.is_parent_device %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
|
||||
{% endif %}
|
||||
{% if devicetype.is_network_device %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
|
||||
{% endif %}
|
||||
{% if devicetype.is_console_server %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
|
||||
{% endif %}
|
||||
{% if devicetype.is_pdu %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<td>
|
||||
<i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
|
||||
</td>
|
||||
<td></td>
|
||||
{% if cp.cs_port %}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
|
||||
@@ -10,7 +11,9 @@
|
||||
{{ cp.cs_port.name }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">Not connected</td>
|
||||
<td colspan="2">
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_consoleport %}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
{{ csp.connected_console.name }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">Not connected</td>
|
||||
<td colspan="2">
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_consoleserverport %}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
|
||||
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.rack.site.slug }}">Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
|
||||
{% if device.parent_bay %}
|
||||
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
|
||||
<li>{{ device.parent_bay.name }}</li>
|
||||
{% endif %}
|
||||
<li>{{ device }}</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
||||
44
netbox/templates/dcim/inc/_devicebay.html
Normal file
44
netbox/templates/dcim/inc/_devicebay.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<tr>
|
||||
<td>
|
||||
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
|
||||
</td>
|
||||
{% if devicebay.installed_device %}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ devicebay.installed_device.device_type }}</span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">
|
||||
<span class="text-muted">Vacant</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_devicebay %}
|
||||
{% if devicebay.installed_device %}
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Remove device"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:devicebay_populate' pk=devicebay.pk %}" class="btn btn-success btn-xs">
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_devicebay %}
|
||||
{% if devicebay.installed_device %}
|
||||
<button class="btn btn-danger btn-xs" disabled="disabled">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -5,6 +5,9 @@
|
||||
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ iface.mac_address|default:'' }}</small>
|
||||
</td>
|
||||
{% if not iface.is_physical %}
|
||||
<td colspan="2">Virtual</td>
|
||||
{% elif iface.connection %}
|
||||
@@ -21,7 +24,9 @@
|
||||
<a href="{% url 'circuits:circuit' pk=iface.circuit.pk %}">{{ iface.circuit }}</a>
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">Not connected</td>
|
||||
<td colspan="2">
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
{% if iface.circuit or iface.connection %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</td>
|
||||
<td>{{ ip.interface }}</td>
|
||||
<td>
|
||||
{% if device.primary_ip == ip %}
|
||||
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
|
||||
<span class="label label-success">Primary</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
{{ po.connected_port.name }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">Not connected</td>
|
||||
<td colspan="2">
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_poweroutlet %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<td>
|
||||
<i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
|
||||
</td>
|
||||
<td></td>
|
||||
{% if pp.power_outlet %}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
|
||||
@@ -10,7 +11,9 @@
|
||||
{{ pp.power_outlet.name }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">Not connected</td>
|
||||
<td colspan="2">
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_powerport %}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
{% csrf_token %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<a href="{% url add_url pk=devicetype.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add {{ title }}</a>
|
||||
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs pull-right">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add {{ title }}
|
||||
</a>
|
||||
<strong>{{ title }}</strong>
|
||||
</div>
|
||||
{% render_table table 'table.html' %}
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
<h1>Interface Connections Import</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="." method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{% render_form form %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user