feat: ✨ global search
This commit is contained in:
@@ -1925,6 +1925,97 @@ def register_routes(app):
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
@app.route('/search')
|
||||
@login_required
|
||||
def search():
|
||||
query = request.args.get('q', '').strip()
|
||||
results = {
|
||||
'subnets': [],
|
||||
'ips': [],
|
||||
'devices': [],
|
||||
'tags': [],
|
||||
'racks': [],
|
||||
'sites': []
|
||||
}
|
||||
|
||||
if query:
|
||||
from flask import current_app
|
||||
conn = get_db_connection(current_app)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
search_pattern = f'%{query}%'
|
||||
|
||||
# Search Subnets (name, cidr, site)
|
||||
cursor.execute('''
|
||||
SELECT id, name, cidr, site
|
||||
FROM Subnet
|
||||
WHERE name LIKE %s OR cidr LIKE %s OR site LIKE %s
|
||||
ORDER BY site, name
|
||||
''', (search_pattern, search_pattern, search_pattern))
|
||||
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)
|
||||
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
|
||||
ORDER BY ip.ip
|
||||
''', (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'}
|
||||
for row in cursor.fetchall()]
|
||||
|
||||
# Search Devices (name, description)
|
||||
cursor.execute('''
|
||||
SELECT id, name, description
|
||||
FROM Device
|
||||
WHERE name LIKE %s OR description LIKE %s
|
||||
ORDER BY name
|
||||
''', (search_pattern, search_pattern))
|
||||
results['devices'] = [{'id': row[0], 'name': row[1], 'description': row[2] or ''}
|
||||
for row in cursor.fetchall()]
|
||||
|
||||
# Search Tags (name, description)
|
||||
cursor.execute('''
|
||||
SELECT id, name, description
|
||||
FROM Tag
|
||||
WHERE name LIKE %s OR description LIKE %s
|
||||
ORDER BY name
|
||||
''', (search_pattern, search_pattern))
|
||||
results['tags'] = [{'id': row[0], 'name': row[1], 'description': row[2] or ''}
|
||||
for row in cursor.fetchall()]
|
||||
|
||||
# Search Racks (name, site)
|
||||
cursor.execute('''
|
||||
SELECT id, name, site, height_u
|
||||
FROM Rack
|
||||
WHERE name LIKE %s OR site LIKE %s
|
||||
ORDER BY site, name
|
||||
''', (search_pattern, search_pattern))
|
||||
results['racks'] = [{'id': row[0], 'name': row[1], 'site': row[2], 'height_u': row[3]}
|
||||
for row in cursor.fetchall()]
|
||||
|
||||
# Get unique sites from subnets and racks
|
||||
all_sites = set()
|
||||
for subnet in results['subnets']:
|
||||
all_sites.add(subnet['site'])
|
||||
for rack in results['racks']:
|
||||
all_sites.add(rack['site'])
|
||||
for ip in results['ips']:
|
||||
all_sites.add(ip['site'])
|
||||
|
||||
# Filter sites that match the query
|
||||
matching_sites = [site for site in all_sites if query.lower() in site.lower()]
|
||||
results['sites'] = sorted(matching_sites)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return render_with_user('search.html', query=query, results=results)
|
||||
|
||||
@app.route('/help')
|
||||
@permission_required('view_help')
|
||||
def help():
|
||||
@@ -3304,6 +3395,7 @@ def register_routes(app):
|
||||
app.add_url_rule('/rack/<int:rack_id>/remove_device', 'rack_remove_device', rack_remove_device, methods=['POST'])
|
||||
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
|
||||
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
|
||||
app.add_url_rule('/search', 'search', search)
|
||||
app.add_url_rule('/help', 'help', help)
|
||||
app.add_url_rule('/backup', 'backup', backup, methods=['GET', 'POST'])
|
||||
app.add_url_rule('/backup/create', 'create_backup', create_backup, methods=['POST'])
|
||||
|
||||
+40
-1
@@ -1,5 +1,14 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.querySelector('form');
|
||||
// Only target the form on the subnet page, not the header search form
|
||||
// Look for a form that's not in the header (header forms have action="/search")
|
||||
const allForms = document.querySelectorAll('form');
|
||||
let form = null;
|
||||
for (let f of allForms) {
|
||||
if (f.action !== '/search' && f.method === 'POST') {
|
||||
form = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (form) {
|
||||
// Check if search input already exists to prevent duplicates
|
||||
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
|
||||
@@ -93,4 +102,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
scrollToTopButton.addEventListener('click', () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Force scrollbar thumb to render on page load
|
||||
// This fixes the issue where scrollbar thumb is missing on initial page load
|
||||
// The scrollbar only renders its thumb after a scroll event has occurred
|
||||
requestAnimationFrame(() => {
|
||||
const isScrollable = document.documentElement.scrollHeight > document.documentElement.clientHeight;
|
||||
if (isScrollable && window.scrollY === 0) {
|
||||
// Trigger a minimal scroll to force scrollbar rendering, then scroll back
|
||||
window.scrollBy(0, 1);
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollBy(0, -1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to IP anchor if present in URL hash
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
setTimeout(() => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Highlight the row briefly
|
||||
element.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
|
||||
setTimeout(() => {
|
||||
element.style.backgroundColor = '';
|
||||
}, 3000);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 flex items-center justify-center mx-4">
|
||||
<div class="container py-8 w-auto min-w-2xl pt-20">
|
||||
<div class="container py-8 max-w-2xl pt-20">
|
||||
<div class="flex items-center mb-8 relative justify-between gap-4">
|
||||
<a href="/devices" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11 shrink-0"><i class="fas fa-arrow-left"></i></a>
|
||||
<h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1>
|
||||
|
||||
+23
-6
@@ -1,15 +1,22 @@
|
||||
<header class="bg-zinc-800 shadow-md py-3 px-6 flex items-center justify-between relative">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-3 flex-shrink-0">
|
||||
<a href="/" class="flex items-center space-x-3">
|
||||
<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://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>
|
||||
</div>
|
||||
<div class="hidden 2xl:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap">
|
||||
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
|
||||
<div class="hidden lg:flex items-center justify-center absolute left-1/2 transform -translate-x-1/2">
|
||||
<form action="/search" method="GET" class="flex items-center space-x-2">
|
||||
<input type="text" name="q" id="search-input" placeholder="Search..."
|
||||
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
|
||||
value="{{ request.args.get('q', '') }}">
|
||||
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
|
||||
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
|
||||
{% if has_permission('view_index') %}
|
||||
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
|
||||
{% endif %}
|
||||
@@ -29,12 +36,22 @@
|
||||
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<button class="md:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
|
||||
<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="md:hidden absolute top-16 right-6 bg-zinc-800 rounded-lg shadow-lg z-50 w-48 hidden flex-col py-2" id="mobile-nav">
|
||||
<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">Home</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Search - {{ NAME }} IPAM</title>
|
||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||
<link href="/static/css/output.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 container mx-auto px-4 py-8 pt-20">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Search Results</h1>
|
||||
|
||||
{% if query %}
|
||||
<p class="text-lg mb-6">Search results for: <span class="font-semibold">"{{ query }}"</span></p>
|
||||
{% else %}
|
||||
<p class="text-lg mb-6 text-gray-600 dark:text-gray-400">Enter a search query to find IPs, devices, subnets, tags, racks, and sites.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if query %}
|
||||
{% set total_results = results.subnets|length + results.ips|length + results.devices|length + results.tags|length + results.racks|length + results.sites|length %}
|
||||
|
||||
{% if total_results == 0 %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
|
||||
<i class="fas fa-search text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-xl font-semibold mb-2">No results found</p>
|
||||
<p class="text-gray-600 dark:text-gray-400">Try a different search term or check your spelling.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="space-y-6">
|
||||
<!-- Subnets -->
|
||||
{% if results.subnets %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<i class="fas fa-network-wired mr-2"></i>
|
||||
Subnets ({{ results.subnets|length }})
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% for subnet in results.subnets %}
|
||||
<a href="/subnet/{{ subnet.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ subnet.name }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.cidr }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.site }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- IP Addresses -->
|
||||
{% if results.ips %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<i class="fas fa-map-marker-alt mr-2"></i>
|
||||
IP Addresses ({{ results.ips|length }})
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% for ip in results.ips %}
|
||||
<a href="/subnet/{{ ip.subnet_id }}#ip-{{ ip.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ ip.ip }}</p>
|
||||
{% if ip.hostname %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.hostname }}</p>
|
||||
{% endif %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.subnet_name }} ({{ ip.subnet_cidr }})</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.site }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Devices -->
|
||||
{% if results.devices %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<i class="fas fa-server mr-2"></i>
|
||||
Devices ({{ results.devices|length }})
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% for device in results.devices %}
|
||||
<a href="/device/{{ device.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ device.name }}</p>
|
||||
{% if device.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ device.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tags -->
|
||||
{% if results.tags %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<i class="fas fa-tags mr-2"></i>
|
||||
Tags ({{ results.tags|length }})
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% for tag in results.tags %}
|
||||
<a href="/tags" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ tag.name }}</p>
|
||||
{% if tag.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ tag.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Racks -->
|
||||
{% if results.racks %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<i class="fas fa-th mr-2"></i>
|
||||
Racks ({{ results.racks|length }})
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% for rack in results.racks %}
|
||||
<a href="/rack/{{ rack.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ rack.name }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.height_u }}U</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.site }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sites -->
|
||||
{% if results.sites %}
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||
<i class="fas fa-map-marker-alt mr-2"></i>
|
||||
Sites ({{ results.sites|length }})
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{% for site in results.sites %}
|
||||
<div class="p-3 bg-gray-300 dark:bg-zinc-900 rounded-lg">
|
||||
<p class="font-semibold text-lg">{{ site }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
{% for ip in ip_addresses %}
|
||||
<tr>
|
||||
<tr id="ip-{{ ip[0] }}">
|
||||
<td class="font-bold text-center">{{ ip[1] }}</td>
|
||||
<td class="text-center">
|
||||
{% if ip[2] == 'DHCP' %}
|
||||
|
||||
Reference in New Issue
Block a user