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 <[email protected]>
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="NetBird MSP Appliance",
|
title="NetBird MSP Appliance",
|
||||||
description="Multi-tenant NetBird management platform for MSPs",
|
description="Multi-tenant NetBird management platform for MSPs",
|
||||||
version="1.1.2",
|
version="1.2.0",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
|
|||||||
@@ -78,22 +78,36 @@ async def create_customer(
|
|||||||
return response
|
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("")
|
@router.get("")
|
||||||
async def list_customers(
|
async def list_customers(
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
per_page: int = Query(default=25, ge=1, le=100),
|
per_page: int = Query(default=25, ge=1, le=100),
|
||||||
search: Optional[str] = Query(default=None),
|
search: Optional[str] = Query(default=None),
|
||||||
status_filter: Optional[str] = Query(default=None, alias="status"),
|
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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List customers with pagination, search, and status filter.
|
"""List customers with pagination, search, status filter, and sorting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page: Page number (1-indexed).
|
page: Page number (1-indexed).
|
||||||
per_page: Items per page.
|
per_page: Items per page.
|
||||||
search: Search in name, subdomain, email.
|
search: Search in name, subdomain, email.
|
||||||
status_filter: Filter by status.
|
status_filter: Filter by status.
|
||||||
|
sort_by: Column to sort by — one of SORTABLE_CUSTOMER_COLUMNS.
|
||||||
|
sort_order: "asc" or "desc".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Paginated customer list with metadata.
|
Paginated customer list with metadata.
|
||||||
@@ -113,8 +127,11 @@ async def list_customers(
|
|||||||
query = query.filter(Customer.status == status_filter)
|
query = query.filter(Customer.status == status_filter)
|
||||||
|
|
||||||
total = query.count()
|
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 = (
|
customers = (
|
||||||
query.order_by(Customer.created_at.desc())
|
query.order_by(sort_expr, Customer.id.asc())
|
||||||
.offset((page - 1) * per_page)
|
.offset((page - 1) * per_page)
|
||||||
.limit(per_page)
|
.limit(per_page)
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
/* NetBird MSP Appliance - Custom Styles */
|
/* 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 */
|
/* i18n FOUC prevention */
|
||||||
body.i18n-loading #login-page,
|
body.i18n-loading #login-page,
|
||||||
body.i18n-loading #app-page {
|
body.i18n-loading #app-page {
|
||||||
|
|||||||
+6
-6
@@ -254,13 +254,13 @@
|
|||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th data-i18n="dashboard.thId">ID</th>
|
<th class="sortable-th" data-sort-col="id" onclick="setCustomerSort('id')"><span data-i18n="dashboard.thId">ID</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
|
||||||
<th data-i18n="dashboard.thName">Name</th>
|
<th class="sortable-th" data-sort-col="name" onclick="setCustomerSort('name')"><span data-i18n="dashboard.thName">Name</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
|
||||||
<th data-i18n="dashboard.thSubdomain">Subdomain</th>
|
<th class="sortable-th" data-sort-col="subdomain" onclick="setCustomerSort('subdomain')"><span data-i18n="dashboard.thSubdomain">Subdomain</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
|
||||||
<th data-i18n="dashboard.thStatus">Status</th>
|
<th class="sortable-th" data-sort-col="status" onclick="setCustomerSort('status')"><span data-i18n="dashboard.thStatus">Status</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
|
||||||
<th data-i18n="dashboard.thDashboard">Dashboard</th>
|
<th data-i18n="dashboard.thDashboard">Dashboard</th>
|
||||||
<th data-i18n="dashboard.thDevices">Devices</th>
|
<th class="sortable-th" data-sort-col="max_devices" onclick="setCustomerSort('max_devices')"><span data-i18n="dashboard.thDevices">Devices</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
|
||||||
<th data-i18n="dashboard.thCreated">Created</th>
|
<th class="sortable-th" data-sort-col="created_at" onclick="setCustomerSort('created_at')"><span data-i18n="dashboard.thCreated">Created</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
|
||||||
<th data-i18n="dashboard.thActions">Actions</th>
|
<th data-i18n="dashboard.thActions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
+28
-1
@@ -12,6 +12,8 @@ let currentPage = 'dashboard';
|
|||||||
let currentCustomerId = null;
|
let currentCustomerId = null;
|
||||||
let currentCustomerData = null;
|
let currentCustomerData = null;
|
||||||
let customersPage = 1;
|
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 brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' };
|
||||||
let azureConfig = { azure_enabled: false };
|
let azureConfig = { azure_enabled: false };
|
||||||
|
|
||||||
@@ -458,7 +460,7 @@ async function loadStats() {
|
|||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
const search = document.getElementById('search-input').value;
|
const search = document.getElementById('search-input').value;
|
||||||
const status = document.getElementById('status-filter').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 (search) url += `&search=${encodeURIComponent(search)}`;
|
||||||
if (status) url += `&status=${encodeURIComponent(status)}`;
|
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) {
|
function renderCustomersTable(data) {
|
||||||
|
updateSortHeaders();
|
||||||
const tbody = document.getElementById('customers-table-body');
|
const tbody = document.getElementById('customers-table-body');
|
||||||
if (!data.items || data.items.length === 0) {
|
if (!data.items || data.items.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user