10 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
jamie d1f0e38374 Merge pull request 'feat: search modal' (#41) from v1.9.7 into main
Reviewed-on: #41
2026-02-19 20:25:29 +00:00
jamie 84d024f4c6 feat: search modal
Release / release (pull_request) Successful in 29s
2026-02-19 20:25:16 +00:00
jamie 1fa28590b4 Merge pull request 'fix: 🐛 nav bar items overlap with search bar' (#40) from v1.9.6 into main
Reviewed-on: #40
2026-02-19 19:35:01 +00:00
jamie 30a3ea66d5 fix: 🐛 nav bar items overlap with search bar
Release / release (pull_request) Successful in 29s
Release / Deploy to Kubernetes (pull_request) Has been cancelled
2026-02-19 19:34:43 +00:00
12 changed files with 213 additions and 261 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ CHANGELOG.md
*.md
# Deployment files
deployment.yml
deployment-dev.yml
deployment-prod.yml
run.sh
Dockerfile
.dockerignore
+1 -17
View File
@@ -14,21 +14,5 @@ jobs:
- name: Build and push Docker image
run: |
docker build -t cr.jdbnet.co.uk/public/ipam:dev \
--build-arg VERSION=dev \
.
docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
docker push cr.jdbnet.co.uk/public/ipam:dev
deploy:
name: Deploy to Kubernetes
needs: release
runs-on: k3s-internal-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Deploy to Kubernetes
run: |
sudo kubectl replace -f deployment-dev.yml --grace-period=60 --force
+21 -7
View File
@@ -8,6 +8,7 @@ on:
jobs:
release:
name: Build & Release
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
@@ -45,16 +46,29 @@ jobs:
draft: false
prerelease: false
deploy:
name: Deploy to Kubernetes
needs: release
runs-on: k3s-internal-htz-01
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Deploy to Kubernetes
- name: Create Valid Project Key
id: sonar_setup
run: |
sudo kubectl replace -f deployment-prod.yml --grace-period=60 --force
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
+1 -1
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://projects.jdbnet.co.uk/ipam/img/favicon.png" alt="IPAM" width="200" />
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
# IP Address Management
</div>
+1 -1
View File
@@ -34,7 +34,7 @@ def inject_env_vars():
return {
'NAME': os.environ.get('NAME', 'JDB-NET'),
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.s3.jdbnet.co.uk/logo/128x128.png'),
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/logo/128x128.png'),
'VERSION': version,
'has_permission': has_permission,
'is_feature_enabled': is_feature_enabled
-64
View File
@@ -1,64 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
selector:
matchLabels:
app: ipam
template:
metadata:
labels:
app: ipam
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:dev
imagePullPolicy: Always
ports:
- containerPort: 5000
name: "ipam"
env:
- name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST
value: "10.10.25.4"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
value: "WXPmo05sGCfjGe"
- name: MYSQL_DATABASE
value: "ipam"
---
apiVersion: v1
kind: Service
metadata:
name: ipam-ingress-service
namespace: ipam
spec:
selector:
app: ipam
ports:
- protocol: TCP
port: 80
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ipam-ingress
namespace: ipam
spec:
rules:
- host: ipam.jdb143.uk
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: ipam-ingress-service
port:
number: 80
-64
View File
@@ -1,64 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
selector:
matchLabels:
app: ipam
template:
metadata:
labels:
app: ipam
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
name: "ipam"
env:
- name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST
value: "10.10.25.4"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
value: "WXPmo05sGCfjGe"
- name: MYSQL_DATABASE
value: "ipam"
---
apiVersion: v1
kind: Service
metadata:
name: ipam-ingress-service
namespace: ipam
spec:
selector:
app: ipam
ports:
- protocol: TCP
port: 80
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ipam-ingress
namespace: ipam
spec:
rules:
- host: ipam.jdb143.uk
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: ipam-ingress-service
port:
number: 80
+43 -70
View File
@@ -539,7 +539,7 @@ def prewarm_cache(app):
# Pre-warm index page (all subnets with utilization)
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()
sites_subnets = {}
for subnet in subnets:
@@ -570,6 +570,7 @@ def prewarm_cache(app):
'id': subnet[0],
'name': subnet[1],
'cidr': subnet[2],
'vlan_id': subnet[4],
'utilization': round(utilization_percent, 1)
})
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,))
subnet_row = cursor.fetchone()
if subnet_row:
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
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_with_device = cursor.fetchall()
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
total_ips = cursor.fetchone()[0]
@@ -661,23 +669,6 @@ def prewarm_cache(app):
'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]}
result = {
'subnet': subnet_dict,
@@ -1000,7 +991,7 @@ def register_routes(app, limiter=None):
conn = get_db_connection(current_app)
try:
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()
sites_subnets = {}
for subnet in subnets:
@@ -1034,6 +1025,7 @@ def register_routes(app, limiter=None):
'id': subnet[0],
'name': subnet[1],
'cidr': subnet[2],
'vlan_id': subnet[4],
'utilization': round(utilization_percent, 1)
})
# Cache for 3 hours
@@ -1500,8 +1492,15 @@ def register_routes(app, limiter=None):
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
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_with_device = cursor.fetchall()
# Calculate utilization stats
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
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 = {
'id': subnet[0],
'name': subnet[1],
@@ -3139,24 +3121,15 @@ def register_routes(app, limiter=None):
subnet = cursor.fetchone()
if not subnet:
return 'Subnet not found', 404
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
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}
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))
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_with_device = cursor.fetchall()
output = StringIO()
writer = csv.writer(output)
writer.writerow(['IP Address', 'Hostname', 'Description'])
@@ -3761,7 +3734,7 @@ def register_routes(app, limiter=None):
FROM IPAddress ip
JOIN Subnet s ON ip.subnet_id = s.id
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))
results['ips'] = [{'id': row[0], 'ip': row[1], 'hostname': row[2],
'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 Device d ON dia.device_id = d.id
WHERE ip.subnet_id = %s
ORDER BY ip.ip
ORDER BY INET_ATON(ip.ip)
''', (subnet_id,))
subnet['ip_addresses'] = cursor.fetchall()
# Get custom fields
@@ -5215,7 +5188,7 @@ def register_routes(app, limiter=None):
FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id
WHERE dia.device_id = %s
ORDER BY ip.ip
ORDER BY INET_ATON(ip.ip)
LIMIT 1
''', (device['id'],))
ip_result = cursor.fetchone()
@@ -5562,19 +5535,19 @@ def register_routes(app, limiter=None):
writer.writerow([f"Subnet: {subnet[1]} ({subnet[2]})"])
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()
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:
hostname = ip[2]
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_description = match[1]
device_description = ip[4] if len(ip) > 4 else None
writer.writerow([ip[1] or '', hostname or '', device_description or ''])
writer.writerow([]) # Empty row between subnets
+88 -4
View File
@@ -1,12 +1,96 @@
document.addEventListener('DOMContentLoaded', function() {
const navToggle = document.getElementById('nav-toggle');
const mobileNav = document.getElementById('mobile-nav');
navToggle.addEventListener('click', function() {
mobileNav.classList.toggle('hidden');
});
const searchModal = document.getElementById('search-modal');
const searchModalOpen = document.getElementById('search-modal-open');
const searchModalOpenMobile = document.getElementById('search-modal-open-mobile');
const searchModalClose = document.getElementById('search-modal-close');
const searchModalBackdrop = document.getElementById('search-modal-backdrop');
const searchModalInput = document.getElementById('search-modal-input');
if (navToggle && mobileNav) {
navToggle.addEventListener('click', function() {
mobileNav.classList.toggle('hidden');
});
}
function openSearchModal() {
if (!searchModal) {
return;
}
searchModal.classList.remove('hidden');
searchModal.classList.add('flex');
document.body.classList.add('overflow-hidden');
if (mobileNav) {
mobileNav.classList.add('hidden');
}
setTimeout(function() {
if (searchModalInput) {
searchModalInput.focus();
searchModalInput.select();
}
}, 0);
}
function closeSearchModal() {
if (!searchModal) {
return;
}
searchModal.classList.add('hidden');
searchModal.classList.remove('flex');
document.body.classList.remove('overflow-hidden');
}
if (searchModalOpen) {
searchModalOpen.addEventListener('click', openSearchModal);
}
if (searchModalOpenMobile) {
searchModalOpenMobile.addEventListener('click', openSearchModal);
}
if (searchModalClose) {
searchModalClose.addEventListener('click', closeSearchModal);
}
if (searchModalBackdrop) {
searchModalBackdrop.addEventListener('click', closeSearchModal);
}
document.addEventListener('click', function(e) {
if (!mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
if (mobileNav && navToggle && !mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
mobileNav.classList.add('hidden');
}
});
document.addEventListener('keydown', function(e) {
const target = e.target;
const isEditableTarget = target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
);
if (
e.key === '/' &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
searchModal &&
!isEditableTarget
) {
e.preventDefault();
openSearchModal();
return;
}
if (e.key === 'Escape') {
closeSearchModal();
}
});
});
+1 -1
View File
@@ -1 +1 @@
document.addEventListener("DOMContentLoaded",function(){let t=document.getElementById("nav-toggle"),e=document.getElementById("mobile-nav");t.addEventListener("click",function(){e.classList.toggle("hidden")}),document.addEventListener("click",function(n){e.contains(n.target)||t.contains(n.target)||e.classList.add("hidden")})});
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("nav-toggle"),t=document.getElementById("mobile-nav"),o=document.getElementById("search-modal"),n=document.getElementById("search-modal-open"),d=document.getElementById("search-modal-open-mobile"),c=document.getElementById("search-modal-close"),l=document.getElementById("search-modal-backdrop"),a=document.getElementById("search-modal-input");function s(){o&&(o.classList.remove("hidden"),o.classList.add("flex"),document.body.classList.add("overflow-hidden"),t&&t.classList.add("hidden"),setTimeout(function(){a&&(a.focus(),a.select())},0))}function i(){o&&(o.classList.add("hidden"),o.classList.remove("flex"),document.body.classList.remove("overflow-hidden"))}e&&t&&e.addEventListener("click",function(){t.classList.toggle("hidden")}),n&&n.addEventListener("click",s),d&&d.addEventListener("click",s),c&&c.addEventListener("click",i),l&&l.addEventListener("click",i),document.addEventListener("click",function(o){t&&e&&!t.contains(o.target)&&!e.contains(o.target)&&t.classList.add("hidden")}),document.addEventListener("keydown",function(e){let t=e.target,n=t&&("INPUT"===t.tagName||"TEXTAREA"===t.tagName||"SELECT"===t.tagName||t.isContentEditable);if("/"===e.key&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&o&&!n)return e.preventDefault(),void s();"Escape"===e.key&&i()})});
+43 -25
View File
@@ -6,17 +6,13 @@
</a>
<a href="https://git.jdbnet.co.uk/jamie/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">{{ VERSION }}</a>
</div>
<div class="hidden lg:flex items-center justify-center absolute left-1/2" style="transform: translateX(calc(-50% + 1.5rem));">
<form action="/search" method="GET" class="flex items-center space-x-2">
<input type="text" name="q" id="search-input" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
{% if current_user_name %}
<button type="button" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2 hover:cursor-pointer" id="search-modal-open" aria-label="Open search modal">
<i class="fas fa-search"></i>
<span>Search</span>
</button>
{% endif %}
{% if has_permission('view_index') %}
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-home"></i>
@@ -58,22 +54,19 @@
</a>
{% endif %}
</nav>
<button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="lg:hidden flex items-center gap-3 flex-shrink-0">
{% if current_user_name %}
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="search-modal-open-mobile" aria-label="Open search modal">
<i class="fas fa-search text-xl"></i>
</button>
{% endif %}
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
<div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
<div class="flex items-center space-x-2">
<input type="text" name="q" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</div>
</form>
{% if has_permission('view_index') %}
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-home"></i>
@@ -115,6 +108,31 @@
</a>
{% endif %}
</div>
{% if current_user_name %}
<div id="search-modal" class="hidden fixed inset-0 z-50 items-center justify-center p-4">
<div id="search-modal-backdrop" class="absolute inset-0 bg-black/60"></div>
<div class="relative w-full max-w-2xl rounded-lg bg-zinc-800 border border-zinc-700 shadow-xl">
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
<h3 class="text-white font-semibold text-lg">Search</h3>
<button type="button" id="search-modal-close" class="text-gray-300 hover:text-white hover:cursor-pointer" aria-label="Close search modal">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/search" method="GET" class="p-4">
<div class="flex items-center gap-2">
<input type="text" name="q" id="search-modal-input" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-full"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0" aria-label="Submit search">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
</div>
{% endif %}
<script src="/static/js/header.min.js"></script>
<!-- Update Available Toast -->
+11 -5
View File
@@ -24,12 +24,18 @@
</div>
<ul class="subnet-list hidden space-y-4 px-2 pb-4">
{% 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">
<a href="/subnet/{{ subnet.id }}">
<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 }}" 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-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
</a>
<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 }}">
<div class="flex items-center space-x-2">
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
{% 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>
</button>
</li>