Skip to content

Commit efae4b6

Browse files
committed
Add unified search with in-memory pagination and clear search option
Bump version to 1.1.8b1 Extract dashboard search form into shared template Implement unified search across all rule types with result counters Add in-memory pagination for search results via paginate_list() Introduce /clear-search route to reset stored queries Include search_helpers.js and enhance UI feedback for active searches Simplify layout templates and refactor redundant search form code
1 parent 7bf0070 commit efae4b6

8 files changed

Lines changed: 206 additions & 94 deletions

File tree

flowapp/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "1.1.7"
1+
__version__ = "1.1.8b1"
22
__title__ = "ExaFS"
33
__description__ = "Tool for creation, validation, and execution of ExaBGP messages."
44
__author__ = "CESNET / Jiri Vrany, Petr Adamec, Josef Verich, Jakub Man"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
document.addEventListener('DOMContentLoaded', function() {
2+
document.querySelectorAll('.btn-close[data-clear-url]').forEach(btn => {
3+
btn.addEventListener('click', function() {
4+
window.location.href = this.dataset.clearUrl;
5+
});
6+
});
7+
8+
const searchInput = document.querySelector('input[name="squery"]');
9+
if (searchInput) {
10+
const clearUrl = searchInput.dataset.clearUrl;
11+
12+
searchInput.addEventListener('keydown', function(e) {
13+
if (e.key === 'Escape' && this.value && clearUrl) {
14+
window.location.href = clearUrl;
15+
}
16+
});
17+
}
18+
});

flowapp/templates/layouts/default.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,7 @@
113113

114114
<script type="text/javascript" src="/static/js/ip_context.js"></script>
115115
<script type="text/javascript" src="/static/js/enable_tooltips.js"></script>
116+
<script type="text/javascript" src="/static/js/search_helpers.js"></script>
117+
116118
</body>
117119
</html>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% block dashboard_search_form %}
2+
<form id="dashboard-search" class="navbar-form mx-3" role="search"
3+
action="{{ url_for('dashboard.index', rtype=rtype, rstate=rstate) }}">
4+
<div class="input-group">
5+
<input class="form-control" type="search" name="squery" data-clear-url="{{ url_for('dashboard.clear_search') }}"
6+
{% if search_query %} value="{{search_query}}" {% else %} placeholder="Search..." {% endif %}>
7+
<button class="btn btn-outline-secondary" type="submit" title="Search">
8+
<i class="bi bi-search"></i>
9+
</button>
10+
{% if search_query %}
11+
<a href="{{ url_for('dashboard.clear_search') }}" class="btn btn-outline-danger" title="Clear search">
12+
<i class="bi bi-x-circle"></i>
13+
</a>
14+
{% endif %}
15+
</div>
16+
<input type="hidden" name="sort" value="{{ sort_key }}" />
17+
<input type="hidden" name="order" value="{{ sort_order }}" />
18+
</form>
19+
{% endblock %}
Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
{% block submenu_dashboard %}
32
<div class="container" id="dashboard-nav">
43

