Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to ExaFS will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.8] - 2025-10-20

### Fixed
- check all for group edit / delete

### Added
- pagination for Expired and All Dashboard cards
- search form quick clear by button or ESC key
- improved search to work with paginated data

## [1.1.7] - 2025-10-16

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions config.example.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ class Config:
Default config options
"""

# Locale for Babel
BABEL_DEFAULT_LOCALE = "en_US_POSIX"
# Limits
FLOWSPEC4_MAX_RULES = 9000
FLOWSPEC6_MAX_RULES = 9000
Expand Down
2 changes: 1 addition & 1 deletion flowapp/__about__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "1.1.7"
__version__ = "1.1.8"
__title__ = "ExaFS"
__description__ = "Tool for creation, validation, and execution of ExaBGP messages."
__author__ = "CESNET / Jiri Vrany, Petr Adamec, Josef Verich, Jakub Man"
Expand Down
162 changes: 114 additions & 48 deletions flowapp/models/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,69 +290,135 @@ def get_existing_community(name=None):
return community.id if hasattr(community, "id") else None


def get_ip_rules(rule_type, rule_state, sort="expires", order="desc"):
"""
Returns list of rules sorted by sort column ordered asc or desc
:param sort: sorting column
:param order: asc or desc
:return: list
"""
def _get_flowspec4_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False):
"""Get Flowspec4 rules with optional pagination"""

today = datetime.now()
comp_func = utils.get_comp_func(rule_state)

if rule_type == "ipv4":
sorter_ip4 = getattr(Flowspec4, sort, Flowspec4.id)
sorting_ip4 = getattr(sorter_ip4, order)
if comp_func:
rules4 = (
db.session.query(Flowspec4).filter(comp_func(Flowspec4.expires, today)).order_by(sorting_ip4()).all()
)
else:
rules4 = db.session.query(Flowspec4).order_by(sorting_ip4()).all()
sorter = getattr(Flowspec4, sort, Flowspec4.id)
sorting = getattr(sorter, order)

return rules4
query = db.session.query(Flowspec4)

if rule_type == "ipv6":
sorter_ip6 = getattr(Flowspec6, sort, Flowspec6.id)
sorting_ip6 = getattr(sorter_ip6, order)
if comp_func:
rules6 = (
db.session.query(Flowspec6).filter(comp_func(Flowspec6.expires, today)).order_by(sorting_ip6()).all()
)
else:
rules6 = db.session.query(Flowspec6).order_by(sorting_ip6()).all()
if comp_func:
query = query.filter(comp_func(Flowspec4.expires, today))

return rules6
query = query.order_by(sorting())

if rule_type == "rtbh":
sorter_rtbh = getattr(RTBH, sort, RTBH.id)
sorting_rtbh = getattr(sorter_rtbh, order)
if paginate:
pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500)
return pagination.items, pagination
else:
return query.all()

if comp_func:
rules_rtbh = db.session.query(RTBH).filter(comp_func(RTBH.expires, today)).order_by(sorting_rtbh()).all()

else:
rules_rtbh = db.session.query(RTBH).order_by(sorting_rtbh()).all()
def _get_flowspec6_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False):
"""Get Flowspec6 rules with optional pagination"""

today = datetime.now()
comp_func = utils.get_comp_func(rule_state)

return rules_rtbh
sorter = getattr(Flowspec6, sort, Flowspec6.id)
sorting = getattr(sorter, order)

if rule_type == "whitelist":
sorter_whitelist = getattr(Whitelist, sort, Whitelist.id)
sorting_whitelist = getattr(sorter_whitelist, order)
query = db.session.query(Flowspec6)

if comp_func:
rules_whitelist = (
db.session.query(Whitelist)
.filter(comp_func(Whitelist.expires, today))
.order_by(sorting_whitelist())
.all()
)
if comp_func:
query = query.filter(comp_func(Flowspec6.expires, today))

else:
rules_whitelist = db.session.query(Whitelist).order_by(sorting_whitelist()).all()
query = query.order_by(sorting())

if paginate:
pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500)
return pagination.items, pagination
else:
return query.all()


def _get_rtbh_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False):
"""Get RTBH rules with optional pagination"""

today = datetime.now()
comp_func = utils.get_comp_func(rule_state)

sorter = getattr(RTBH, sort, RTBH.id)
sorting = getattr(sorter, order)

return rules_whitelist
query = db.session.query(RTBH)

if comp_func:
query = query.filter(comp_func(RTBH.expires, today))

query = query.order_by(sorting())

if paginate:
pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500)
return pagination.items, pagination
else:
return query.all()


def _get_whitelist_rules(rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False):
"""Get Whitelist rules with optional pagination"""

today = datetime.now()
comp_func = utils.get_comp_func(rule_state)

sorter = getattr(Whitelist, sort, Whitelist.id)
sorting = getattr(sorter, order)

query = db.session.query(Whitelist)

if comp_func:
query = query.filter(comp_func(Whitelist.expires, today))

query = query.order_by(sorting())

if paginate:
pagination = query.paginate(page=page, per_page=per_page, error_out=False, max_per_page=500)
return pagination.items, pagination
else:
return query.all()


# Facade function - keeps backward compatibility and config-based routing
def get_ip_rules(rule_type, rule_state, sort="expires", order="desc", page=1, per_page=50, paginate=False):
"""
Returns list of rules sorted by sort column ordered asc or desc, with optional pagination.
This is a facade function that delegates to type-specific handlers.

