From 0e2b292408eb0f5789e80a51237c23137bf419a5 Mon Sep 17 00:00:00 2001 From: twothatIT Date: Thu, 23 Jul 2026 15:16:31 +0200 Subject: [PATCH] feat(customers): sortable table columns + default ascending ID order - Customer list now defaults to ascending ID order instead of newest-first, so the table starts at customer #1 instead of the highest ID - Add sort_by/sort_order query params to GET /customers (whitelisted column map to prevent SQL injection via arbitrary column names) - Make ID/Name/Subdomain/Status/Devices/Created column headers clickable, toggling asc/desc with a visual arrow indicator Co-Authored-By: Claude Sonnet 5 --- app/main.py | 2 +- app/routers/customers.py | 21 +++++++++++++++++++-- static/css/styles.css | 20 ++++++++++++++++++++ static/index.html | 12 ++++++------ static/js/app.js | 29 ++++++++++++++++++++++++++++- 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index baff13a..a14f58d 100644 --- a/app/main.py +++ b/app/main.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) app = FastAPI( title="NetBird MSP Appliance", description="Multi-tenant NetBird management platform for MSPs", - version="1.1.2", + version="1.2.0", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", diff --git a/app/routers/customers.py b/app/routers/customers.py index 0f1280b..215aa84 100644 --- a/app/routers/customers.py +++ b/app/routers/customers.py @@ -78,22 +78,36 @@ async def create_customer( return response +SORTABLE_CUSTOMER_COLUMNS = { + "id": Customer.id, + "name": Customer.name, + "subdomain": Customer.subdomain, + "status": Customer.status, + "max_devices": Customer.max_devices, + "created_at": Customer.created_at, +} + + @router.get("") async def list_customers( page: int = Query(default=1, ge=1), per_page: int = Query(default=25, ge=1, le=100), search: Optional[str] = Query(default=None), status_filter: Optional[str] = Query(default=None, alias="status"), + sort_by: str = Query(default="id"), + sort_order: str = Query(default="asc", pattern="^(asc|desc)$"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """List customers with pagination, search, and status filter. + """List customers with pagination, search, status filter, and sorting. Args: page: Page number (1-indexed). per_page: Items per page. search: Search in name, subdomain, email. status_filter: Filter by status. + sort_by: Column to sort by — one of SORTABLE_CUSTOMER_COLUMNS. + sort_order: "asc" or "desc". Returns: Paginated customer list with metadata. @@ -113,8 +127,11 @@ async def list_customers( query = query.filter(Customer.status == status_filter) total = query.count() + + sort_column = SORTABLE_CUSTOMER_COLUMNS.get(sort_by, Customer.id) + sort_expr = sort_column.desc() if sort_order == "desc" else sort_column.asc() customers = ( - query.order_by(Customer.created_at.desc()) + query.order_by(sort_expr, Customer.id.asc()) .offset((page - 1) * per_page) .limit(per_page) .all() diff --git a/static/css/styles.css b/static/css/styles.css index ed991db..f879f93 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,5 +1,25 @@ /* NetBird MSP Appliance - Custom Styles */ +/* Sortable table headers */ +.sortable-th { + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.sortable-th:hover { + color: var(--bs-primary); +} + +.sortable-th .sort-icon { + opacity: 0.35; +} + +.sortable-th.sort-asc .sort-icon, +.sortable-th.sort-desc .sort-icon { + opacity: 1; +} + /* i18n FOUC prevention */ body.i18n-loading #login-page, body.i18n-loading #app-page { diff --git a/static/index.html b/static/index.html index 4fbe6c5..8861e50 100644 --- a/static/index.html +++ b/static/index.html @@ -254,13 +254,13 @@ - - - - + + + + - - + + diff --git a/static/js/app.js b/static/js/app.js index 8fe7f12..a8b9fed 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -12,6 +12,8 @@ let currentPage = 'dashboard'; let currentCustomerId = null; let currentCustomerData = null; let customersPage = 1; +let customersSortBy = 'id'; +let customersSortOrder = 'asc'; let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' }; let azureConfig = { azure_enabled: false }; @@ -458,7 +460,7 @@ async function loadStats() { async function loadCustomers() { const search = document.getElementById('search-input').value; const status = document.getElementById('status-filter').value; - let url = `/customers?page=${customersPage}&per_page=25`; + let url = `/customers?page=${customersPage}&per_page=25&sort_by=${customersSortBy}&sort_order=${customersSortOrder}`; if (search) url += `&search=${encodeURIComponent(search)}`; if (status) url += `&status=${encodeURIComponent(status)}`; @@ -470,7 +472,32 @@ async function loadCustomers() { } } +function setCustomerSort(column) { + if (customersSortBy === column) { + customersSortOrder = customersSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + customersSortBy = column; + customersSortOrder = 'asc'; + } + customersPage = 1; + loadCustomers(); +} + +function updateSortHeaders() { + document.querySelectorAll('.sortable-th').forEach(th => { + const col = th.getAttribute('data-sort-col'); + const icon = th.querySelector('.sort-icon'); + th.classList.remove('sort-asc', 'sort-desc'); + if (icon) icon.className = 'bi bi-arrow-down-up sort-icon ms-1'; + if (col === customersSortBy) { + th.classList.add(customersSortOrder === 'asc' ? 'sort-asc' : 'sort-desc'); + if (icon) icon.className = `bi bi-arrow-${customersSortOrder === 'asc' ? 'up' : 'down'} sort-icon ms-1`; + } + }); +} + function renderCustomersTable(data) { + updateSortHeaders(); const tbody = document.getElementById('customers-table-body'); if (!data.items || data.items.length === 0) { tbody.innerHTML = ``;
IDNameSubdomainStatusIDNameSubdomainStatus DashboardDevicesCreatedDevicesCreated Actions
${t('dashboard.noCustomers')}