mirror of
https://github.com/eduardogsilva/routerfleet.git
synced 2025-07-23 12:24:30 +02:00
fetch and store router information
This commit is contained in:
parent
130bb25dca
commit
e40eb2fdca
10 changed files with 325 additions and 3 deletions
|
@ -1,5 +1,6 @@
|
||||||
*/5 * * * * root sleep 0 ; /usr/bin/curl -s http://routerfleet:8001/cron/generate_backup_schedule/ >> /var/log/cron.log 2>&1
|
*/5 * * * * root sleep 0 ; /usr/bin/curl -s http://routerfleet:8001/cron/generate_backup_schedule/ >> /var/log/cron.log 2>&1
|
||||||
* * * * * root sleep 5 ; /usr/bin/curl -s http://routerfleet:8001/cron/create_backup_tasks/ >> /var/log/cron.log 2>&1
|
* * * * * root sleep 5 ; /usr/bin/curl -s http://routerfleet:8001/cron/create_backup_tasks/ >> /var/log/cron.log 2>&1
|
||||||
|
* * * * * root sleep 10; /usr/bin/curl -s http://routerfleet:8001/cron/update_router_information/ >> /var/log/cron.log 2>&1
|
||||||
*/10 * * * * root sleep 20; /usr/bin/curl -s http://routerfleet:8001/cron/housekeeping/ >> /var/log/cron.log 2>&1
|
*/10 * * * * root sleep 20; /usr/bin/curl -s http://routerfleet:8001/cron/housekeeping/ >> /var/log/cron.log 2>&1
|
||||||
* * * * * root sleep 40; /usr/bin/curl -s http://routerfleet:8001/cron/perform_backup_tasks/ >> /var/log/cron.log 2>&1
|
* * * * * root sleep 40; /usr/bin/curl -s http://routerfleet:8001/cron/perform_backup_tasks/ >> /var/log/cron.log 2>&1
|
||||||
* * * * * root sleep 50; /usr/bin/curl -s http://routerfleet:8001/cron/check_updates/ >> /var/log/cron.log 2>&1
|
* * * * * root sleep 50; /usr/bin/curl -s http://routerfleet:8001/cron/check_updates/ >> /var/log/cron.log 2>&1
|
||||||
|
|
18
import_tool/migrations/0006_alter_importtask_router_type.py
Normal file
18
import_tool/migrations/0006_alter_importtask_router_type.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 5.2 on 2025-04-23 12:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('import_tool', '0005_importtask_import_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='importtask',
|
||||||
|
name='router_type',
|
||||||
|
field=models.CharField(choices=[('monitoring', 'Monitoring Only'), ('routeros', 'Mikrotik (RouterOS)'), ('routeros-branded', 'Mikrotik (Branded)'), ('openwrt', 'OpenWRT')], max_length=100),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,12 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Router, SSHKey, RouterStatus, BackupSchedule
|
from .models import Router, SSHKey, RouterStatus, BackupSchedule, RouterInformation
|
||||||
|
|
||||||
|
|
||||||
|
class RouterInformationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('router', 'model_name', 'model_version', 'serial_number', 'os_version', 'firmware_version', 'architecture')
|
||||||
|
search_fields = ('router__name', 'model_name', 'model_version', 'serial_number', 'os_version', 'firmware_version', 'architecture')
|
||||||
|
list_filter = ('router__name', 'model_name', 'model_version', 'serial_number', 'os_version', 'firmware_version', 'architecture')
|
||||||
|
admin.site.register(RouterInformation, RouterInformationAdmin)
|
||||||
|
|
||||||
|
|
||||||
class RouterAdmin(admin.ModelAdmin):
|
class RouterAdmin(admin.ModelAdmin):
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Generated by Django 5.2 on 2025-04-23 12:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('router_manager', '0016_router_port'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='router',
|
||||||
|
name='router_type',
|
||||||
|
field=models.CharField(choices=[('monitoring', 'Monitoring Only'), ('routeros', 'Mikrotik (RouterOS)'), ('routeros-branded', 'Mikrotik (Branded)'), ('openwrt', 'OpenWRT')], max_length=100),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RouterInformation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('success', models.BooleanField(default=False)),
|
||||||
|
('error', models.BooleanField(default=False)),
|
||||||
|
('error_message', models.TextField(blank=True, null=True)),
|
||||||
|
('retry_count', models.IntegerField(default=0)),
|
||||||
|
('next_retry', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('last_retrieval', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('host_id', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('model_name', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('model_version', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('serial_number', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('os_version', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('firmware_version', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('architecture', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('json_data', models.TextField(blank=True, null=True)),
|
||||||
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
('router', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='router_manager.router')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
18
router_manager/migrations/0018_routerinformation_cpu.py
Normal file
18
router_manager/migrations/0018_routerinformation_cpu.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 5.2 on 2025-04-24 17:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('router_manager', '0017_alter_router_router_type_routerinformation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='routerinformation',
|
||||||
|
name='cpu',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 5.2 on 2025-04-24 17:24
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('router_manager', '0018_routerinformation_cpu'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='routerinformation',
|
||||||
|
name='host_id',
|
||||||
|
),
|
||||||
|
]
|
|
@ -80,3 +80,31 @@ class BackupSchedule(models.Model):
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4)
|
uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class RouterInformation(models.Model):
|
||||||
|
router = models.OneToOneField(Router, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
success = models.BooleanField(default=False)
|
||||||
|
error = models.BooleanField(default=False)
|
||||||
|
error_message = models.TextField(blank=True, null=True)
|
||||||
|
retry_count = models.IntegerField(default=0)
|
||||||
|
next_retry = models.DateTimeField(blank=True, null=True)
|
||||||
|
last_retrieval = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
model_name = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
model_version = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
serial_number = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
|
||||||
|
os_version = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
firmware_version = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
architecture = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
cpu = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
|
||||||
|
json_data = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.router)
|
||||||
|
|
|
@ -8,9 +8,11 @@ from django.utils import timezone
|
||||||
from backup.models import BackupProfile
|
from backup.models import BackupProfile
|
||||||
from backup_data.models import RouterBackup
|
from backup_data.models import RouterBackup
|
||||||
from routerfleet_tools.models import WebadminSettings
|
from routerfleet_tools.models import WebadminSettings
|
||||||
|
from routerlib.router_functions import update_router_information
|
||||||
from user_manager.models import UserAcl
|
from user_manager.models import UserAcl
|
||||||
from .forms import RouterForm, RouterGroupForm, SSHKeyForm
|
from .forms import RouterForm, RouterGroupForm, SSHKeyForm
|
||||||
from .models import Router, RouterGroup, RouterStatus, SSHKey, BackupSchedule
|
from .models import Router, RouterGroup, RouterInformation, RouterStatus, SSHKey, BackupSchedule
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -263,3 +265,28 @@ def view_create_instant_backup_multiple_routers(request):
|
||||||
return JsonResponse({'results': results})
|
return JsonResponse({'results': results})
|
||||||
|
|
||||||
return JsonResponse({'error': 'Invalid request method.'}, status=405)
|
return JsonResponse({'error': 'Invalid request method.'}, status=405)
|
||||||
|
|
||||||
|
|
||||||
|
def view_cron_update_router_information(request):
|
||||||
|
data = {'status': 'success'}
|
||||||
|
refresh_interval = 24 #hours
|
||||||
|
|
||||||
|
router_list = Router.objects.filter(enabled=True).exclude(router_type='monitoring').exclude(routerstatus__status_online=False)
|
||||||
|
router = router_list.filter(routerinformation__isnull=True).first()
|
||||||
|
if not router:
|
||||||
|
router = router_list.filter(routerinformation__next_retry__lt=timezone.now()).first()
|
||||||
|
if not router:
|
||||||
|
router = router_list.filter(routerinformation__last_retrieval__isnull=True).first()
|
||||||
|
if not router:
|
||||||
|
router = router_list.filter(routerinformation__last_retrieval__lt=timezone.now() - timezone.timedelta(hours=refresh_interval)).first()
|
||||||
|
|
||||||
|
if router:
|
||||||
|
router_information, created = RouterInformation.objects.get_or_create(router=router)
|
||||||
|
success, error_message = update_router_information(router_information)
|
||||||
|
if not success:
|
||||||
|
data['status'] = 'error'
|
||||||
|
data['message'] = 'Failed to update router'
|
||||||
|
else:
|
||||||
|
data['message'] = 'No routers need update'
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
|
@ -6,7 +6,7 @@ from dashboard.views import view_dashboard, view_status,backup_statistics_data,r
|
||||||
from integration_manager.views import view_wireguard_webadmin_launcher, view_manage_wireguard_integration, view_launch_wireguard_webadmin
|
from integration_manager.views import view_wireguard_webadmin_launcher, view_manage_wireguard_integration, view_launch_wireguard_webadmin
|
||||||
from user_manager.views import view_manage_user, view_user_list
|
from user_manager.views import view_manage_user, view_user_list
|
||||||
from accounts.views import view_login, view_logout, view_create_first_user
|
from accounts.views import view_login, view_logout, view_create_first_user
|
||||||
from router_manager.views import view_create_instant_backup_multiple_routers, view_router_list, view_manage_router, view_router_group_list, view_ssh_key_list, view_manage_router_group, view_manage_sshkey, view_router_details, view_create_instant_backup_task, view_router_availability
|
from router_manager.views import view_create_instant_backup_multiple_routers, view_router_list, view_manage_router, view_router_group_list, view_ssh_key_list, view_manage_router_group, view_manage_sshkey, view_router_details, view_create_instant_backup_task, view_router_availability, view_cron_update_router_information
|
||||||
from backup.views import view_backup_profile_list, view_manage_backup_profile, view_backup_list, view_backup_details, view_debug_run_backups, view_compare_backups, view_backup_download, view_backup_delete
|
from backup.views import view_backup_profile_list, view_manage_backup_profile, view_backup_list, view_backup_details, view_debug_run_backups, view_compare_backups, view_backup_download, view_backup_delete
|
||||||
from monitoring.views import view_export_router_list, view_update_router_status, view_router_config_timestamp, view_router_last_status_change
|
from monitoring.views import view_export_router_list, view_update_router_status, view_router_config_timestamp, view_router_last_status_change
|
||||||
from backup_data.views import view_generate_backup_schedule, view_create_backup_tasks, view_perform_backup_tasks, view_housekeeping
|
from backup_data.views import view_generate_backup_schedule, view_create_backup_tasks, view_perform_backup_tasks, view_housekeeping
|
||||||
|
@ -58,6 +58,7 @@ urlpatterns = [
|
||||||
path('cron/perform_backup_tasks/', view_perform_backup_tasks, name='perform_backup_tasks'),
|
path('cron/perform_backup_tasks/', view_perform_backup_tasks, name='perform_backup_tasks'),
|
||||||
path('cron/housekeeping/', view_housekeeping, name='housekeeping'),
|
path('cron/housekeeping/', view_housekeeping, name='housekeeping'),
|
||||||
path('cron/check_updates/', cron_check_updates, name='check_updates'),
|
path('cron/check_updates/', cron_check_updates, name='check_updates'),
|
||||||
|
path('cron/update_router_information/', view_cron_update_router_information, name='update_router_information'),
|
||||||
path('cron/concatenate_notifications/', view_cron_concatenate_notifications, name='concatenate_notifications'),
|
path('cron/concatenate_notifications/', view_cron_concatenate_notifications, name='concatenate_notifications'),
|
||||||
path('cron/send_messages/', view_cron_send_messages, name='send_messages'),
|
path('cron/send_messages/', view_cron_send_messages, name='send_messages'),
|
||||||
path('cron/daily_reports/', view_cron_daily_reports, name='daily_reports'),
|
path('cron/daily_reports/', view_cron_daily_reports, name='daily_reports'),
|
||||||
|
|
161
routerlib/router_functions.py
Normal file
161
routerlib/router_functions.py
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
from django.utils import timezone
|
||||||
|
from router_manager.models import RouterInformation, Router
|
||||||
|
from routerlib.functions import connect_to_ssh
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_routeros_key_value_output(output: str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse lines like "key: value" into a dict.
|
||||||
|
Skip blank lines or lines starting with '[' (the prompt).
|
||||||
|
"""
|
||||||
|
data = {}
|
||||||
|
# Normalize and split
|
||||||
|
for raw_line in output.replace('\r', '').splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
# skip empty or prompt lines
|
||||||
|
if not line or line.startswith('['):
|
||||||
|
continue
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
key, val = line.split(':', 1)
|
||||||
|
data[key.strip()] = val.strip()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_router_information(router_information: RouterInformation):
|
||||||
|
"""
|
||||||
|
Connect to the router, retrieve info, and store it in RouterInformation.
|
||||||
|
"""
|
||||||
|
router = router_information.router
|
||||||
|
field_max_length = 100
|
||||||
|
success = False
|
||||||
|
error_message = ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssh = connect_to_ssh(router.address, router.port, router.username, router.password, router.ssh_key)
|
||||||
|
json_data = {}
|
||||||
|
|
||||||
|
if router.router_type in ('routeros', 'routeros-branded'):
|
||||||
|
for cmd in ['/system resource print', '/system routerboard print']:
|
||||||
|
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||||
|
raw = stdout.read().decode('utf-8', errors='ignore')
|
||||||
|
parsed = _parse_routeros_key_value_output(raw)
|
||||||
|
json_data[cmd] = parsed
|
||||||
|
|
||||||
|
rb = json_data['/system routerboard print']
|
||||||
|
sr = json_data['/system resource print']
|
||||||
|
|
||||||
|
if sr:
|
||||||
|
router_information.model_name = sr.get('board-name', '')[:field_max_length]
|
||||||
|
router_information.os_version = sr.get('version', '')[:field_max_length]
|
||||||
|
router_information.architecture = sr.get('architecture-name', '')[:field_max_length]
|
||||||
|
router_information.cpu = sr.get('cpu', '')[:field_max_length]
|
||||||
|
success = True
|
||||||
|
if rb:
|
||||||
|
router_information.model_version = rb.get('model', '')[:field_max_length]
|
||||||
|
router_information.serial_number = rb.get('serial-number', '')[:field_max_length]
|
||||||
|
router_information.firmware_version = rb.get('current-firmware', '')[:field_max_length]
|
||||||
|
success = True
|
||||||
|
if not success:
|
||||||
|
return False, 'Failed to retrieve router information'
|
||||||
|
|
||||||
|
elif router.router_type == 'openwrt':
|
||||||
|
stdin, stdout, stderr = ssh.exec_command('cat /etc/os-release')
|
||||||
|
osrel = {}
|
||||||
|
for line in stdout.read().decode('utf-8').splitlines():
|
||||||
|
if '=' in line:
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
osrel[k] = v.strip().strip('"')
|
||||||
|
json_data['cat /etc/os-release'] = osrel
|
||||||
|
|
||||||
|
# hostname
|
||||||
|
stdin, stdout, stderr = ssh.exec_command('uci get system.@system[0].hostname')
|
||||||
|
hostname = stdout.read().decode('utf-8').strip()
|
||||||
|
json_data['uci get system.@system[0].hostname'] = hostname
|
||||||
|
|
||||||
|
# architecture
|
||||||
|
stdin, stdout, stderr = ssh.exec_command('uname -m')
|
||||||
|
arch = stdout.read().decode('utf-8').strip()
|
||||||
|
json_data['uname -m'] = arch
|
||||||
|
|
||||||
|
# fallback serial (MAC of eth0)
|
||||||
|
stdin, stdout, stderr = ssh.exec_command('cat /sys/class/net/eth0/address')
|
||||||
|
mac = stdout.read().decode('utf-8').strip()
|
||||||
|
json_data['cat /sys/class/net/eth0/address'] = mac
|
||||||
|
|
||||||
|
if osrel:
|
||||||
|
router_information.model_name = osrel.get('OPENWRT_DEVICE_MODEL', '')[:field_max_length]
|
||||||
|
router_information.model_version = osrel.get('VERSION_ID', '')[:field_max_length]
|
||||||
|
router_information.serial_number = mac[:field_max_length]
|
||||||
|
router_information.os_version = osrel.get('VERSION', '')[:field_max_length]
|
||||||
|
router_information.firmware_version = osrel.get('OPENWRT_RELEASE', '')[:field_max_length]
|
||||||
|
router_information.architecture = arch[:field_max_length]
|
||||||
|
success = True
|
||||||
|
if not success:
|
||||||
|
return False, 'Failed to retrieve router information'
|
||||||
|
else:
|
||||||
|
return False, f"Router type not supported: {router.get_router_type_display()}"
|
||||||
|
|
||||||
|
if success:
|
||||||
|
router_information.success = True
|
||||||
|
router_information.error = False
|
||||||
|
router_information.retry_count = 0
|
||||||
|
router_information.next_retry = None
|
||||||
|
router_information.error_message = ''
|
||||||
|
router_information.last_retrieval = timezone.now()
|
||||||
|
router_information.json_data = json.dumps(json_data)
|
||||||
|
router_information.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
error_message = str(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ssh.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return success, error_message
|
||||||
|
|
||||||
|
|
||||||
|
def update_router_information(router_information: RouterInformation):
|
||||||
|
max_retry = 3
|
||||||
|
retry_minutes = 5
|
||||||
|
|
||||||
|
success = False
|
||||||
|
error_message = ''
|
||||||
|
|
||||||
|
if router_information.retry_count > max_retry:
|
||||||
|
router_information.error = True
|
||||||
|
router_information.success = False
|
||||||
|
router_information.next_retry = None
|
||||||
|
router_information.retry_count = 0
|
||||||
|
router_information.last_retrieval = timezone.now()
|
||||||
|
if router_information.error_message:
|
||||||
|
router_information.error_message += f"\nMax retries reached for {router_information.router.name}"
|
||||||
|
else:
|
||||||
|
router_information.error_message = f"Max retries reached for {router_information.router.name}"
|
||||||
|
router_information.save()
|
||||||
|
return False, router_information.error_message
|
||||||
|
try:
|
||||||
|
success, error_message = get_router_information(router_information)
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
error_message = f"Failed to update router information for {router_information.router.name}. Exception: {e}"
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
router_information.error = True
|
||||||
|
router_information.success = False
|
||||||
|
router_information.next_retry = timezone.now() + datetime.timedelta(minutes=retry_minutes)
|
||||||
|
router_information.retry_count += 1
|
||||||
|
router_information.last_retrieval = timezone.now()
|
||||||
|
if error_message:
|
||||||
|
router_information.error_message = error_message
|
||||||
|
else:
|
||||||
|
router_information.error_message = f"Failed to update router information for {router_information.router.name}"
|
||||||
|
router_information.save()
|
||||||
|
|
||||||
|
return success, error_message
|
Loading…
Add table
Add a link
Reference in a new issue