feat: ip address notes/descriptions

This commit is contained in:
2025-12-27 22:30:54 +00:00
parent b23cda48af
commit 8b001a047b
5 changed files with 246 additions and 15 deletions
+6
View File
@@ -332,6 +332,11 @@ def init_db(app=None):
# Initialize existing records with empty JSON object
cursor.execute("UPDATE Subnet SET custom_fields = '{}' WHERE custom_fields IS NULL")
# Add notes column to IPAddress table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM IPAddress LIKE 'notes'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE IPAddress ADD COLUMN notes TEXT DEFAULT NULL')
# Define all permissions with categories
permissions = [
# View permissions
@@ -529,6 +534,7 @@ def init_db(app=None):
create_index_if_not_exists(cursor, 'idx_ipaddress_hostname', 'IPAddress', 'hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_ip', 'IPAddress', 'ip')
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_hostname', 'IPAddress', 'subnet_id, hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_notes', 'IPAddress', 'notes(255)')
# DeviceIPAddress table indexes
create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_id', 'DeviceIPAddress', 'device_id')
+72 -11
View File
@@ -597,7 +597,7 @@ 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 * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
@@ -632,14 +632,17 @@ def prewarm_cache(app):
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[0], ip[1], hostname, device_id, device_description))
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 = {
@@ -1430,7 +1433,7 @@ def register_routes(app, limiter=None):
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
# Calculate utilization stats
@@ -1469,14 +1472,17 @@ def register_routes(app, limiter=None):
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[0], ip[1], hostname, device_id, device_description))
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
subnet_dict = {'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]}
result = {
@@ -2756,6 +2762,48 @@ def register_routes(app, limiter=None):
logging.info(f"User {user_name} updated description for device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@app.route('/ip/<int:ip_id>/update_notes', methods=['POST'])
@permission_required('edit_subnet')
def update_ip_notes(ip_id):
from flask import jsonify
user_name = get_current_user_name()
from flask import current_app
# Get notes from request (can be JSON or form data)
if request.is_json:
notes = request.json.get('notes', '')
else:
notes = request.form.get('notes', '')
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
# Get subnet_id for cache invalidation and audit log
cursor.execute('SELECT subnet_id, ip FROM IPAddress WHERE id = %s', (ip_id,))
ip_result = cursor.fetchone()
if not ip_result:
return jsonify({'success': False, 'error': 'IP address not found'}), 404
subnet_id, ip_address = ip_result
# Update notes
cursor.execute('UPDATE IPAddress SET notes = %s WHERE id = %s', (notes, ip_id))
conn.commit()
# Add audit log
add_audit_log(
session['user_id'],
'update_ip_notes',
f"Updated notes for IP {ip_address}",
subnet_id,
conn=conn
)
# Invalidate subnet cache
invalidate_cache_for_subnet(subnet_id)
logging.info(f"User {user_name} updated notes for IP {ip_address} (ID: {ip_id}).")
return jsonify({'success': True, 'message': 'Notes updated successfully'})
@app.route('/device/<int:device_id>/update_custom_fields', methods=['POST'])
@permission_required('edit_device')
def update_device_custom_fields(device_id):
@@ -2910,29 +2958,42 @@ def register_routes(app, limiter=None):
subnet = cursor.fetchone()
if not subnet:
return 'Subnet not found', 404
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
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[0], ip[1], hostname, device_id, device_description))
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
output = StringIO()
writer = csv.writer(output)
writer.writerow(['IP Address', 'Hostname', 'Description'])
for ip in ip_addresses_with_device:
ip_addr = ip[1] or ''
hostname = ip[2] or ''
description = (ip[4] or '').split('\n')[0] if ip[4] else ''
writer.writerow([ip_addr, hostname, description])
device_desc = ip[4] or ''
ip_notes = ip[5] if len(ip) > 5 and ip[5] else ''
# Combine device description and IP notes
combined_desc = ''
if device_desc:
combined_desc = device_desc
if ip_notes:
if combined_desc:
combined_desc = combined_desc + '\n' + ip_notes
else:
combined_desc = ip_notes
writer.writerow([ip_addr, hostname, combined_desc])
csv_bytes = output.getvalue().encode('utf-8')
output_bytes = BytesIO(csv_bytes)
output_bytes.seek(0)
@@ -3488,14 +3549,14 @@ def register_routes(app, limiter=None):
results['subnets'] = [{'id': row[0], 'name': row[1], 'cidr': row[2], 'site': row[3] or 'Unassigned'}
for row in cursor.fetchall()]
# Search IP Addresses (ip, hostname)
# Search IP Addresses (ip, hostname, notes)
cursor.execute('''
SELECT ip.id, ip.ip, ip.hostname, ip.subnet_id, s.name, s.cidr, s.site
FROM IPAddress ip
JOIN Subnet s ON ip.subnet_id = s.id
WHERE ip.ip LIKE %s OR ip.hostname LIKE %s
WHERE ip.ip LIKE %s OR ip.hostname LIKE %s OR ip.notes LIKE %s
ORDER BY ip.ip
''', (search_pattern, search_pattern))
''', (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],
'subnet_cidr': row[5], 'site': row[6] or 'Unassigned'}
+148 -1
View File
@@ -31,8 +31,10 @@ document.addEventListener('DOMContentLoaded', () => {
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
const descCell = row.querySelector('td:nth-child(3)');
const descText = descCell ? descCell.textContent.toLowerCase() : '';
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) {
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm) || descText.includes(searchTerm)) {
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -132,4 +134,149 @@ document.addEventListener('DOMContentLoaded', () => {
}, 100);
}
}
// Auto-resize all description textareas (both editable and readonly)
const allDescTextareas = document.querySelectorAll('.desc-col textarea');
allDescTextareas.forEach(textarea => {
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
});
// IP Notes inline editing functionality
const ipNotesTextareas = document.querySelectorAll('.ip-notes-textarea');
const originalValues = new Map();
// Helper function to show toast notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
ipNotesTextareas.forEach(textarea => {
// Store original value
originalValues.set(textarea, textarea.value);
// Ensure overflow is hidden and resize is disabled
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
// Auto-resize textarea
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
// Handle input to auto-resize
textarea.addEventListener('input', autoResize);
// Handle blur event to save notes
textarea.addEventListener('blur', async function() {
const ipId = this.getAttribute('data-ip-id');
const deviceDesc = this.getAttribute('data-device-desc') || '';
const fullValue = this.value;
const originalValue = originalValues.get(this);
// Extract IP notes: everything after the device description
let ipNotes = '';
if (deviceDesc) {
// If device description exists, check if textarea starts with it
const deviceDescTrimmed = deviceDesc.trim();
const fullValueTrimmed = fullValue.trim();
if (fullValueTrimmed.startsWith(deviceDescTrimmed)) {
// Remove device description from the beginning
ipNotes = fullValueTrimmed.substring(deviceDescTrimmed.length).trim();
// Also handle case where there's a newline separator
if (ipNotes.startsWith('\n')) {
ipNotes = ipNotes.substring(1).trim();
}
} else {
// Device description was modified or removed - extract everything as IP notes
// This shouldn't normally happen, but handle gracefully
ipNotes = fullValueTrimmed;
}
} else {
// No device description, so entire value is IP notes
ipNotes = fullValue.trim();
}
// Only save if value changed
if (fullValue !== originalValue) {
// Show loading indicator
const originalBg = this.style.backgroundColor;
this.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
this.disabled = true;
try {
const response = await fetch(`/ip/${ipId}/update_notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ notes: ipNotes })
});
const data = await response.json();
if (data.success) {
// Update the displayed value to reflect what was saved
let newDisplayValue = '';
if (deviceDesc) {
newDisplayValue = deviceDesc;
if (ipNotes) {
newDisplayValue += '\n' + ipNotes;
}
} else {
newDisplayValue = ipNotes;
}
this.value = newDisplayValue;
originalValues.set(this, newDisplayValue);
autoResize();
showToast('Notes saved successfully', 'success');
} else {
// Restore original value on error
this.value = originalValue;
autoResize();
showToast(data.error || 'Failed to save notes', 'error');
}
} catch (error) {
// Restore original value on error
this.value = originalValue;
autoResize();
showToast('Error saving notes. Please try again.', 'error');
console.error('Error saving IP notes:', error);
} finally {
this.style.backgroundColor = originalBg;
this.disabled = false;
}
}
});
// Handle Escape key to cancel editing
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = originalValues.get(this);
autoResize();
this.blur();
}
});
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll("form"),t=null;for(let o of e)if("/search"!==o.action&&"POST"===o.method){t=o;break}if(t&&!document.querySelector('input[placeholder="Search by IP or Hostname"]')){t.addEventListener("submit",e=>{e.preventDefault()});let l=document.createElement("input");l.type="text",l.placeholder="Search by IP or Hostname",l.className="p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center",t.insertAdjacentElement("beforebegin",l),l.addEventListener("keypress",e=>{if("Enter"===e.key){e.preventDefault();let t=l.value.toLowerCase(),o=document.querySelectorAll("tbody tr");o.forEach(e=>{let o=e.querySelector("td:nth-child(1)").textContent.toLowerCase(),l=e.querySelector("td:nth-child(2)").textContent.toLowerCase();o.includes(t)||l.includes(t)?(e.style.backgroundColor="rgba(59, 130, 246, 0.5)",e.scrollIntoView({behavior:"smooth",block:"center"}),setTimeout(()=>{e.style.backgroundColor=""},3e3)):e.style.backgroundColor=""})}})}let r=document.getElementById("toggle-desc"),n=document.querySelectorAll(".desc-col"),s=document.getElementById("desc-col-header"),a=!1;r&&r.addEventListener("click",function(){a=!a,n.forEach(e=>e.classList.toggle("hidden",!a)),s&&s.classList.toggle("hidden",!a),r.textContent=a?"Hide Descriptions":"Show Descriptions"});let d=document.createElement("button");d.innerHTML='<i class="fas fa-arrow-up"></i>',d.style.fontSize="26px",d.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",d.style.width="60px",d.style.height="60px",d.style.borderRadius="50%",document.body.appendChild(d);let c=document.createElement("style");if(c.textContent=`
document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll("form"),t=null;for(let l of e)if("/search"!==l.action&&"POST"===l.method){t=l;break}if(t&&!document.querySelector('input[placeholder="Search by IP or Hostname"]')){t.addEventListener("submit",e=>{e.preventDefault()});let o=document.createElement("input");o.type="text",o.placeholder="Search by IP or Hostname",o.className="p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center",t.insertAdjacentElement("beforebegin",o),o.addEventListener("keypress",e=>{if("Enter"===e.key){e.preventDefault();let t=o.value.toLowerCase(),l=document.querySelectorAll("tbody tr");l.forEach(e=>{let l=e.querySelector("td:nth-child(1)").textContent.toLowerCase(),o=e.querySelector("td:nth-child(2)").textContent.toLowerCase(),s=e.querySelector("td:nth-child(3)"),r=s?s.textContent.toLowerCase():"";l.includes(t)||o.includes(t)||r.includes(t)?(e.style.backgroundColor="rgba(59, 130, 246, 0.5)",e.scrollIntoView({behavior:"smooth",block:"center"}),setTimeout(()=>{e.style.backgroundColor=""},3e3)):e.style.backgroundColor=""})}})}let s=document.getElementById("toggle-desc"),r=document.querySelectorAll(".desc-col"),n=document.getElementById("desc-col-header"),i=!1;s&&s.addEventListener("click",function(){i=!i,r.forEach(e=>e.classList.toggle("hidden",!i)),n&&n.classList.toggle("hidden",!i),s.textContent=i?"Hide Descriptions":"Show Descriptions"});let a=document.createElement("button");a.innerHTML='<i class="fas fa-arrow-up"></i>',a.style.fontSize="26px",a.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",a.style.width="60px",a.style.height="60px",a.style.borderRadius="50%",document.body.appendChild(a);let d=document.createElement("style");if(d.textContent=`
@keyframes bob {
0%, 100% {
transform: translateY(0);
@@ -11,4 +11,4 @@ document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAl
.bobbing {
animation: bob 1.5s infinite;
}
`,document.head.appendChild(c),d.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?d.classList.remove("hidden"):d.classList.add("hidden")}),d.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})}),requestAnimationFrame(()=>{let e=document.documentElement.scrollHeight>document.documentElement.clientHeight;e&&0===window.scrollY&&(window.scrollBy(0,1),requestAnimationFrame(()=>{window.scrollBy(0,-1)}))}),window.location.hash){let i=window.location.hash.substring(1),b=document.getElementById(i);b&&setTimeout(()=>{b.scrollIntoView({behavior:"smooth",block:"center"}),b.style.backgroundColor="rgba(59, 130, 246, 0.5)",setTimeout(()=>{b.style.backgroundColor=""},3e3)},100)}});
`,document.head.appendChild(d),a.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?a.classList.remove("hidden"):a.classList.add("hidden")}),a.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})}),requestAnimationFrame(()=>{let e=document.documentElement.scrollHeight>document.documentElement.clientHeight;e&&0===window.scrollY&&(window.scrollBy(0,1),requestAnimationFrame(()=>{window.scrollBy(0,-1)}))}),window.location.hash){let c=window.location.hash.substring(1),h=document.getElementById(c);h&&setTimeout(()=>{h.scrollIntoView({behavior:"smooth",block:"center"}),h.style.backgroundColor="rgba(59, 130, 246, 0.5)",setTimeout(()=>{h.style.backgroundColor=""},3e3)},100)}let u=document.querySelectorAll(".desc-col textarea");u.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t()});let y=document.querySelectorAll(".ip-notes-textarea"),b=new Map;function g(e,t="success"){let l=document.createElement("div");l.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,l.textContent=e,document.body.appendChild(l),setTimeout(()=>{l.style.transition="opacity 0.3s",l.style.opacity="0",setTimeout(()=>l.remove(),300)},3e3)}y.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}b.set(e,e.value),e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t),e.addEventListener("blur",async function(){let e=this.getAttribute("data-ip-id"),l=this.getAttribute("data-device-desc")||"",o=this.value,s=b.get(this),r="";if(l){let n=l.trim(),i=o.trim();i.startsWith(n)?(r=i.substring(n.length).trim()).startsWith("\n")&&(r=r.substring(1).trim()):r=i}else r=o.trim();if(o!==s){let a=this.style.backgroundColor;this.style.backgroundColor="rgba(59, 130, 246, 0.2)",this.disabled=!0;try{let d=await fetch(`/ip/${e}/update_notes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({notes:r})}),c=await d.json();if(c.success){let h="";l?(h=l,r&&(h+="\n"+r)):h=r,this.value=h,b.set(this,h),t(),g("Notes saved successfully","success")}else this.value=s,t(),g(c.error||"Failed to save notes","error")}catch(u){this.value=s,t(),g("Error saving notes. Please try again.","error"),console.error("Error saving IP notes:",u)}finally{this.style.backgroundColor=a,this.disabled=!1}}}),e.addEventListener("keydown",function(e){"Escape"===e.key&&(this.value=b.get(this),t(),this.blur())})})});
+18 -1
View File
@@ -200,7 +200,24 @@
{% endif %}
</td>
<td class="text-left align-top hidden sm:table-cell desc-col">
<textarea readonly rows="1" class="border border-gray-600 rounded w-full resize-y cursor-pointer p-2">{{ ip[4].split('\n')[0] if ip[4] else '' }}</textarea>
{% set device_desc = ip[4] if ip[4] else '' %}
{% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %}
{% set combined_desc = '' %}
{% if device_desc %}
{% set combined_desc = device_desc %}
{% endif %}
{% if ip_notes %}
{% if combined_desc %}
{% set combined_desc = combined_desc + '\n' + ip_notes %}
{% else %}
{% set combined_desc = ip_notes %}
{% endif %}
{% endif %}
{% if can_edit_subnet %}
<textarea data-ip-id="{{ ip[0] }}" data-device-desc="{{ device_desc|e }}" rows="1" class="ip-notes-textarea border border-gray-600 rounded w-full p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% 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>
{% endif %}
</td>
</tr>
{% endfor %}