Skip to content

Commit b0d2e98

Browse files
(feature): Added automated device config backups. (#35)
(feature): Added ability to backup device configurations on a daily/weekly basis.
1 parent 6ca2981 commit b0d2e98

File tree

11 files changed

+314
-4
lines changed

11 files changed

+314
-4
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Set up Python for formatting/linting
1818
uses: actions/setup-python@v4
1919
with:
20-
python-version: '3.8'
20+
python-version: '3.9'
2121

2222
- name: Lint with flake8 (with soft fail)
2323
run: |

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## v0.3.1 - 10-20-2025
4+
- 🆕 Inventory Config Backups: Added ability to fetch device configuration via NAPALM drivers.
5+
- 🆕 Inventory Config Backups: Added ability automatically backup configurations on a daily/weekly basis.
6+
37
## v0.3.0 - 10-10-2025
48
- 🖥️ Inventory: Re-design into single modal/page for CRUD process.
59
- 🆕 Inventory Tracked Interface: Added ability to add interfaces in order to "track" for particular scripts and cards such as Statseeker

src/network_ops_dashboard/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
admin.site.register(StatseekerAlert)
5454
admin.site.register(DiscoveryJob)
5555
admin.site.register(DiscoveredDevice)
56+
admin.site.register(ConfigBackupSchedule)
5657

5758
@admin.register(SiteSettings)
5859
class SiteSettingsAdmin(admin.ModelAdmin):

src/network_ops_dashboard/inventory/forms.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,13 @@ def clean(self):
8888
if not password:
8989
self.add_error("password", "Password is required if no API key is provided.")
9090

91-
return cleaned_data
91+
return cleaned_data
92+
93+
class ConfigBackupScheduleForm(forms.ModelForm):
94+
class Meta:
95+
model = ConfigBackupSchedule
96+
fields = ["enabled", "frequency", "day_of_week", "time_of_day", "email_alerts", "alert_email", "devices"]
97+
widgets = {
98+
"time_of_day": forms.TimeInput(attrs={"type": "time", "class": "form-control"}),
99+
"devices": forms.SelectMultiple(attrs={"class": "form-select", "size": 8}),
100+
}

src/network_ops_dashboard/inventory/models.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,51 @@ class Meta:
219219
]
220220

221221
def __str__(self):
222-
return f"{self.device.name} {self.name}"
222+
return f"{self.device.name} {self.name}"
223+
224+
class ConfigBackupSchedule(models.Model):
225+
FREQUENCY_CHOICES = [
226+
("daily", "Daily"),
227+
("weekly", "Weekly"),
228+
]
229+
DAYS_OF_WEEK = [
230+
(1, "Monday"),
231+
(2, "Tuesday"),
232+
(3, "Wednesday"),
233+
(4, "Thursday"),
234+
(5, "Friday"),
235+
(6, "Saturday"),
236+
(0, "Sunday"),
237+
]
238+
239+
enabled = models.BooleanField(default=False)
240+
frequency = models.CharField(max_length=10, choices=FREQUENCY_CHOICES, default="weekly")
241+
day_of_week = models.IntegerField(choices=DAYS_OF_WEEK, default=0)
242+
time_of_day = models.TimeField(default=timezone.now)
243+
email_alerts = models.BooleanField(default=False)
244+
alert_email = models.EmailField(blank=True, null=True)
245+
246+
# which devices to include
247+
devices = models.ManyToManyField(Inventory, related_name="config_backup_targets", blank=True)
248+
249+
updated_at = models.DateTimeField(auto_now=True)
250+
251+
def __str__(self):
252+
return "Global Config Backup Schedule"
253+
254+
@property
255+
def cron_key(self):
256+
return "config_backup"
257+
258+
def ensure_cron_job(self):
259+
"""Create or update the single cron job."""
260+
from network_ops_dashboard.scripts.cron import ensure_daily_cron, ensure_weekly_cron
261+
262+
if not self.enabled:
263+
return
264+
265+
hhmm = self.time_of_day.strftime("%H:%M")
266+
if self.frequency == "daily":
267+
ensure_daily_cron(self.cron_key, hhmm)
268+
else:
269+
ensure_weekly_cron(self.cron_key, hhmm, self.day_of_week)

src/network_ops_dashboard/inventory/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
re_path(r'^edit/(?P<pk>[0-9]{1,10})/$', views.inventory_edit_modal, name='inventory_edit_modal'),
2929
re_path(r'^delete/(?P<pk>[0-9]{1,10})/$', views.inventory_delete_modal, name='inventory_delete_modal'),
3030
re_path(r'^fetch-config/(?P<pk>[0-9]{1,10})/$', views.inventory_fetch_config, name='inventory_fetch_config'),
31+
re_path(r'^config-backup/save/$', views.config_backup_save, name='config_backup_save'),
32+
re_path(r'^config-backup/modal/$', views.config_backup_modal, name='config_backup_modal'),
3133
re_path(r'^platform/$', views.platform_home, name='platform_home'),
3234
re_path(r'^platform/add/$', views.platform_add, name='platform_add'),
3335
re_path(r'^platform/edit/(?P<pk>[0-9]{1,10})/$', views.platform_edit, name='platform_edit'),