@@ -21,34 +20,7 @@ <h1>{{ rstate|capitalize }} {{ table_title }}</h1>
2120
<div class="col-6">
2221
<ul class="nav nav-pills justify-content-end">
2322
<li class="nav-item">
24-
<form id="dashboard-search" class="navbar-form mx-3" role="search" action="{{ url_for('dashboard.index', rtype=rtype, rstate=rstate) }}">
25-
<div class="input-group">
26-
<input
27-
class="form-control"
28-
type="search"
29-
name="squery"
30-
{% if search_query %}
31-
value="{{search_query}}"
32-
{% else %}
33-
placeholder="Search..."
34-
{% endif %}
35-
>
36-
<button class="btn btn-outline-secondary" type="submit">
37-
<i class="bi bi-search"></i>
38-
</button>
39-
40-
</div>
41-
<input
42-
type="hidden"
43-
name="sort"
44-
value={{ sort_key }}
45-
/>
46-
<input
47-
type="hidden"
48-
name="order"
49-
value={{ sort_order }}
50-
/>
51-
</form>
23+
{% include 'pages/dashboard_search_form.html' %}
5224
</li>
5325
<li class="nav-item">
5426
<a class="nav-link {{ css_classes['active'] }}" href="{{ url_for('dashboard.index', rtype=rtype, rstate='active') }}" >Active</a>
@@ -62,6 +34,28 @@ <h1>{{ rstate|capitalize }} {{ table_title }}</h1>
6234
</ul>
6335
</div>
6436
</div>
37+
38+
{% if search_query %}
39+
<div class="row mt-2">
40+
<div class="col-12">
41+
<div class="alert alert-info alert-dismissible fade show py-2" role="alert">
42+
<small>
43+
<i class="bi bi-search"></i> Searching for: <strong>{{ search_query }}</strong>
44+
{% if count_match and count_match[rtype] is defined %}
45+
<span class="badge bg-primary ms-2">{{ count_match[rtype] }} results in {{ rtype|upper }}</span>
46+
{% for other_type, count in count_match.items() %}
47+
{% if other_type != rtype and count > 0 %}
48+
<span class="badge bg-secondary ms-1">{{ count }} in {{ other_type|upper }}</span>
49+
{% endif %}
50+
{% endfor %}
51+
{% endif %}
52+
</small>
53+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" data-clear-url="{{ url_for('dashboard.clear_search') }}"></button>
54+
</div>
55+
</div>
56+
</div>
57+
{% endif %}
58+
6559
<hr />
6660

6761
{% endblock %}

flowapp/templates/pages/submenu_dashboard_view.html

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,17 @@ <h1>{{ rstate|capitalize }} {{ table_title }}</h1>
2121
</div>
2222
<div class="col-md-6">
2323
<ul class="nav nav-pills pull-right">
24-
<li>
25-
<form class="navbar-form pull-right" role="search" action="{{ url_for('dashboard.index', rtype=rtype, rstate=rstate) }}">
26-
<div class="input-group">
27-
<input
28-
class="form-control"
29-
type="text"
30-
name="squery"
31-
{% if search_query %}
32-
value="{{search_query}}"
33-
{% else %}
34-
placeholder="Search..."
35-
{% endif %}
36-
/>
37-
<input
38-
type="hidden"
39-
name="sort"
40-
value={{ sort_key }}
41-
/>
42-
<input
43-
type="hidden"
44-
name="order"
45-
value={{ sort_order }}
46-
/>
47-
<div class="input-group-btn">
48-
<button class="btn btn-default"><i class="glyphicon glyphicon-search"></i></button>
49-
</div>
50-
</div>
51-
</form>
24+
<li class="nav-item">
25+
{% include 'pages/dashboard_search_form.html' %}
26+
</li>
27+
<li class="nav-item">
28+
<a class="nav-link {{ css_classes['active'] }}" href="{{ url_for('dashboard.index', rtype=rtype, rstate='active') }}" >Active</a>
29+
</li>
30+
<li class="nav-item">
31+
<a class="nav-link {{ css_classes['expired'] }}" href="{{ url_for('dashboard.index', rtype=rtype, rstate='expired') }}" >Expired</a>
32+
</li>
33+
<li class="nav-item">
34+
<a class="nav-link {{ css_classes['all'] }}" href="{{ url_for('dashboard.index', rtype=rtype, rstate='all') }}" >All</a>
5235
</li>
5336
</ul>
5437
</div>

flowapp/views/dashboard.py

Lines changed: 129 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
session,
1111
make_response,
1212
abort,
13+
redirect,
14+
url_for,
1315
)
16+
1417
from markupsafe import Markup
1518
from flowapp import models, validators, flowspec
1619
from flowapp.auth import auth_required
@@ -86,18 +89,15 @@ def index(rtype=None, rstate="active"):
8689

