32 Commits

Author SHA1 Message Date
jamie dddfa347e6 refactor: 🎨 use shared helpers 2026-05-23 16:24:34 +00:00
jamie bd5f2e7e32 refactor: 🎨 consolidate to a single file 2026-05-23 16:16:51 +00:00
jamie c5406e2c7c refactor: 🎨 remove caching 2026-05-23 16:07:09 +00:00
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
jamie 6f2cfad65f Merge pull request 'v1.9.5' (#38) from v1.9.5 into main
Reviewed-on: #38
2026-01-08 16:24:32 +00:00
jamie 2621d233f9 fix: 🐛 update version display logic to omit 'v' prefix for dev versions
Release / release (pull_request) Successful in 32s
Release / Deploy to Kubernetes (pull_request) Successful in 2s
2026-01-08 16:24:05 +00:00
jamie af4997df5a fix: 🐛 remove leading 'v' from version display in header template 2026-01-08 16:18:26 +00:00
jamie 1980fd04ba Merge pull request 'fix: 🐛 update workflow trigger to workflow_dispatch in dev.yml' (#37) from v1.9.4 into main
Reviewed-on: #37
2026-01-08 16:01:57 +00:00
jamie d06d0c76c2 fix: 🐛 update workflow trigger to workflow_dispatch in dev.yml
Release / release (pull_request) Successful in 32s
Release / Deploy to Kubernetes (pull_request) Successful in 1s
2026-01-08 16:00:55 +00:00
jamie 9244328da8 Merge pull request 'v1.9.3' (#36) from v1.9.3 into main
Dev / build (push) Has been cancelled
Dev / Deploy to Kubernetes (push) Has been cancelled
Reviewed-on: #36
2026-01-08 15:59:14 +00:00
jamie 70489c3dac fix: 🐛 update container image reference in Docker configurations
Dev / build (push) Successful in 2s
Dev / Deploy to Kubernetes (push) Successful in 1s
Release / release (pull_request) Successful in 32s
Release / Deploy to Kubernetes (pull_request) Successful in 1s
2026-01-08 15:57:58 +00:00
jamie 2a3ee1c8af fix: 🐛 update deployment configurations for dev and prod environments
Dev / build (push) Successful in 29s
Dev / Deploy to Kubernetes (push) Successful in 1s
2026-01-08 15:55:54 +00:00
jamie 8a01cb4755 Merge pull request 'ci: 🚀 switch to gitea' (#35) from v1.9.2 into main
Dev / build (push) Successful in 1s
Dev / Deploy to Kubernetes (push) Successful in 2s
Reviewed-on: #35
2026-01-08 15:53:11 +00:00
jamie d85b409662 chore: remove release-please configuration and version files
Release Please / release-please (push) Has been cancelled
Staging / Build and Push (push) Has been cancelled
Staging / Deploy to Kubernetes (push) Has been cancelled
2026-01-08 15:52:13 +00:00
jamie 9dfea6c795 fix: 🐛 update container image registry in deployment configuration
Dev / build (push) Successful in 2s
Dev / Deploy to Kubernetes (push) Successful in 1s
Release / release (pull_request) Successful in 31s
Release / Deploy to Kubernetes (pull_request) Failing after 2s
2026-01-08 15:48:30 +00:00
jamie 29cb46963c fix: 🐛 update workflow trigger from pull_request to push
Dev / build (push) Successful in 29s
Dev / Deploy to Kubernetes (push) Successful in 6s
2026-01-08 15:44:52 +00:00
jamie ca7c5f77a4 ci: 🚀 switch to gitea
Dev / release (pull_request) Has been skipped
Dev / Deploy to Kubernetes (pull_request) Has been skipped
2026-01-08 15:42:13 +00:00
jamie 9f28113573 Merge pull request 'chore(main): release 1.10.0' (#34) from release-please--branches--main into main
Staging / Build and Push (push) Has been cancelled
Staging / Deploy to Kubernetes (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Reviewed-on: #34
2026-01-08 15:38:38 +00:00
github-actions[bot] f4920cbee6 chore(main): release 1.10.0 2025-12-31 01:08:53 +00:00
jamie c1b0a7084b feat: feature flags 2025-12-31 01:08:30 +00:00
Jamie 9558baf84e Merge pull request #33 from JDB-NET/release-please--branches--main
chore(main): release 1.9.1
2025-12-29 18:24:31 +00:00
github-actions[bot] 5912bc6367 chore(main): release 1.9.1 2025-12-29 18:24:13 +00:00
jamie 83c1b21c04 fix: 🐛 device page dictionary 2025-12-29 18:23:50 +00:00
28 changed files with 5113 additions and 6170 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ CHANGELOG.md
*.md *.md
# Deployment files # Deployment files
deployment.yml deployment-dev.yml
deployment-prod.yml
run.sh run.sh
Dockerfile Dockerfile
.dockerignore .dockerignore
+18
View File
@@ -0,0 +1,18 @@
name: Dev
on:
workflow_dispatch:
jobs:
build:
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- 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
+74
View File
@@ -0,0 +1,74 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
name: Build & Release
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
- name: Generate Changelog
id: changelog
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/ipam:$VERSION \
-t cr.jdbnet.co.uk/public/ipam:latest \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/ipam:$VERSION
docker push cr.jdbnet.co.uk/public/ipam:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: 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
-72
View File
@@ -1,72 +0,0 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
packages: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
manifest-file: .release-please-manifest.json
config-file: .release-please-config.json
- name: Checkout
if: ${{ steps.release.outputs.release_created }}
uses: actions/checkout@v4
- name: Set up Docker Buildx
if: ${{ steps.release.outputs.release_created }}
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: ${{ steps.release.outputs.release_created }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Read version
if: ${{ steps.release.outputs.release_created }}
id: version
run: |
VERSION=$(cat VERSION)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build and push Docker image
if: ${{ steps.release.outputs.release_created }}
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/jdb-net/ipam:${{ env.VERSION }}
ghcr.io/jdb-net/ipam:latest
build-args: |
VERSION=${{ env.VERSION }}
deploy:
name: Deploy to Kubernetes
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: [ k3s-internal-htz-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply manifests
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
-54
View File
@@ -1,54 +0,0 @@
{
"packages": {
".": {
"release-type": "simple",
"version-file": "VERSION",
"extra-files": [
"CHANGELOG.md"
],
"changelog-sections": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "refactor",
"section": "Refactoring"
},
{
"type": "style",
"section": "Style Changes"
},
{
"type": "perf",
"section": "Performance Improvements"
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "test",
"section": "Tests"
},
{
"type": "build",
"section": "Build System"
},
{
"type": "ci",
"section": "CI/CD"
},
{
"type": "chore",
"section": "Chores"
}
]
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
-3
View File
@@ -1,3 +0,0 @@
{
".": "1.9.0"
}
-172
View File
@@ -1,172 +0,0 @@
# Changelog
## [1.9.0](https://github.com/JDB-NET/ipam/compare/v1.8.0...v1.9.0) (2025-12-27)
### Features
* :sparkles: api rate limiting ([e316a16](https://github.com/JDB-NET/ipam/commit/e316a1638661e023a23b4b164fc2c773cd2f7e2a))
* :sparkles: custom fields by device or subnet ([b23cda4](https://github.com/JDB-NET/ipam/commit/b23cda48af575b92a16be2c211e2b2ebb9008a56))
* :sparkles: ip address history ([21042b7](https://github.com/JDB-NET/ipam/commit/21042b7fd701ecf025ba6d9407137d05156d884a))
* :sparkles: ip address notes/descriptions ([8b001a0](https://github.com/JDB-NET/ipam/commit/8b001a047b263501300213d40a474b19e976cabb))
* :sparkles: log api usage to audit log ([e028f96](https://github.com/JDB-NET/ipam/commit/e028f9610cb09c5551594a32fada43e7078a2a73))
* :sparkles: two factor authentication ([5037c1b](https://github.com/JDB-NET/ipam/commit/5037c1b57823a59ed4dfda6dc3a16d570cde47bd))
* :sparkles: vlan management ([c7350ae](https://github.com/JDB-NET/ipam/commit/c7350aeb1f5b3ac471e40a351c66f9a82b70bdf4))
### Bug Fixes
* :bug: 2fa verification ([53dc19a](https://github.com/JDB-NET/ipam/commit/53dc19a549ca4255d22cc9812c6e7e83e1b76697))
### Refactoring
* :art: auto save custom fields ([7e1c4b1](https://github.com/JDB-NET/ipam/commit/7e1c4b126e0d45010b17af7be64f31c85e33d76d))
* :art: minify ([9106799](https://github.com/JDB-NET/ipam/commit/91067994bac7688b95e8c71a69b14967f640407e))
### Style Changes
* :lipstick: backup code button ([181e2b2](https://github.com/JDB-NET/ipam/commit/181e2b2ca53b2c7b99899f54bef028a3d9ac30eb))
## [1.8.0](https://github.com/JDB-NET/ipam/compare/v1.7.0...v1.8.0) (2025-12-23)
### Features
* :sparkles: get next available ip by api ([64ae4be](https://github.com/JDB-NET/ipam/commit/64ae4be6d5997ff0b16ff5232237d38f2fec5b64))
### Bug Fixes
* :bug: global search missing from devices ([283c445](https://github.com/JDB-NET/ipam/commit/283c445263b7dc992448d907e682e53b7720b610))
### Build System
* :rocket: redeploy ([d7fcffd](https://github.com/JDB-NET/ipam/commit/d7fcffd4b5598b682dede864ba526b1257584f6a))
## [1.7.0](https://github.com/JDB-NET/ipam/compare/v1.6.1...v1.7.0) (2025-12-05)
### Features
* :sparkles: add devices by tag page ([9c0e6d0](https://github.com/JDB-NET/ipam/commit/9c0e6d035c8dda68281b2bfe2b7a61802353f7a7))
### Bug Fixes
* :bug: invalidate cache when device type is added ([47208b3](https://github.com/JDB-NET/ipam/commit/47208b31eed51f0cf0d7c8c411093bda1c84cf1b))
* :bug: invalidate linked cache ([8242e9d](https://github.com/JDB-NET/ipam/commit/8242e9d758ef19030b516e4a51f0cfb556f4e5ba))
## [1.6.1](https://github.com/JDB-NET/ipam/compare/v1.6.0...v1.6.1) (2025-12-05)
### Bug Fixes
* :bug: invalidate subnet cache when device is deleted ([286bf4b](https://github.com/JDB-NET/ipam/commit/286bf4b665e6352dea7b14753f080fa5cabb7926))
## [1.6.0](https://github.com/JDB-NET/ipam/compare/v1.5.1...v1.6.0) (2025-12-05)
### Features
* :sparkles: backup and restore ([707846b](https://github.com/JDB-NET/ipam/commit/707846bb3c717df9223ea7103e29efc6e671e16d))
* :sparkles: bulk operations ([2163be8](https://github.com/JDB-NET/ipam/commit/2163be8f79b579e38944a689915a18d5c35f8d3a))
* :sparkles: global search ([3e8965d](https://github.com/JDB-NET/ipam/commit/3e8965de6f19b3b382e236b08df685401205f356))
* :sparkles: in memory cache ([3a9250f](https://github.com/JDB-NET/ipam/commit/3a9250f5b0c14bfc6a807fe2948bbc852a652047))
* :sparkles: subnet utilisation stats ([f98e92d](https://github.com/JDB-NET/ipam/commit/f98e92da062640d47bec3516def0efde3aebd058))
* :sparkles: update available notification ([730b870](https://github.com/JDB-NET/ipam/commit/730b8701db81f5e03760a25209baeab2f81116fa))
### Refactoring
* :art: database indexing and optimisation ([47f68fd](https://github.com/JDB-NET/ipam/commit/47f68fd27cf62d0e0d2af55089bc0556043c12ff))
* :art: header link to github releases ([61e3200](https://github.com/JDB-NET/ipam/commit/61e320020724e437d8a607e7341b12b2fe6f794d))
* :art: improved audit log filtering ([f016598](https://github.com/JDB-NET/ipam/commit/f0165985fc194fd3a3e460b52447a5511908ed91))
* :art: js ([1d9209a](https://github.com/JDB-NET/ipam/commit/1d9209a714a6d0b7d1901b6e3470f5265e0171a6))
* :art: tidy nav bar ([69588d6](https://github.com/JDB-NET/ipam/commit/69588d6518571d8de55c718c14176bb78cb19ee1))
### CI/CD
* :rocket: include all commit types ([f6795f5](https://github.com/JDB-NET/ipam/commit/f6795f52815a2d599840c8ed83c99ad690a046c8))
## [1.5.1](https://github.com/JDB-NET/ipam/compare/v1.5.0...v1.5.1) (2025-12-04)
### Bug Fixes
* :bug: audit log on mobile ([6f01c99](https://github.com/JDB-NET/ipam/commit/6f01c9956f4a31414a082a779eb493735df0b8e6))
## [1.5.0](https://github.com/JDB-NET/ipam/compare/v1.4.2...v1.5.0) (2025-11-21)
### Features
* :sparkles: device tags ([ad1e576](https://github.com/JDB-NET/ipam/commit/ad1e576da42bf90c59347f7f7a4cce13c6842204))
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
### Bug Fixes
* :bug: ensure all fields are updated by api ([5c1ad03](https://github.com/JDB-NET/ipam/commit/5c1ad039904b2c8c8629242b5558b03da5ad782c))
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
### Bug Fixes
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
### Features
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
### Features
* :sparkles: role based access control ([3bf2697](https://github.com/JDB-NET/ipam/commit/3bf269701030bc1f14a48c5af488286c424dbfa7))
## [1.2.0](https://github.com/JDB-NET/ipam/compare/v1.1.1...v1.2.0) (2025-11-06)
### Features
* :sparkles: added the ability to create/edit/remove device types ([d68eefc](https://github.com/JDB-NET/ipam/commit/d68eefcf0cc4a59cda9cedb3e126d974ee45d2ad))
### Bug Fixes
* :bug: missing button classes ([f93fa15](https://github.com/JDB-NET/ipam/commit/f93fa155eb5d6c9ff4ed19f332c3ad6fff328d31))
## [1.1.1](https://github.com/JDB-NET/ipam/compare/v1.1.0...v1.1.1) (2025-11-01)
### Bug Fixes
* :bug: image name ([de123fa](https://github.com/JDB-NET/ipam/commit/de123fafd40d97ea6e545bd8dd1d3a812e2a709f))
## [1.1.0](https://github.com/JDB-NET/ipam/compare/v1.0.0...v1.1.0) (2025-11-01)
### Features
* Added icon on login button. Closes [#1](https://github.com/JDB-NET/ipam/issues/1) ([6e068b6](https://github.com/JDB-NET/ipam/commit/6e068b672592f7d23ca66a0a6189b5763d89a698))
* Added light mode up to admin ([38c8402](https://github.com/JDB-NET/ipam/commit/38c840251f03c8f1e1a2c407efa77621df70ce2f))
* Rack stuff now complete ([5d220d3](https://github.com/JDB-NET/ipam/commit/5d220d354df83db8b2bfbf8e2c87bd78ba91f6e5))
### Bug Fixes
* Back buttons now hidden on mobile ([40a7a2f](https://github.com/JDB-NET/ipam/commit/40a7a2f2d58f6c89a7e7e74908c088e7eddf966a))
* Corrected image in deployment ([9ecd492](https://github.com/JDB-NET/ipam/commit/9ecd492065fcd226d274f8e343d401437e1c8de8))
* Fixed back button on device page ([9734e4d](https://github.com/JDB-NET/ipam/commit/9734e4df0b27461867393c132991f9e2ec907de4))
* Fixed database initialisation and dropped to 1 worker ([7cd6a0f](https://github.com/JDB-NET/ipam/commit/7cd6a0f96d8dc20743603d55498d8c1af8069690))
+3
View File
@@ -1,6 +1,9 @@
FROM python:3.13-slim FROM python:3.13-slim
LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl mariadb-client-compat RUN apt-get update && apt-get install -y curl mariadb-client-compat
RUN rm -rf /var/lib/apt/lists/* RUN rm -rf /var/lib/apt/lists/*
+4 -4
View File
@@ -1,5 +1,5 @@
<div align="center"> <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 # IP Address Management
</div> </div>
@@ -40,7 +40,7 @@ docker run -d \
-e SECRET_KEY=your_secret_key \ -e SECRET_KEY=your_secret_key \
-e NAME="Your Organisation" \ -e NAME="Your Organisation" \
-e LOGO_PNG="https://example.com/logo.png" \ -e LOGO_PNG="https://example.com/logo.png" \
ghcr.io/jdb-net/ipam:latest cr.jdbnet.co.uk/public/ipam:latest
``` ```
### Docker Compose ### Docker Compose
@@ -48,7 +48,7 @@ docker run -d \
```yaml ```yaml
services: services:
ipam: ipam:
image: ghcr.io/jdb-net/ipam:latest image: cr.jdbnet.co.uk/public/ipam:latest
container_name: ipam container_name: ipam
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -247,7 +247,7 @@ spec:
spec: spec:
containers: containers:
- name: ipam - name: ipam
image: ghcr.io/jdb-net/ipam:latest image: cr.jdbnet.co.uk/public/ipam:latest
ports: ports:
- containerPort: 5000 - containerPort: 5000
env: env:
-1
View File
@@ -1 +0,0 @@
1.9.0
+4767 -31
View File
File diff suppressed because it is too large Load Diff
-191
View File
@@ -1,191 +0,0 @@
"""
In-memory caching module with TTL support and cache invalidation
"""
import time
import sys
from threading import Lock
from functools import wraps
class Cache:
"""Simple in-memory cache with TTL support and size limiting"""
def __init__(self, max_size_mb=50):
self._cache = {}
self._lock = Lock()
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
self._access_order = [] # Track access order for LRU eviction
def _get_size(self, obj):
"""Estimate size of an object in bytes"""
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
elif isinstance(obj, (list, tuple)):
size += sum(self._get_size(item) for item in obj)
elif isinstance(obj, str):
size += sys.getsizeof(obj) - sys.getsizeof('')
return size
def _get_cache_size(self):
"""Get approximate total size of cache in bytes"""
total_size = sys.getsizeof(self._cache)
for key, (value, expiry) in self._cache.items():
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
return total_size
def _evict_if_needed(self):
"""Evict entries if cache exceeds size limit"""
current_size = self._get_cache_size()
if current_size <= self._max_size_bytes:
return
# First, remove expired entries
current_time = time.time()
expired_keys = []
for key in list(self._cache.keys()):
_, expiry = self._cache[key]
if expiry is not None and current_time >= expiry:
expired_keys.append(key)
for key in expired_keys:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
# If still over limit, remove oldest entries (LRU)
current_size = self._get_cache_size()
while current_size > self._max_size_bytes and self._access_order:
oldest_key = self._access_order.pop(0)
if oldest_key in self._cache:
del self._cache[oldest_key]
current_size = self._get_cache_size()
def get(self, key):
"""Get value from cache if it exists and hasn't expired"""
with self._lock:
if key in self._cache:
value, expiry = self._cache[key]
if expiry is None or time.time() < expiry:
# Update access order (move to end for LRU)
if key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
return value
else:
# Expired, remove it
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
return None
def set(self, key, value, ttl=None):
"""Set value in cache with optional TTL (time to live in seconds)"""
with self._lock:
# Remove old entry if it exists
if key in self._cache:
if key in self._access_order:
self._access_order.remove(key)
expiry = None if ttl is None else time.time() + ttl
self._cache[key] = (value, expiry)
self._access_order.append(key)
# Evict if needed to stay under size limit
self._evict_if_needed()
def delete(self, key):
"""Delete a key from cache"""
with self._lock:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def clear(self, pattern=None):
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
with self._lock:
if pattern is None:
self._cache.clear()
self._access_order.clear()
else:
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_subnet(self, subnet_id):
"""Invalidate all cache entries related to a specific subnet"""
patterns = [
f'subnet:{subnet_id}',
f'subnet_list',
f'index',
f'admin',
f'utilization:{subnet_id}'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_device(self, device_id):
"""Invalidate all cache entries related to a specific device"""
patterns = [
f'device:{device_id}',
f'device_list',
f'devices',
f'device_types'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_all(self):
"""Invalidate all cache entries"""
self.clear()
# Global cache instance
cache = Cache()
def cached(ttl=None, key_prefix=''):
"""
Decorator to cache function results
Args:
ttl: Time to live in seconds (None = no expiration)
key_prefix: Prefix for cache key
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key from function name, args, and kwargs
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
# Try to get from cache
cached_value = cache.get(cache_key)
if cached_value is not None:
return cached_value
# Call function and cache result
result = func(*args, **kwargs)
cache.set(cache_key, result, ttl)
return result
return wrapper
return decorator
+27
View File
@@ -6,6 +6,7 @@ import mysql.connector
import logging import logging
from flask import current_app from flask import current_app
# ── Connection, crypto, schema init ─────────────────────────────────────────
def hash_password(password, salt=None): def hash_password(password, salt=None):
if salt is None: if salt is None:
salt = base64.b64encode(os.urandom(16)).decode('utf-8') salt = base64.b64encode(os.urandom(16)).decode('utf-8')
@@ -350,6 +351,32 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL') cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL')
# Create FeatureFlags table
cursor.execute('''
CREATE TABLE IF NOT EXISTS FeatureFlags (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
feature_key VARCHAR(255) NOT NULL UNIQUE,
enabled BOOLEAN DEFAULT TRUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
''')
# Initialize default feature flags
default_features = [
('racks', True, 'Enable rack management functionality'),
('ip_address_notes', True, 'Enable IP address notes/descriptions editing on subnet page'),
('device_tags', True, 'Enable device tagging functionality'),
('bulk_operations', True, 'Enable bulk operations for devices and IPs')
]
for feature_key, enabled, description in default_features:
cursor.execute('SELECT id FROM FeatureFlags WHERE feature_key = %s', (feature_key,))
if not cursor.fetchone():
cursor.execute('INSERT INTO FeatureFlags (feature_key, enabled, description) VALUES (%s, %s, %s)',
(feature_key, enabled, description))
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
-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: ghcr.io/jdb-net/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
-1
View File
@@ -5,4 +5,3 @@ gunicorn
requests requests
pyotp pyotp
qrcode[pil] qrcode[pil]
Flask-Limiter
-5445
View File
File diff suppressed because it is too large Load Diff
+85 -1
View File
@@ -1,12 +1,96 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const navToggle = document.getElementById('nav-toggle'); const navToggle = document.getElementById('nav-toggle');
const mobileNav = document.getElementById('mobile-nav'); const mobileNav = document.getElementById('mobile-nav');
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() { navToggle.addEventListener('click', function() {
mobileNav.classList.toggle('hidden'); 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) { 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'); 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()})});
+4 -4
View File
@@ -16,12 +16,12 @@ document.addEventListener('DOMContentLoaded', function() {
const compareLink = document.getElementById('toast-compare-link'); const compareLink = document.getElementById('toast-compare-link');
const closeBtn = document.getElementById('toast-close'); const closeBtn = document.getElementById('toast-close');
// Set versions // Set versions (don't add 'v' prefix for dev versions)
currentVersionEl.textContent = 'v' + data.current_version; currentVersionEl.textContent = (data.current_version === 'dev' ? '' : 'v') + data.current_version;
latestVersionEl.textContent = 'v' + data.latest_version; latestVersionEl.textContent = (data.latest_version === 'dev' ? '' : 'v') + data.latest_version;
// Set compare link (current version to latest version) // Set compare link (current version to latest version)
compareLink.href = `https://github.com/JDB-NET/ipam/compare/v${data.current_version}...v${data.latest_version}`; compareLink.href = `https://git.jdbnet.co.uk/jamie/ipam/compare/v${data.current_version}...v${data.latest_version}`;
// Show toast // Show toast
toast.classList.remove('hidden'); toast.classList.remove('hidden');
+1 -1
View File
@@ -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"),o=document.getElementById("toast-close");n.textContent="v"+t.current_version,s.textContent="v"+t.latest_version,a.href=`https://github.com/JDB-NET/ipam/compare/v${t.current_version}...v${t.latest_version}`,e.classList.remove("hidden"),o.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)})});
+35 -1
View File
@@ -42,7 +42,7 @@
</div> </div>
<i class="fas fa-chevron-right text-gray-400"></i> <i class="fas fa-chevron-right text-gray-400"></i>
</a> </a>
{% if has_permission('view_tags') %} {% if has_permission('view_tags') and is_feature_enabled('device_tags') %}
<a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors"> <a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i> <i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
@@ -167,6 +167,40 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Feature Flags Section -->
{% if has_permission('manage_users') %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md mt-8">
<h2 class="text-2xl font-bold mb-6">Feature Flags</h2>
<form action="/admin/feature_flags" method="POST">
<div class="space-y-4">
{% for flag in feature_flags %}
<div class="flex items-center justify-between p-4 bg-gray-300 dark:bg-zinc-700 rounded-lg">
<div class="flex-1">
<label for="feature_{{ flag.key }}" class="text-lg font-semibold cursor-pointer">
{{ flag.key|replace('_', ' ')|title }}
</label>
{% if flag.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ flag.description }}</p>
{% endif %}
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="feature_{{ flag.key }}" id="feature_{{ flag.key }}"
class="sr-only peer" {% if flag.enabled %}checked{% endif %}>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-gray-500 dark:peer-checked:bg-zinc-600"></div>
</label>
</div>
{% endfor %}
</div>
<div class="flex justify-end mt-6">
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
</div>
{% endif %}
</div> </div>
</div> </div>
+4
View File
@@ -22,7 +22,9 @@
<div class="flex space-x-4"> <div class="flex space-x-4">
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 font-medium border-b-2 border-gray-600 text-gray-900 dark:text-gray-100 hover:cursor-pointer">Bulk IP Assignment</button> <button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 font-medium border-b-2 border-gray-600 text-gray-900 dark:text-gray-100 hover:cursor-pointer">Bulk IP Assignment</button>
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Device Creation</button> <button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Device Creation</button>
{% if is_feature_enabled('device_tags') %}
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Tag Assignment</button> <button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Tag Assignment</button>
{% endif %}
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Export</button> <button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Export</button>
</div> </div>
</div> </div>
@@ -94,6 +96,7 @@
</div> </div>
<!-- Bulk Tag Assignment --> <!-- Bulk Tag Assignment -->
{% if is_feature_enabled('device_tags') %}
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md"> <div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_assign_device_tag %} {% if can_assign_device_tag %}
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2> <h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
@@ -123,6 +126,7 @@
<p class="text-gray-500">You don't have permission to assign tags to devices.</p> <p class="text-gray-500">You don't have permission to assign tags to devices.</p>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<!-- Bulk Export --> <!-- Bulk Export -->
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md"> <div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
+2
View File
@@ -115,6 +115,7 @@
{% endif %} {% endif %}
<!-- Tags Section --> <!-- Tags Section -->
{% if is_feature_enabled('device_tags') %}
<div class="tags-section mb-6"> <div class="tags-section mb-6">
<h3 class="text-lg font-bold mb-2">Tags:</h3> <h3 class="text-lg font-bold mb-2">Tags:</h3>
<div class="flex flex-wrap gap-2 mb-4"> <div class="flex flex-wrap gap-2 mb-4">
@@ -155,6 +156,7 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<form action="/update_device_description" method="POST" class="mb-6 mt-4"> <form action="/update_device_description" method="POST" class="mb-6 mt-4">
<input type="hidden" name="device_id" value="{{ device.id }}"> <input type="hidden" name="device_id" value="{{ device.id }}">
+5 -1
View File
@@ -16,14 +16,16 @@
<h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1> <h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1>
<div class="flex flex-row justify-center gap-4 mb-6 flex-wrap"> <div class="flex flex-row justify-center gap-4 mb-6 flex-wrap">
<a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a> <a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a>
{% if is_feature_enabled('bulk_operations') %}
<a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a> <a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a>
{% endif %}
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a> <a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
</div> </div>
<!-- Filters Section --> <!-- Filters Section -->
<div class="mb-6 space-y-4"> <div class="mb-6 space-y-4">
<!-- Tag Filter --> <!-- Tag Filter -->
{% if all_tag_names %} {% if is_feature_enabled('device_tags') and all_tag_names %}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<label class="text-sm font-medium">Filter by tag:</label> <label class="text-sm font-medium">Filter by tag:</label>
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600"> <select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
@@ -67,6 +69,7 @@
</span> </span>
</div> </div>
<!-- Tags --> <!-- Tags -->
{% if is_feature_enabled('device_tags') %}
{% set tags = device_tags.get(device.id, []) %} {% set tags = device_tags.get(device.id, []) %}
{% if tags %} {% if tags %}
<div class="flex flex-wrap gap-1 mt-2"> <div class="flex flex-wrap gap-1 mt-2">
@@ -78,6 +81,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
+42 -24
View File
@@ -4,19 +4,15 @@
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded"> <img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
<span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span> <span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
</a> </a>
<a href="https://github.com/JDB-NET/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> <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> </div>
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav"> <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') %} {% if has_permission('view_index') %}
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2"> <a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-home"></i> <i class="fas fa-home"></i>
@@ -29,7 +25,7 @@
<span>Devices</span> <span>Devices</span>
</a> </a>
{% endif %} {% endif %}
{% if has_permission('view_racks') %} {% if has_permission('view_racks') and is_feature_enabled('racks') %}
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2"> <a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-th"></i> <i class="fas fa-th"></i>
<span>Racks</span> <span>Racks</span>
@@ -58,22 +54,19 @@
</a> </a>
{% endif %} {% endif %}
</nav> </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"> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg> </svg>
</button> </button>
<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> </div>
</form> <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">
{% if has_permission('view_index') %} {% 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"> <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> <i class="fas fa-home"></i>
@@ -86,7 +79,7 @@
<span>Devices</span> <span>Devices</span>
</a> </a>
{% endif %} {% endif %}
{% if has_permission('view_racks') %} {% if has_permission('view_racks') and is_feature_enabled('racks') %}
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2"> <a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-th"></i> <i class="fas fa-th"></i>
<span>Racks</span> <span>Racks</span>
@@ -115,6 +108,31 @@
</a> </a>
{% endif %} {% endif %}
</div> </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> <script src="/static/js/header.min.js"></script>
<!-- Update Available Toast --> <!-- Update Available Toast -->
+10 -4
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>
<div class="flex items-center space-x-2">
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p> <p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
</a> {% if subnet.vlan_id %}
<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 }}"> <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>
+5
View File
@@ -251,6 +251,7 @@
<td class="text-left align-top hidden sm:table-cell desc-col"> <td class="text-left align-top hidden sm:table-cell desc-col">
{% set device_desc = ip[4] if ip[4] else '' %} {% set device_desc = ip[4] if ip[4] else '' %}
{% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %} {% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %}
{% if ip_notes_enabled %}
{% set combined_desc = '' %} {% set combined_desc = '' %}
{% if device_desc %} {% if device_desc %}
{% set combined_desc = device_desc %} {% set combined_desc = device_desc %}
@@ -267,6 +268,10 @@
{% else %} {% else %}
<textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea> <textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% endif %} {% endif %}
{% else %}
{# IP notes disabled - show only device description, read-only #}
<textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ device_desc }}</textarea>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
-70
View File
@@ -1,70 +0,0 @@
import pyotp
import qrcode
import secrets
import json
import base64
from io import BytesIO
from flask import current_app
def generate_totp_secret():
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def get_totp_uri(secret, email, issuer_name="IPAM"):
"""Generate TOTP URI for QR code"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=email,
issuer_name=issuer_name
)
def generate_qr_code(uri):
"""Generate QR code image from URI"""
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return base64.b64encode(buffer.getvalue()).decode('utf-8')
def verify_totp(secret, code):
"""Verify a TOTP code"""
if not secret or not code:
return False
try:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1) # Allow 1 time step window for clock skew
except Exception:
return False
def generate_backup_codes(count=10):
"""Generate backup codes for 2FA"""
return [secrets.token_urlsafe(8).upper() for _ in range(count)]
def verify_backup_code(backup_codes_json, code):
"""Verify a backup code and remove it if valid"""
if not backup_codes_json or not code:
return False, None
try:
codes = json.loads(backup_codes_json)
code_upper = code.upper().strip()
if code_upper in codes:
codes.remove(code_upper)
return True, json.dumps(codes) if codes else None
return False, None
except (json.JSONDecodeError, AttributeError):
return False, None
def format_backup_codes(codes):
"""Format backup codes for display (group in pairs)"""
formatted = []
for i in range(0, len(codes), 2):
if i + 1 < len(codes):
formatted.append(f"{codes[i]} {codes[i+1]}")
else:
formatted.append(codes[i])
return formatted