Compare commits
11 Commits
9244328da8
..
v1.9.8
| Author | SHA1 | Date | |
|---|---|---|---|
| ae28d3fb26 | |||
| 4d6a95e2b0 | |||
| d1f0e38374 | |||
| 84d024f4c6 | |||
| 1fa28590b4 | |||
| 30a3ea66d5 | |||
| 6f2cfad65f | |||
| 2621d233f9 | |||
| af4997df5a | |||
| 1980fd04ba | |||
| d06d0c76c2 |
+2
-1
@@ -4,7 +4,8 @@ CHANGELOG.md
|
||||
*.md
|
||||
|
||||
# Deployment files
|
||||
deployment.yml
|
||||
deployment-dev.yml
|
||||
deployment-prod.yml
|
||||
run.sh
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
name: Dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,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 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
|
||||
docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
|
||||
docker push cr.jdbnet.co.uk/public/ipam:dev
|
||||
@@ -43,18 +43,4 @@ jobs:
|
||||
name: ${{ steps.get_version.outputs.VERSION }}
|
||||
body: ${{ steps.changelog.outputs.changelog }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
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-prod.yml --grace-period=60 --force
|
||||
prerelease: false
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -631,8 +631,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 +668,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,
|
||||
@@ -1500,8 +1490,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 +1531,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],
|
||||
@@ -2615,7 +2595,7 @@ def register_routes(app, limiter=None):
|
||||
|
||||
try:
|
||||
# Get current version from environment
|
||||
current_version = os.environ.get('VERSION', 'unknown')
|
||||
current_version = os.environ.get('VERSION', 'unknown').lstrip('v')
|
||||
|
||||
# Fetch latest release from Gitea
|
||||
response = requests.get('https://git.jdbnet.co.uk/api/v1/repos/jamie/ipam/releases/latest', timeout=5)
|
||||
@@ -3139,24 +3119,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 +3732,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 +4148,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 +5186,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 +5533,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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+1
-1
@@ -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()})});
|
||||
@@ -16,9 +16,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const compareLink = document.getElementById('toast-compare-link');
|
||||
const closeBtn = document.getElementById('toast-close');
|
||||
|
||||
// Set versions
|
||||
currentVersionEl.textContent = 'v' + data.current_version;
|
||||
latestVersionEl.textContent = 'v' + data.latest_version;
|
||||
// Set versions (don't add 'v' prefix for dev versions)
|
||||
currentVersionEl.textContent = (data.current_version === 'dev' ? '' : 'v') + data.current_version;
|
||||
latestVersionEl.textContent = (data.latest_version === 'dev' ? '' : 'v') + data.latest_version;
|
||||
|
||||
// Set compare link (current version to latest version)
|
||||
compareLink.href = `https://git.jdbnet.co.uk/jamie/ipam/compare/v${data.current_version}...v${data.latest_version}`;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
document.addEventListener("DOMContentLoaded",function(){let t=sessionStorage.getItem("update-toast-dismissed");!t&&fetch("/check_update").then(t=>t.json()).then(t=>{if(t.update_available){let e=document.getElementById("update-toast"),n=document.getElementById("toast-current-version"),s=document.getElementById("toast-latest-version"),a=document.getElementById("toast-compare-link"),d=document.getElementById("toast-close");n.textContent="v"+t.current_version,s.textContent="v"+t.latest_version,a.href=`https://git.jdbnet.co.uk/jamie/ipam/compare/v${t.current_version}...v${t.latest_version}`,e.classList.remove("hidden"),d.addEventListener("click",function(){e.classList.add("hidden"),sessionStorage.setItem("update-toast-dismissed","true")})}}).catch(t=>{console.error("Error checking for updates:",t)})});
|
||||
document.addEventListener("DOMContentLoaded",function(){let e=sessionStorage.getItem("update-toast-dismissed");!e&&fetch("/check_update").then(e=>e.json()).then(e=>{if(e.update_available){let t=document.getElementById("update-toast"),n=document.getElementById("toast-current-version"),s=document.getElementById("toast-latest-version"),a=document.getElementById("toast-compare-link"),d=document.getElementById("toast-close");n.textContent=("dev"===e.current_version?"":"v")+e.current_version,s.textContent=("dev"===e.latest_version?"":"v")+e.latest_version,a.href=`https://git.jdbnet.co.uk/jamie/ipam/compare/v${e.current_version}...v${e.latest_version}`,t.classList.remove("hidden"),d.addEventListener("click",function(){t.classList.add("hidden"),sessionStorage.setItem("update-toast-dismissed","true")})}}).catch(e=>{console.error("Error checking for updates:",e)})});
|
||||
+44
-26
@@ -4,19 +4,15 @@
|
||||
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
|
||||
<span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
|
||||
</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">v{{ 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>
|
||||
<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>
|
||||
<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 -->
|
||||
|
||||
Reference in New Issue
Block a user