Redesign Home screen with device summary and scanner status

Adds a responsive Home screen combining the notification list with a
device summary panel and ARP scanner status, visible side-by-side on
wide screens and stacked on narrow ones.

Backend:
- New GET /api/devices/summary endpoint returning registered device
  counts and "seen in last 24h / 7 days" breakdowns by registration
  status
- Migration 07 adds indexes on (is_registered) and (last_seen,
  is_registered) so summary queries use index range scans instead of
  full table scans

Frontend:
- HomeScreen replaces NotificationList, embedding notifications,
  device summary card, and ArpScannerCard in a LayoutBuilder-driven
  two-column (≥700 px) or single-column layout
- ArpScannerCard extracted to a shared public widget reused by both
  HomeScreen and StatusScreen
- Nav rail entry renamed to "Home" with home icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-28 19:10:08 -04:00
co-authored by Claude Sonnet 4.6
parent dc8f47e66d
commit 251d8a479e
13 changed files with 802 additions and 367 deletions
+96 -2
View File
@@ -1,10 +1,10 @@
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
use log::{debug, error};
use rusqlite::{params, params_from_iter};
use crate::{
db::{self, error::DbError},
model::devices::Device,
model::devices::{Device, DeviceSummary},
};
pub fn list_devices(
@@ -122,6 +122,47 @@ pub fn insert(device: Device) -> Result<(), DbError> {
}
}
pub fn get_summary() -> Result<DeviceSummary, DbError> {
debug!("Getting device summary");
let conn = db::get_db_connection();
let one_day_ago = (Utc::now() - Duration::days(1)).to_rfc3339();
let one_week_ago = (Utc::now() - Duration::weeks(1)).to_rfc3339();
let total_registered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE is_registered = 1",
[],
|row| row.get(0),
)?;
let seen_last_day_registered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 1",
params![one_day_ago],
|row| row.get(0),
)?;
let seen_last_day_unregistered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 0",
params![one_day_ago],
|row| row.get(0),
)?;
let seen_last_week_registered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 1",
params![one_week_ago],
|row| row.get(0),
)?;
let seen_last_week_unregistered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 0",
params![one_week_ago],
|row| row.get(0),
)?;
Ok(DeviceSummary {
total_registered,
seen_last_day_registered,
seen_last_day_unregistered,
seen_last_week_registered,
seen_last_week_unregistered,
})
}
pub fn update(device: Device) -> Result<(), DbError> {
let conn = db::get_db_connection();
match conn.execute(
@@ -432,6 +473,59 @@ mod tests {
);
}
#[tokio::test]
async fn test_get_summary() {
tests_common::setup().await;
// Insert a registered device seen now
insert(Device {
mac_address: "su:mm:ar:y1:01:01".to_string(),
ipv4_address: "192.168.50.1".to_string(),
vendor: "Test".to_string(),
last_seen: Utc::now(),
is_registered: true,
owner: "Test".to_string(),
device_type: "Server".to_string(),
})
.unwrap();
// Insert an unregistered device seen now
insert(Device {
mac_address: "su:mm:ar:y1:02:02".to_string(),
ipv4_address: "192.168.50.2".to_string(),
vendor: "Test".to_string(),
last_seen: Utc::now(),
is_registered: false,
owner: "".to_string(),
device_type: "".to_string(),
})
.unwrap();
let summary = get_summary().unwrap();
// bb and cc from seed data are registered, plus our new one
assert!(
summary.total_registered >= 3,
"Should have at least 3 registered devices"
);
assert!(
summary.seen_last_day_registered >= 1,
"Should have at least 1 registered device seen in last day"
);
assert!(
summary.seen_last_day_unregistered >= 1,
"Should have at least 1 unregistered device seen in last day"
);
assert!(
summary.seen_last_week_registered >= 1,
"Should have at least 1 registered device seen in last week"
);
assert!(
summary.seen_last_week_unregistered >= 1,
"Should have at least 1 unregistered device seen in last week"
);
}
fn validate_device(
device: Device,
mac_address: String,
+9
View File
@@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize};
use std::fmt;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct DeviceSummary {
pub total_registered: i64,
pub seen_last_day_registered: i64,
pub seen_last_day_unregistered: i64,
pub seen_last_week_registered: i64,
pub seen_last_week_unregistered: i64,
}
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct Device {
pub mac_address: String,
+4 -1
View File
@@ -1,7 +1,7 @@
use std::error::Error;
use crate::model::device_events::{DeviceEvent, DeviceEventType};
use crate::model::devices::Device;
use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings;
use crate::web_server::arp_scanner::ArpScannerStatusResponse;
@@ -38,6 +38,7 @@ pub mod utils;
paths(
test_api,
devices::list,
devices::summary,
devices::read,
devices::register,
devices::unregister,
@@ -51,6 +52,7 @@ pub mod utils;
),
components(schemas(
Device,
DeviceSummary,
Notification,
NotificationType,
RegisterDevicePayload,
@@ -103,6 +105,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
.route("/api/test", get(test_api))
.route("/api/devices", get(devices::list))
.route("/api/devices", put(devices::register))
.route("/api/devices/summary", get(devices::summary))
.route("/api/devices/{mac_address}", delete(devices::unregister))
.route("/api/devices/{mac_address}", get(devices::read))
.route("/api/devices/{mac_address}/events", get(device_events::list))
+21 -1
View File
@@ -8,7 +8,7 @@ use log::{debug, error};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::{db, model::devices::Device};
use crate::{db, model::devices::{Device, DeviceSummary}};
use crate::web_server::utils;
@@ -177,6 +177,26 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
}
}
#[utoipa::path(
get,
path = "/api/devices/summary",
tag = "devices",
responses(
(status = 200, description = "Device summary counts", body = DeviceSummary),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn summary() -> Result<Json<DeviceSummary>, StatusCode> {
match db::devices::get_summary() {
Ok(value) => Ok(Json(value)),
Err(err) => {
error!("Error getting device summary: {}", err);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
// Payload structs
#[derive(Deserialize, ToSchema)]
pub struct RegisterDevicePayload {