8790
data_handler_module = current_app.config["DASHBOARD"].get(rtype).get("data_handler", models)
8891
data_handler_method = current_app.config["DASHBOARD"].get(rtype).get("data_handler_method", "get_ip_rules")
89-
92+
9093
# Get pagination parameters
9194
page = request.args.get(PAGE_ARG, 1, type=int)
9295
per_page = request.args.get(PER_PAGE_ARG, PER_PAGE_DEFAULT, type=int)
93-
96+
9497
# Validate per_page
9598
if per_page not in PER_PAGE_OPTIONS:
9699
per_page = PER_PAGE_DEFAULT
97-
98-
# Determine if pagination should be used (only for 'expired' and 'all')
99-
use_pagination = rstate in ['expired', 'all']
100-
100+
101101
# get search query, sort order and sort key from request or session
102102
get_search_query = request.args.get(SEARCH_ARG, session.get(SEARCH_ARG, ""))
103103
get_sort_key = request.args.get(SORT_ARG, session.get(SORT_ARG, DEFAULT_SORT))
@@ -121,40 +121,56 @@ def index(rtype=None, rstate="active"):
121121

122122
# get the handler and the data
123123
handler = getattr(data_handler_module, data_handler_method)
124-
125-
# Call handler with pagination if applicable
126-
if use_pagination and not get_search_query:
127-
# Use paginated version
128-
rules_data = handler(rtype, rstate, get_sort_key, get_sort_order, page=page, per_page=per_page, paginate=True)
129-
if isinstance(rules_data, tuple):
130-
rules, pagination = rules_data
131-
else:
132-
# Fallback if handler doesn't support pagination yet
133-
rules = rules_data
134-
pagination = None
135-
else:
136-
# Use non-paginated version (for active or when searching)
137-
rules = handler(rtype, rstate, get_sort_key, get_sort_order)
138-
pagination = None
139124

140-
# Enrich rules with whitelist information
141-
rules, whitelist_rule_ids = enrich_rules_with_whitelist_info(rules, rtype)
125+
# Determine if we're searching
126+
is_searching = bool(get_search_query)
142127

143-
# search rules
144-
if get_search_query:
145-
count_match = current_app.config["COUNT_MATCH"]
128+
# Always fetch ALL rules first (no pagination at DB level when searching)
129+
if is_searching:
130+
# Get all rules for search
131+
rules = handler(rtype, rstate, get_sort_key, get_sort_order, paginate=False)
132+
133+
# Perform search on all rules
146134
rules = filter_rules(rules, get_search_query)
147-
# extended search in for all rule types
148-
count_match[rtype] = len(rules)
135+
136+
# Now paginate the search results in memory
137+
pagination = paginate_list(rules, page, per_page)
138+
# Get the slice of rules for current page
139+
start_idx = (page - 1) * per_page
140+
end_idx = start_idx + per_page
141+
rules = rules[start_idx:end_idx]
142+
143+
# Get counts for other rule types
144+
count_match = current_app.config["COUNT_MATCH"]
145+
count_match[rtype] = pagination.total
149146
for other_rtype in other_rtypes(rtype):
150-
other_rules = handler(other_rtype, rstate)
147+
other_rules = handler(other_rtype, rstate, get_sort_key, get_sort_order, paginate=False)
151148
other_rules = filter_rules(other_rules, get_search_query)
152149
count_match[other_rtype] = len(other_rules)
153-
# Disable pagination when searching
154-
pagination = None
155150
else:
151+
# No search - use normal pagination or fetch all
152+
use_pagination = rstate in ["expired", "all"]
153+
154+
if use_pagination:
155+
# Use paginated version from DB
156+
rules_data = handler(
157+
rtype, rstate, get_sort_key, get_sort_order, page=page, per_page=per_page, paginate=True
158+
)
159+
if isinstance(rules_data, tuple):
160+
rules, pagination = rules_data
161+
else:
162+
rules = rules_data
163+
pagination = None
164+
else:
165+
# Fetch all rules for 'active' state
166+
rules = handler(rtype, rstate, get_sort_key, get_sort_order, paginate=False)
167+
pagination = None
168+
156169
count_match = ""
157170

