6 Commits

Author SHA1 Message Date
jamie c8c483ae95 Merge pull request 'v1.9.9' (#45) from v1.9.9 into main
Reviewed-on: #45
2026-04-29 22:59:57 +01:00
jamie fd2b561308 refactor: 🎨 make whole subnet card clickable
Release / Build & Release (pull_request) Successful in 35s
Release / SonarQube (pull_request) Successful in 36s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:57:54 +00:00
jamie 3e5ee0800e feat: display vlan id on main page
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:55:50 +00:00
jamie 5850898d5b ci: 🚀 add sonarqube 2026-04-29 21:50:10 +00:00
jamie ae28d3fb26 Merge pull request 'fix: 🐛 devices with same name return incorrect id' (#43) from v1.9.8 into main
Reviewed-on: #43
2026-04-07 11:26:38 +01:00
jamie 4d6a95e2b0 fix: 🐛 devices with same name return incorrect id
Release / release (pull_request) Successful in 28s
2026-04-07 10:26:26 +00:00
3 changed files with 83 additions and 76 deletions
+28
View File
@@ -8,6 +8,7 @@ on:
jobs: jobs:
release: release:
name: Build & Release
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v') if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01 runs-on: build-htz-01
steps: steps:
@@ -44,3 +45,30 @@ jobs:
body: ${{ steps.changelog.outputs.changelog }} body: ${{ steps.changelog.outputs.changelog }}
draft: false draft: false
prerelease: false prerelease: false
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Valid Project Key
id: sonar_setup
run: |
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
-Dsonar.projectName=${{ gitea.repository }}
-Dsonar.qualitygate.wait=true
+43 -70
View File
@@ -539,7 +539,7 @@ def prewarm_cache(app):
# Pre-warm index page (all subnets with utilization) # Pre-warm index page (all subnets with utilization)
logging.info("Pre-warming cache: Loading all subnets for index page...") logging.info("Pre-warming cache: Loading all subnets for index page...")
cursor.execute('SELECT id, name, cidr, site FROM Subnet') cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet')
subnets = cursor.fetchall() subnets = cursor.fetchall()
sites_subnets = {} sites_subnets = {}
for subnet in subnets: for subnet in subnets:
@@ -570,6 +570,7 @@ def prewarm_cache(app):
'id': subnet[0], 'id': subnet[0],
'name': subnet[1], 'name': subnet[1],
'cidr': subnet[2], 'cidr': subnet[2],
'vlan_id': subnet[4],
'utilization': round(utilization_percent, 1) 'utilization': round(utilization_percent, 1)
}) })
cache.set('index', sites_subnets, ttl=10800) cache.set('index', sites_subnets, ttl=10800)
@@ -631,8 +632,15 @@ def prewarm_cache(app):
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet_row = cursor.fetchone() subnet_row = cursor.fetchone()
if subnet_row: if subnet_row:
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('''
ip_addresses = cursor.fetchall() SELECT ip.id, ip.ip, ip.hostname, d.id, d.description, ip.notes
FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
LEFT JOIN Device d ON dia.device_id = d.id
WHERE ip.subnet_id = %s
ORDER BY INET_ATON(ip.ip)
''', (subnet_id,))
ip_addresses_with_device = cursor.fetchall()
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
total_ips = cursor.fetchone()[0] total_ips = cursor.fetchone()[0]
@@ -661,23 +669,6 @@ def prewarm_cache(app):
'percent': round(utilization_percent, 1) 'percent': round(utilization_percent, 1)
} }
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
ip_id = ip[0]
ip_address = ip[1]
hostname = ip[2]
ip_notes = ip[3] if len(ip) > 3 else None
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
subnet_dict = {'id': subnet_row[0], 'name': subnet_row[1], 'cidr': subnet_row[2]} subnet_dict = {'id': subnet_row[0], 'name': subnet_row[1], 'cidr': subnet_row[2]}
result = { result = {
'subnet': subnet_dict, 'subnet': subnet_dict,
@@ -1000,7 +991,7 @@ def register_routes(app, limiter=None):
conn = get_db_connection(current_app) conn = get_db_connection(current_app)
try: try:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, site FROM Subnet') cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet')
subnets = cursor.fetchall() subnets = cursor.fetchall()
sites_subnets = {} sites_subnets = {}
for subnet in subnets: for subnet in subnets:
@@ -1034,6 +1025,7 @@ def register_routes(app, limiter=None):
'id': subnet[0], 'id': subnet[0],
'name': subnet[1], 'name': subnet[1],
'cidr': subnet[2], 'cidr': subnet[2],
'vlan_id': subnet[4],
'utilization': round(utilization_percent, 1) 'utilization': round(utilization_percent, 1)
}) })
# Cache for 3 hours # Cache for 3 hours
@@ -1500,8 +1492,15 @@ def register_routes(app, limiter=None):
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,)) cursor.execute('SELECT id, name, cidr, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone() subnet = cursor.fetchone()
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('''
ip_addresses = cursor.fetchall() SELECT ip.id, ip.ip, ip.hostname, d.id, d.description, ip.notes
FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
LEFT JOIN Device d ON dia.device_id = d.id
WHERE ip.subnet_id = %s
ORDER BY INET_ATON(ip.ip)
''', (subnet_id,))
ip_addresses_with_device = cursor.fetchall()
# Calculate utilization stats # Calculate utilization stats
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
@@ -1534,23 +1533,6 @@ def register_routes(app, limiter=None):
# Get custom fields for subnet # Get custom fields for subnet
custom_fields = get_custom_fields_for_entity('subnet', subnet_id, conn=conn) custom_fields = get_custom_fields_for_entity('subnet', subnet_id, conn=conn)
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
ip_id = ip[0]
ip_address = ip[1]
hostname = ip[2]
ip_notes = ip[3] if len(ip) > 3 else None
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
subnet_dict = { subnet_dict = {
'id': subnet[0], 'id': subnet[0],
'name': subnet[1], 'name': subnet[1],
@@ -3139,24 +3121,15 @@ def register_routes(app, limiter=None):
subnet = cursor.fetchone() subnet = cursor.fetchone()
if not subnet: if not subnet:
return 'Subnet not found', 404 return 'Subnet not found', 404
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('''
ip_addresses = cursor.fetchall() SELECT ip.id, ip.ip, ip.hostname, d.id, d.description, ip.notes
cursor.execute('SELECT id, name, description FROM Device') FROM IPAddress ip
devices = cursor.fetchall() LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
device_name_map = {name.lower(): (id, description) for id, name, description in devices} LEFT JOIN Device d ON dia.device_id = d.id
ip_addresses_with_device = [] WHERE ip.subnet_id = %s
for ip in ip_addresses: ORDER BY INET_ATON(ip.ip)
ip_id = ip[0] ''', (subnet_id,))
ip_address = ip[1] ip_addresses_with_device = cursor.fetchall()
hostname = ip[2]
ip_notes = ip[3] if len(ip) > 3 else None
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
output = StringIO() output = StringIO()
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow(['IP Address', 'Hostname', 'Description']) writer.writerow(['IP Address', 'Hostname', 'Description'])
@@ -3761,7 +3734,7 @@ def register_routes(app, limiter=None):
FROM IPAddress ip FROM IPAddress ip
JOIN Subnet s ON ip.subnet_id = s.id JOIN Subnet s ON ip.subnet_id = s.id
WHERE ip.ip LIKE %s OR ip.hostname LIKE %s OR ip.notes LIKE %s WHERE ip.ip LIKE %s OR ip.hostname LIKE %s OR ip.notes LIKE %s
ORDER BY ip.ip ORDER BY INET_ATON(ip.ip)
''', (search_pattern, search_pattern, search_pattern)) ''', (search_pattern, search_pattern, search_pattern))
results['ips'] = [{'id': row[0], 'ip': row[1], 'hostname': row[2], results['ips'] = [{'id': row[0], 'ip': row[1], 'hostname': row[2],
'subnet_id': row[3], 'subnet_name': row[4], 'subnet_id': row[3], 'subnet_name': row[4],
@@ -4177,7 +4150,7 @@ def register_routes(app, limiter=None):
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
LEFT JOIN Device d ON dia.device_id = d.id LEFT JOIN Device d ON dia.device_id = d.id
WHERE ip.subnet_id = %s WHERE ip.subnet_id = %s
ORDER BY ip.ip ORDER BY INET_ATON(ip.ip)
''', (subnet_id,)) ''', (subnet_id,))
subnet['ip_addresses'] = cursor.fetchall() subnet['ip_addresses'] = cursor.fetchall()
# Get custom fields # Get custom fields
@@ -5215,7 +5188,7 @@ def register_routes(app, limiter=None):
FROM DeviceIPAddress dia FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id JOIN IPAddress ip ON dia.ip_id = ip.id
WHERE dia.device_id = %s WHERE dia.device_id = %s
ORDER BY ip.ip ORDER BY INET_ATON(ip.ip)
LIMIT 1 LIMIT 1
''', (device['id'],)) ''', (device['id'],))
ip_result = cursor.fetchone() ip_result = cursor.fetchone()
@@ -5562,19 +5535,19 @@ def register_routes(app, limiter=None):
writer.writerow([f"Subnet: {subnet[1]} ({subnet[2]})"]) writer.writerow([f"Subnet: {subnet[1]} ({subnet[2]})"])
writer.writerow(['IP Address', 'Hostname', 'Description']) writer.writerow(['IP Address', 'Hostname', 'Description'])
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('''
SELECT ip.id, ip.ip, ip.hostname, d.id, d.description, ip.notes
FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
LEFT JOIN Device d ON dia.device_id = d.id
WHERE ip.subnet_id = %s
ORDER BY INET_ATON(ip.ip)
''', (subnet_id,))
ip_addresses = cursor.fetchall() ip_addresses = cursor.fetchall()
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
for ip in ip_addresses: for ip in ip_addresses:
hostname = ip[2] hostname = ip[2]
device_description = None device_description = ip[4] if len(ip) > 4 else None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_description = match[1]
writer.writerow([ip[1] or '', hostname or '', device_description or '']) writer.writerow([ip[1] or '', hostname or '', device_description or ''])
writer.writerow([]) # Empty row between subnets writer.writerow([]) # Empty row between subnets
+11 -5
View File
@@ -24,12 +24,18 @@
</div> </div>
<ul class="subnet-list hidden space-y-4 px-2 pb-4"> <ul class="subnet-list hidden space-y-4 px-2 pb-4">
{% for subnet in subnets %} {% for subnet in subnets %}
<li class="p-4 bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 hover:dark:bg-zinc-700 rounded-lg shadow-md flex items-center justify-between"> <li class="relative p-4 bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 hover:dark:bg-zinc-700 rounded-lg shadow-md flex items-center justify-between">
<a href="/subnet/{{ subnet.id }}"> <a href="/subnet/{{ subnet.id }}" class="absolute inset-0 z-0 rounded-lg"></a>
<div class="pointer-events-none z-10 flex-grow">
<p class="text-gray-900 dark:text-white text-lg font-medium">{{ subnet.name }}</p> <p class="text-gray-900 dark:text-white text-lg font-medium">{{ subnet.name }}</p>
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p> <div class="flex items-center space-x-2">
</a> <p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
<button type="button" class="export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 hover:cursor-pointer flex items-center justify-center rounded-full w-9 h-9" title="Export as CSV" data-subnet-id="{{ subnet.id }}"> {% if subnet.vlan_id %}
<span class="px-2 py-0.5 text-xs font-semibold bg-gray-200 dark:bg-zinc-800 rounded-full">VLAN {{ subnet.vlan_id }}</span>
{% endif %}
</div>
</div>
<button type="button" class="relative z-10 export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 hover:cursor-pointer flex items-center justify-center rounded-full w-9 h-9 shrink-0" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv"></i> <i class="fas fa-file-csv"></i>
</button> </button>
</li> </li>