Add "go to last page" and page count to notifications and devices lists

The list endpoints now return a total count alongside the page so the
front-end can show how many pages exist and offer a last-page jump.

Backend: add count(is_new) and count_devices(...) (sharing a WHERE-builder
with list_devices so page and count can't drift), wrap both list responses
in {items, total_count} structs, and register them with utoipa.

Front-end: parse the wrapper shape (dropping the fetch-one-extra trick),
add a Last-page button and a responsive "Page X of Y" / "X / Y" label to
the shared PaginationBar, and track the total in both lists. Notifications
re-sync the count on every fetch and decrement it locally on mark-read/
unread removals so the count stays accurate without a re-fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-05 21:09:25 -04:00
co-authored by Claude Opus 4.8
parent 3cb104e364
commit 844d7d189c
21 changed files with 765 additions and 275 deletions
+168 -45
View File
@@ -30,6 +30,53 @@ fn resolve_sort_direction(sort_order: Option<&str>) -> &'static str {
}
}
// Builds the shared `WHERE` clause (and its bound parameters) used by both
// `list_devices` and `count_devices`, so the page and its total count always
// apply the exact same filters. The returned clause starts with "WHERE 1=1" so
// callers can append further SQL (ordering, paging) unconditionally.
fn build_device_filters(
is_registered: Option<bool>,
last_seen_from: Option<DateTime<Utc>>,
last_seen_to: Option<DateTime<Utc>>,
owner: Option<String>,
device_type: Option<String>,
vendor: Option<String>,
) -> (String, Vec<rusqlite::types::Value>) {
let mut clause = "WHERE 1=1 ".to_string();
let mut params: Vec<rusqlite::types::Value> = Vec::new();
if let Some(is_registered) = is_registered {
debug!("Adding filter is_registered={}", is_registered);
clause.push_str("AND is_registered=? ");
params.push(is_registered.into());
};
if let Some(last_seen_from) = last_seen_from {
debug!("Adding filter last_seen>={}", last_seen_from.to_rfc3339());
clause.push_str("AND last_seen>=? ");
params.push(last_seen_from.to_rfc3339().into());
};
if let Some(last_seen_to) = last_seen_to {
debug!("Adding filter last_seen<={}", last_seen_to.to_rfc3339());
clause.push_str("AND last_seen<=? ");
params.push(last_seen_to.to_rfc3339().into());
};
if let Some(owner) = owner {
debug!("Adding filter owner={}", owner);
clause.push_str("AND owner LIKE ? ");
params.push(format!("%{}%", owner).into());
};
if let Some(device_type) = device_type {
debug!("Adding filter device_type={}", device_type);
clause.push_str("AND device_type=? ");
params.push(device_type.into());
}
if let Some(vendor) = vendor {
debug!("Adding filter vendor={}", vendor);
clause.push_str("AND vendor=? ");
params.push(vendor.into());
}
(clause, params)
}
#[allow(clippy::too_many_arguments)]
pub fn list_devices(
is_registered: Option<bool>,
@@ -47,38 +94,18 @@ pub fn list_devices(
let conn = db::get_db_connection();
// Prepare SQL and parameters
let mut sql_statement = "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices WHERE 1=1 ".to_string();
let mut params: Vec<rusqlite::types::Value> = Vec::new();
if let Some(is_registered) = is_registered {
debug!("Adding filter is_registered={}", is_registered);
sql_statement.push_str("AND is_registered=? ");
params.push(is_registered.into());
};
if let Some(last_seen_from) = last_seen_from {
debug!("Adding filter last_seen>={}", last_seen_from.to_rfc3339());
sql_statement.push_str("AND last_seen>=? ");
params.push(last_seen_from.to_rfc3339().into());
};
if let Some(last_seen_to) = last_seen_to {
debug!("Adding filter last_seen<={}", last_seen_to.to_rfc3339());
sql_statement.push_str("AND last_seen<=? ");
params.push(last_seen_to.to_rfc3339().into());
};
if let Some(owner) = owner {
debug!("Adding filter owner={}", owner);
sql_statement.push_str("AND owner LIKE ? ");
params.push(format!("%{}%", owner).into());
};
if let Some(device_type) = device_type {
debug!("Adding filter device_type={}", device_type);
sql_statement.push_str("AND device_type=? ");
params.push(device_type.into());
}
if let Some(vendor) = vendor {
debug!("Adding filter vendor={}", vendor);
sql_statement.push_str("AND vendor=? ");
params.push(vendor.into());
}
let (filters, mut params) = build_device_filters(
is_registered,
last_seen_from,
last_seen_to,
owner,
device_type,
vendor,
);
let mut sql_statement = format!(
"SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices {}",
filters
);
// List order — both column and direction are validated against a whitelist so user input
// is never interpolated raw. A secondary `mac_address ASC` keeps paging deterministic when
@@ -122,6 +149,38 @@ pub fn list_devices(
Ok(devices)
}
// Counts the devices matching the given filters, ignoring paging. Used to report
// the total number of pages alongside a `list_devices` page.
pub fn count_devices(
is_registered: Option<bool>,
last_seen_from: Option<DateTime<Utc>>,
last_seen_to: Option<DateTime<Utc>>,
owner: Option<String>,
device_type: Option<String>,
vendor: Option<String>,
) -> Result<i64, DbError> {
debug!("Counting devices");
let conn = db::get_db_connection();
let (filters, params) = build_device_filters(
is_registered,
last_seen_from,
last_seen_to,
owner,
device_type,
vendor,
);
let sql_statement = format!("SELECT COUNT(*) FROM devices {}", filters);
let count: i64 = conn.query_row(
sql_statement.as_str(),
params_from_iter(params.iter()),
|row| row.get(0),
)?;
Ok(count)
}
// Read device from its MAC address
pub fn read(mac_address: String) -> Option<Device> {
let conn = db::get_db_connection();
@@ -368,7 +427,8 @@ mod tests {
assert!(devices.len() >= 3, "There should be at least 3 devices");
// Validate 1 device data
let device = devices
.iter().find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
.iter()
.find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
.unwrap();
validate_device(
@@ -386,9 +446,19 @@ mod tests {
);
// List registered devices
let devices: Vec<Device> =
list_devices(Some(true), None, None, None, None, None, None, None, None, None)
.unwrap();
let devices: Vec<Device> = list_devices(
Some(true),
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(
devices.len() >= 2,
@@ -397,22 +467,22 @@ mod tests {
// Device aa:aa:aa:aa:aa:aa should not be present
assert!(
devices
.iter().find(|item| item.mac_address == "aa:aa:aa:aa:aa:aa")
.iter()
.find(|item| item.mac_address == "aa:aa:aa:aa:aa:aa")
.is_none(),
"Device aa:aa:aa:aa:aa:aa should not be present"
);
// All devices should be registered
assert!(
devices
.iter().find(|item| !item.is_registered)
.is_none(),
devices.iter().find(|item| !item.is_registered).is_none(),
"There should not be any non-registered devices"
);
// Validate 1 device
let device = devices
.iter().find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
.iter()
.find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
.unwrap();
validate_device(
@@ -479,7 +549,8 @@ mod tests {
assert!(!devices.is_empty(), "There should be at least one device");
assert!(
devices
.iter().find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
.iter()
.find(|item| item.mac_address == "bb:bb:bb:bb:bb:bb")
.is_some(),
"Device bb:bb:bb:bb:bb:bb should be present"
);
@@ -526,7 +597,10 @@ mod tests {
let macs_desc: Vec<&str> = by_mac_desc.iter().map(|d| d.mac_address.as_str()).collect();
let mut sorted_macs_desc = macs_desc.clone();
sorted_macs_desc.sort_by(|a, b| b.cmp(a));
assert_eq!(macs_desc, sorted_macs_desc, "mac_address desc should be sorted reversed");
assert_eq!(
macs_desc, sorted_macs_desc,
"mac_address desc should be sorted reversed"
);
// Sort by owner ascending — empty-string owners (unregistered) come first lexically.
let by_owner_asc = list_devices(
@@ -561,9 +635,13 @@ mod tests {
None,
)
.unwrap();
let default_sorted = list_devices(None, None, None, None, None, None, None, None, None, None).unwrap();
let default_sorted =
list_devices(None, None, None, None, None, None, None, None, None, None).unwrap();
let invalid_macs: Vec<&str> = invalid.iter().map(|d| d.mac_address.as_str()).collect();
let default_macs: Vec<&str> = default_sorted.iter().map(|d| d.mac_address.as_str()).collect();
let default_macs: Vec<&str> = default_sorted
.iter()
.map(|d| d.mac_address.as_str())
.collect();
assert_eq!(
invalid_macs, default_macs,
"invalid sort_by must fall back to the default ordering"
@@ -618,6 +696,51 @@ mod tests {
);
}
#[tokio::test]
async fn test_count_devices() {
tests_common::setup().await;
// Count must match an unpaginated list, independent of paging, for both
// no filter and a representative filter combination.
let total = count_devices(None, None, None, None, None, None).unwrap();
assert_eq!(
total,
list_devices(None, None, None, None, None, None, None, None, None, None)
.unwrap()
.len() as i64,
"Count of all devices should match the unpaginated list length"
);
// Paging the list must not change the count.
let total_paged = count_devices(None, None, None, None, None, None).unwrap();
assert_eq!(total_paged, total, "Count should ignore paging");
// Filtered count matches the filtered unpaginated list.
let registered = count_devices(Some(true), None, None, None, None, None).unwrap();
assert_eq!(
registered,
list_devices(
Some(true),
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap()
.len() as i64,
"Count of registered devices should match the filtered list length"
);
assert!(
registered <= total,
"Filtered count should not exceed the total"
);
}
#[tokio::test]
async fn test_seen() {
tests_common::setup().await;
+72 -11
View File
@@ -62,6 +62,30 @@ pub fn list(
Ok(notifications)
}
// Counts the notifications matching the given filter, ignoring paging. Used to
// report the total number of pages alongside a `list` page.
pub fn count(is_new: Option<bool>) -> Result<i64, DbError> {
debug!("Counting notifications");
let conn = db::get_db_connection();
let mut sql_statement = "SELECT COUNT(*) FROM notifications WHERE 1=1".to_string();
let mut params: Vec<rusqlite::types::Value> = Vec::new();
if let Some(is_new) = is_new {
debug!("Adding filter is_new={}", is_new);
sql_statement.push_str(" AND is_new=?");
params.push(is_new.into());
}
let count: i64 = conn.query_row(
sql_statement.as_str(),
params_from_iter(params.iter()),
|row| row.get(0),
)?;
Ok(count)
}
pub fn insert(notification: Notification) -> Result<i64, DbError> {
let conn = db::get_db_connection();
let mac_address = notification.mac_address.as_deref().map(normalize_mac);
@@ -360,10 +384,7 @@ mod tests {
NotificationType::DeviceOnlineAfterTime,
"Wrong notification type (should be DeviceOnlineAfterTime)"
);
assert!(
inserted_notification.is_new,
"Notification should be new"
);
assert!(inserted_notification.is_new, "Notification should be new");
assert_eq!(
inserted_notification.created_on, created_on,
"Wrong created_on (should be ${created_on})"
@@ -502,10 +523,7 @@ mod tests {
NotificationType::DeviceOnlineAfterTime,
"Wrong notification type (should be DeviceOnlineAfterTime)"
);
assert!(
inserted_notification.is_new,
"Notification should be new"
);
assert!(inserted_notification.is_new, "Notification should be new");
assert_eq!(
inserted_notification.created_on, created_on,
"Wrong created_on (should be ${created_on})"
@@ -576,7 +594,8 @@ mod tests {
// Check date of notification 1
let notification1 = notifications
.iter().find(|notification| notification.id == 1)
.iter()
.find(|notification| notification.id == 1)
.unwrap();
// 2026-01-03 14:13:12 - UTC
@@ -588,7 +607,8 @@ mod tests {
// Check title of notification 3
let notification3 = notifications
.iter().find(|notification| notification.id == 3)
.iter()
.find(|notification| notification.id == 3)
.unwrap();
assert_eq!(
@@ -599,7 +619,8 @@ mod tests {
// Check body of notification 5
let notification5 = notifications
.iter().find(|notification| notification.id == 5)
.iter()
.find(|notification| notification.id == 5)
.unwrap();
assert_eq!(
@@ -648,4 +669,44 @@ mod tests {
"Second page should have at least 1 notification"
);
}
#[tokio::test]
async fn test_count() {
tests_common::setup().await;
// The count must match an unpaginated list for each filter.
let all = count(None).unwrap();
assert_eq!(
all,
list(None, None, None).unwrap().len() as i64,
"Count of all notifications should match the unpaginated list length"
);
let new = count(Some(true)).unwrap();
assert_eq!(
new,
list(Some(true), None, None).unwrap().len() as i64,
"Count of new notifications should match the unpaginated list length"
);
let old = count(Some(false)).unwrap();
assert_eq!(
old,
list(Some(false), None, None).unwrap().len() as i64,
"Count of old notifications should match the unpaginated list length"
);
// New + old must add up to the total, and the count must be independent
// of paging.
assert_eq!(
new + old,
all,
"New and old counts should add up to the total"
);
assert_eq!(
count(Some(true)).unwrap(),
new,
"Count should ignore paging parameters"
);
}
}
+8
View File
@@ -28,6 +28,14 @@ pub struct Device {
pub name: Option<String>,
}
// A page of devices plus the total number of devices matching the request's
// filters, so the front-end can show how many pages exist.
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct DeviceListResponse {
pub items: Vec<Device>,
pub total_count: i64,
}
impl Device {
pub fn new(
mac_address: String,
+8
View File
@@ -18,6 +18,14 @@ pub struct Notification {
pub mac_address: Option<String>,
}
// A page of notifications plus the total number of notifications matching the
// request's filters, so the front-end can show how many pages exist.
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct NotificationListResponse {
pub items: Vec<Notification>,
pub total_count: i64,
}
impl Notification {
pub fn new(
created_on: DateTime<Utc>,
+4 -2
View File
@@ -2,8 +2,8 @@ use std::error::Error;
use std::path::{Path, PathBuf};
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType};
use crate::model::devices::{Device, DeviceListResponse, DeviceSummary};
use crate::model::notifications::{Notification, NotificationListResponse, NotificationType};
use crate::settings::get_settings;
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
use crate::web_server::scanner_status::{
@@ -66,8 +66,10 @@ pub mod utils;
),
components(schemas(
Device,
DeviceListResponse,
DeviceSummary,
Notification,
NotificationListResponse,
NotificationType,
RegisterDevicePayload,
UpdateDevicePayload,
+29 -12
View File
@@ -10,7 +10,7 @@ use utoipa::ToSchema;
use crate::{
db,
model::devices::{Device, DeviceSummary},
model::devices::{Device, DeviceListResponse, DeviceSummary},
};
use crate::web_server::utils;
@@ -32,14 +32,14 @@ use crate::web_server::utils;
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
),
responses(
(status = 200, description = "List of devices", body = Vec<Device>),
(status = 200, description = "List of devices", body = DeviceListResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<Device>>, StatusCode> {
) -> Result<Json<DeviceListResponse>, StatusCode> {
let is_registered: Option<bool> = utils::parse_parameter_bool(&params, "is_registered");
let last_seen_from: Option<DateTime<Utc>> =
utils::parse_parameter_date(&params, "last_seen_from");
@@ -52,24 +52,41 @@ pub async fn list(
let page_offset: Option<i64> = utils::parse_parameter_int(&params, "page_offset");
let page_limit: Option<i64> = utils::parse_parameter_int(&params, "page_limit");
match db::devices::list_devices(
let items = match db::devices::list_devices(
is_registered,
last_seen_from,
last_seen_to,
owner.clone(),
device_type.clone(),
vendor.clone(),
sort_by,
sort_order,
page_offset,
page_limit,
) {
Ok(value) => value,
Err(err) => {
error!("Error listing devices: {}", err);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let total_count = match db::devices::count_devices(
is_registered,
last_seen_from,
last_seen_to,
owner,
device_type,
vendor,
sort_by,
sort_order,
page_offset,
page_limit,
) {
Ok(value) => Ok(Json(value)),
Ok(value) => value,
Err(err) => {
error!("Error listing devices: {}", err);
Err(StatusCode::INTERNAL_SERVER_ERROR)
error!("Error counting devices: {}", err);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
};
Ok(Json(DeviceListResponse { items, total_count }))
}
#[utoipa::path(
+21 -7
View File
@@ -8,7 +8,11 @@ use axum::{
};
use log::error;
use crate::{db, model::notifications::Notification, web_server::utils};
use crate::{
db,
model::notifications::{Notification, NotificationListResponse},
web_server::utils,
};
#[utoipa::path(
get,
@@ -118,23 +122,33 @@ pub async fn mark_all_as_old() -> impl IntoResponse {
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
),
responses(
(status = 200, description = "List of notifications", body = Vec<Notification>),
(status = 200, description = "List of notifications", body = NotificationListResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<Notification>>, StatusCode> {
) -> Result<Json<NotificationListResponse>, StatusCode> {
let is_new: Option<bool> = utils::parse_parameter_bool(&params, "is_new");
let page_offset: Option<i64> = utils::parse_parameter_int(&params, "page_offset");
let page_limit: Option<i64> = utils::parse_parameter_int(&params, "page_limit");
match db::notifications::list(is_new, page_offset, page_limit) {
Ok(value) => Ok(Json(value)),
let items = match db::notifications::list(is_new, page_offset, page_limit) {
Ok(value) => value,
Err(err) => {
error!("Error listing notifications: {}", err);
Err(StatusCode::INTERNAL_SERVER_ERROR)
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
};
let total_count = match db::notifications::count(is_new) {
Ok(value) => value,
Err(err) => {
error!("Error counting notifications: {}", err);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
Ok(Json(NotificationListResponse { items, total_count }))
}