src/network_ops_dashboard/inventory/views.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,37 @@ def inventory_fetch_config(request, pk):
240240
except Exception as e:
241241
return JsonResponse({"status": "error", "message": str(e)}, status=500)
242242

243+
@login_required(login_url='/accounts/login/')
244+
def config_backup_modal(request):
245+
from network_ops_dashboard.inventory.models import ConfigBackupSchedule, Inventory
246+
from network_ops_dashboard.inventory.forms import ConfigBackupScheduleForm
247+
248+
cfg, _ = ConfigBackupSchedule.objects.get_or_create(pk=1)
249+
form = ConfigBackupScheduleForm(instance=cfg)
250+
devices = Inventory.objects.all().order_by("name")
251+
252+
return render(request, "network_ops_dashboard/inventory/_config_backup_modal.html", {"form": form, "cfg": cfg, "devices": devices})
253+
254+
@require_POST
255+
@login_required(login_url='/accounts/login/')
256+
def config_backup_save(request):
257+
from network_ops_dashboard.inventory.models import ConfigBackupSchedule
258+
from network_ops_dashboard.inventory.forms import ConfigBackupScheduleForm
259+
260+
cfg, _ = ConfigBackupSchedule.objects.get_or_create(pk=1)
261+
form = ConfigBackupScheduleForm(request.POST, instance=cfg)
262+
263+
if form.is_valid():
264+
cfg = form.save()
265+
cfg.ensure_cron_job()
266+
response = HttpResponse(
267+
"<script>window.dispatchEvent(new Event('configBackupSaved'));</script>"
268+
)
269+
response["HX-Trigger"] = "configBackupSaved"
270+
return response
271+
272+
return render(request, "network_ops_dashboard/inventory/_config_backup_modal.html", {"form": form, "cfg": cfg, "devices": cfg.devices.all()})
273+
243274
@login_required(login_url='/accounts/login/')
244275
def platform_home(request):
245276
platform_all = Platform.objects.all().order_by('name')
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from django.core.management.base import BaseCommand
2+
from django.core.mail import send_mail
3+
from django.conf import settings
4+
import logging
5+
6+
logger = logging.getLogger(__name__)
7+
8+
class Command(BaseCommand):
9+
help = "Collect Inventory Device Configs."
10+
11+
@staticmethod
12+
def _email_config_backup_summary(results, cfg):
13+
total = len(results)
14+
failed = [r for r in results if not r.get("success")]
15+
passed = [r for r in results if r.get("success")]
16+
subject = "[Config Backup] All Successful" if not failed else f"[Config Backup] {len(failed)} Failures"
17+
18+
results_str = f"Processed {total} device config backups and {len(failed)} failed."
19+
lines = [results_str, ""]
20+
21+
ordered = failed + passed
22+
for r in ordered:
23+
if r.get("success"):
24+
lines.append(f"{r['device']}: OK")
25+
else:
26+
err = r.get("error")
27+
lines.append(f"{r['device']}: FAILED" + (f" — {err}" if err else ""))
28+
29+
body = "\n".join(lines) + "\n"
30+
31+
to_email = getattr(cfg, "alert_email", "") or getattr(settings, "ADMINS", [("", "")])[0][1] or None
32+
if to_email:
33+
send_mail(
34+
subject,
35+
body,
36+
getattr(settings, "DEFAULT_FROM_EMAIL", None),
37+
[to_email],
38+
fail_silently=True,
39+
)
40+
41+
def handle(self, *args, **kwargs):
42+
from network_ops_dashboard.inventory.models import ConfigBackupSchedule
43+
from network_ops_dashboard.inventory.scripts.services import pull_device_config
44+
45+
cfg = ConfigBackupSchedule.objects.first()
46+
if not cfg:
47+
logger.warning("Config Backup: no ConfigBackupSchedule found.")
48+
return
49+
if not cfg.enabled:
50+
logger.info("Config Backup: disabled; skipping.")
51+
return
52+
if not cfg.devices.exists():
53+
logger.info("Config Backup: no devices enabled for config backup; skipping.")
54+
return
55+
56+
results = []
57+
logger.info("Config Backup: Backups started.")
58+
59+
for device in cfg.devices.all():
60+
try:
61+
pull_device_config(device)
62+
results.append({"device": device.name, "success": True})
63+
logger.info(f"Config Backup: {device.name} backup success.")
64+
except Exception as e:
65+
results.append({"device": device.name, "success": False})
66+
logger.error(f"Config Backup: {device.name} backup failed: {e}")
67+
68+
failed = [r for r in results if not r["success"]]
69+
logger.info(f"Config Backup: Finished. {len(results)} processed, {len(failed)} failed.")
70+
71+
if cfg.email_alerts:
72+
if cfg.alert_email:
73+
try:
74+
self._email_config_backup_summary(results, cfg)
75+
logger.info("Config Backup: Summary email sent.")
76+
except Exception as e:
77+
logger.error(f"Config Backup: Email failed: {e}")
78+
else:
79+
logger.info(f"Config Backup: Email Alerts enabled but no email configured. Please configure email address in settings.")