Args:
rule_type: Type of rule ('ipv4', 'ipv6', 'rtbh', 'whitelist')
rule_state: State filter ('active', 'expired', 'all')
sort: Column to sort by (default: 'expires')
order: Sort order 'asc' or 'desc' (default: 'desc')
page: Page number (1-indexed, default: 1)
per_page: Number of items per page (default: 50)
paginate: If True, return (items, pagination) tuple; if False, return all items

Returns:
If paginate=True: tuple of (list of rules, pagination object)
If paginate=False: list of all rules
"""
# Dispatch to appropriate handler
handlers = {
"ipv4": _get_flowspec4_rules,
"ipv6": _get_flowspec6_rules,
"rtbh": _get_rtbh_rules,
"whitelist": _get_whitelist_rules,
}

handler = handlers.get(rule_type)

if handler:
return handler(rule_state, sort, order, page, per_page, paginate)
else:
# Unknown rule type
if paginate:
return [], None
else:
return []


def get_user_rules_ids(user_id, rule_type):
Expand Down
15 changes: 15 additions & 0 deletions flowapp/static/js/check_all.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
document.getElementById("check-all").addEventListener("click", function(event){
/**
* find all checkboxes in current dashboard and toggle checked all / none
*/
const inputs = document.querySelectorAll("input[type='checkbox']");
if (this.checked) {
for(let minput of inputs) {
minput.checked = true;
}
} else {
for(let minput of inputs) {
minput.checked = false;
}
}
});
18 changes: 18 additions & 0 deletions flowapp/static/js/search_helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.btn-close[data-clear-url]').forEach(btn => {
btn.addEventListener('click', function() {
window.location.href = this.dataset.clearUrl;
});
});

const searchInput = document.querySelector('input[name="squery"]');
if (searchInput) {
const clearUrl = searchInput.dataset.clearUrl;

searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && this.value && clearUrl) {
window.location.href = clearUrl;
}
});
}
});
2 changes: 2 additions & 0 deletions flowapp/templates/layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,7 @@

<script type="text/javascript" src="/static/js/ip_context.js"></script>
<script type="text/javascript" src="/static/js/enable_tooltips.js"></script>
<script type="text/javascript" src="/static/js/search_helpers.js"></script>

</body>
</html>
7 changes: 6 additions & 1 deletion flowapp/templates/pages/dashboard_admin.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% extends 'layouts/default.html' %}
{% from 'pagination_macro.html' import render_pagination %}


{% block title %}Flowspec{% endblock %}
Expand All @@ -18,8 +19,12 @@
</table>
</form>
</div>

{# Pagination controls #}
{{ render_pagination(pagination, rtype, rstate, sort_key, sort_order, search_query, per_page, per_page_options) }}

<script type="text/javascript" src="{{ url_for('static', filename='js/check_all.js') }}"></script>
{% else %}
<h2>There are no {{ rstate|capitalize }} {{ table_title }}.</h2>
{% endif %}
{% endblock %}
{% endblock %}
19 changes: 19 additions & 0 deletions flowapp/templates/pages/dashboard_search_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% block dashboard_search_form %}
<form id="dashboard-search" class="navbar-form mx-3" role="search"
action="{{ url_for('dashboard.index', rtype=rtype, rstate=rstate) }}">
<div class="input-group">
<input class="form-control" type="search" name="squery" data-clear-url="{{ url_for('dashboard.clear_search') }}"
{% if search_query %} value="{{search_query}}" {% else %} placeholder="Search..." {% endif %}>
<button class="btn btn-outline-secondary" type="submit" title="Search">
<i class="bi bi-search"></i>
</button>
{% if search_query %}
<a href="{{ url_for('dashboard.clear_search') }}" class="btn btn-outline-danger" title="Clear search">
<i class="bi bi-x-circle"></i>
</a>
{% endif %}
</div>
<input type="hidden" name="sort" value="{{ sort_key }}" />
<input type="hidden" name="order" value="{{ sort_order }}" />
</form>
{% endblock %}
6 changes: 5 additions & 1 deletion flowapp/templates/pages/dashboard_view.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{% extends 'layouts/default.html' %}
{% from 'macros.html' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %}
{% from 'pagination_macro.html' import render_pagination %}


{% block title %}Flowspec{% endblock %}
Expand All @@ -15,8 +16,11 @@ <h2>{{ rstate|capitalize }} {{ table_title }}</h2>
{{ dashboard_table_body }}

</table>

{# Pagination controls #}
{{ render_pagination(pagination, rtype, rstate, sort_key, sort_order, search_query, per_page, per_page_options) }}
{% else %}
<h2>There are no {{ rstate|capitalize }} {{ table_title }}.</h2>
{% endif %}

{% endblock %}
{% endblock %}
Loading