171+
# Enrich rules with whitelist information
172+
rules, whitelist_rule_ids = enrich_rules_with_whitelist_info(rules, rtype)
173+
158174
allowed_communities = current_app.config["ALLOWED_COMMUNITIES"]
159175

160176
return view_factory(
@@ -180,6 +196,72 @@ def index(rtype=None, rstate="active"):
180196
)
181197

182198

199+
# Add this route to your dashboard.py Blueprint
200+
201+
202+
@dashboard.route("/clear-search")
203+
@auth_required
204+
def clear_search():
205+
"""
206+
Clear the search query from session and redirect back to the current view.
207+
"""
208+
# Get current rtype and rstate before clearing
209+
rtype = session.get(TYPE_ARG, next(iter(current_app.config["DASHBOARD"].keys())))
210+
rstate = session.get(RULE_ARG, "active")
211+
sort_key = session.get(SORT_ARG, DEFAULT_SORT)
212+
sort_order = session.get(ORDER_ARG, DEFAULT_ORDER)
213+
214+
# Clear the search query from session
215+
session[SEARCH_ARG] = ""
216+
217+
# Redirect back to dashboard with current settings but no search
218+
return redirect(url_for("dashboard.index", rtype=rtype, rstate=rstate, sort=sort_key, order=sort_order))
219+
220+
221+
## Helper functions
222+
223+
224+
def paginate_list(items, page, per_page):
225+
"""
226+
Create a pagination object from a list of items.
227+
This mimics SQLAlchemy's pagination for in-memory lists.
228+
229+
:param items: List of items to paginate
230+
:param page: Current page number (1-indexed)
231+
:param per_page: Number of items per page
232+
:return: Pagination-like object
233+
"""
234+
total = len(items)
235+
pages = (total + per_page - 1) // per_page # Ceiling division
236+
237+
has_prev = page > 1
238+
has_next = page < pages
239+
240+
prev_num = page - 1 if has_prev else None
241+
next_num = page + 1 if has_next else None
242+
243+
# Calculate first and last item numbers for display
244+
first = (page - 1) * per_page + 1 if total > 0 else 0
245+
last = min(page * per_page, total)
246+
247+
class Pagination:
248+
pass
249+
250+
pagination = Pagination()
251+
pagination.page = page
252+
pagination.per_page = per_page
253+
pagination.total = total
254+
pagination.pages = pages
255+
pagination.has_prev = has_prev
256+
pagination.has_next = has_next
257+
pagination.prev_num = prev_num
258+
pagination.next_num = next_num
259+
pagination.first = first
260+
pagination.last = last
261+
262+
return pagination
263+
264+
183265
def create_dashboard_table_body(
184266
rules,
185267
rtype,
@@ -571,11 +653,25 @@ def create_view_response(
571653

572654

573655
def filter_rules(rules, get_search_query):
656+
"""
657+
Filter rules based on search query.
658+
Performs full-text search across all rule fields.
659+
660+
:param rules: List of rule objects
661+
:param get_search_query: Search string
662+
:return: Filtered list of rules
663+
"""
664+
if not get_search_query:
665+
return rules
666+
574667
rules_serialized = [rule.dict() for rule in rules]
575668
result = []
669+
search_lower = get_search_query.lower()
670+
576671
for idx, rule in enumerate(rules_serialized):
577-
full_text = " ".join("{}".format(c) for c in rule.values())
578-
if get_search_query.lower() in full_text.lower():
672+
# Create a full text string from all values
673+
full_text = " ".join(str(c) for c in rule.values())
674+
if search_lower in full_text.lower():
579675
result.append(rules[idx])
580676

581677
return result

0 commit comments

Comments
 (0)