src/network_ops_dashboard/scripts/cron.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"sdwan_vmanage_stats": "collect_sdwan_stats",
1414
"pagerduty_incidents": "collect_pd_incidents",
1515
"statseeker_alarms": "collect_statseeker_alarms",
16+
"config_backup": "collect_device_configs",
1617
}
1718

1819
def _job_comment(key: str) -> str:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<div class="modal-header">
2+
<h5 class="modal-title">Configuration Backup Settings</h5>
3+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
4+
</div>
5+
6+
<form method="post"
7+
hx-post="{% url 'config_backup_save' %}"
8+
hx-target="#configBackupModal .modal-content"
9+
hx-swap="outerHTML">
10+
{% csrf_token %}
11+
<div class="modal-body">
12+
13+
<div class="form-check form-switch mb-3">
14+
<input class="form-check-input" type="checkbox" name="enabled" id="id_enabled"
15+
{% if cfg.enabled %}checked{% endif %}>
16+
<label class="form-check-label" for="id_enabled">Enable Config Backups</label>
17+
</div>
18+
19+
<div class="row g-2">
20+
<div class="col-md-6">
21+
<label class="form-label">Frequency</label>
22+
<select name="frequency" class="form-select" id="id_frequency">
23+
<option value="daily" {% if cfg.frequency == 'daily' %}selected{% endif %}>Daily</option>
24+
<option value="weekly" {% if cfg.frequency == 'weekly' %}selected{% endif %}>Weekly</option>
25+
</select>
26+
</div>
27+
28+
<div class="col-md-6">
29+
<label class="form-label">Time of Day</label>
30+
<input type="time" name="time_of_day" class="form-control"
31+
value="{{ cfg.time_of_day|time:'H:i' }}">
32+
</div>
33+
</div>
34+
35+
<div class="mt-3">
36+
<label class="form-label">Day of Week (for weekly)</label>
37+
<select name="day_of_week" class="form-select">
38+
{% for value, label in form.fields.day_of_week.choices %}
39+
<option value="{{ value }}" {% if form.instance.day_of_week == value %}selected{% endif %}>{{ label }}</option>
40+
{% endfor %}
41+
</select>
42+
</div>
43+
44+
<hr>
45+
46+
<div class="mb-3">
47+
<div class="form-check form-switch">
48+
<input class="form-check-input" type="checkbox" name="email_alerts" id="id_email_alerts"
49+
{% if cfg.email_alerts %}checked{% endif %}>
50+
<label class="form-check-label" for="id_email_alerts">Email Alerts</label>
51+
</div>
52+
<input type="email" class="form-control mt-2" name="alert_email"
53+
value="{{ cfg.alert_email|default_if_none:'' }}" placeholder="Email address for alerts">
54+
</div>
55+
56+
<hr>
57+
58+
<!-- Multi-select for devices -->
59+
<label class="form-label">Devices to Include</label>
60+
<select name="devices" multiple class="form-select" size="8">
61+
{% for device in devices %}
62+
<option value="{{ device.pk }}" {% if device in cfg.devices.all %}selected{% endif %}>
63+
{{ device.name }}
64+
</option>
65+
{% endfor %}
66+
</select>
67+
68+
</div>
69+
70+
<div class="modal-footer">
71+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
72+
<button type="submit" class="btn btn-primary">Save</button>
73+
</div>
74+
</form>
75+
76+
<script>
77+
document.body.addEventListener("htmx:afterSwap", (evt) => {
78+
// Only re-run when the config modal was swapped
79+
if (evt.detail.target.id === "configBackupModal" || evt.detail.target.closest("#configBackupModal")) {
80+
const chk = document.querySelector('#configBackupModal input[name="email_alerts"]');
81+
const emailField = document.querySelector('#configBackupModal input[name="alert_email"]');
82+
const freqSelect = document.querySelector('#configBackupModal select[name="frequency"]');
83+
const daySelect = document.querySelector('#configBackupModal select[name="day_of_week"]');
84+
85+
function toggleEmailField() {
86+
if (!chk || !emailField) return;
87+
if (chk.checked) {
88+
emailField.disabled = false;
89+
emailField.placeholder = "admin@example.com";
90+
} else {
91+
emailField.value = "";
92+
emailField.disabled = true;
93+
emailField.placeholder = "(disabled)";
94+
}
95+
}
96+
97+
function toggleDaySelect() {
98+
if (!freqSelect || !daySelect) return;
99+
const isWeekly = freqSelect.value === "weekly";
100+
daySelect.disabled = !isWeekly;
101+
if (!isWeekly) daySelect.value = "";
102+
}
103+
104+
toggleEmailField();
105+
toggleDaySelect();
106+
if (chk) chk.addEventListener("change", toggleEmailField);
107+
if (freqSelect) freqSelect.addEventListener("change", toggleDaySelect);
108+
}
109+
});
110+
</script>

0 commit comments

Comments
 (0)