📋 What Was Accomplished Backend Changes: - ✅ Enhanced API Endpoint: Updated GET /api/v1/events to accept optional date query parameter - ✅ Input Validation: Added YYYY-MM-DD format validation to PaginationQueryDto - ✅ Database Filtering: Implemented timezone-aware date filtering in EventsService - ✅ Controller Integration: Updated EventsController to pass date parameter to service Frontend Changes: - ✅ Date Picker Component: Created reusable DatePicker component following project design system - ✅ Gallery UI Enhancement: Integrated date picker into gallery page with clear labeling - ✅ State Management: Implemented reactive date state with automatic re-fetching - ✅ Clear Filter Functionality: Added "Clear Filter" button for easy reset - ✅ Enhanced UX: Improved empty states for filtered vs unfiltered views 🔍 Technical Implementation API Design: GET /api/v1/events?date=2025-08-02&limit=20&cursor=xxx Key Files Modified: - meteor-web-backend/src/events/dto/pagination-query.dto.ts - meteor-web-backend/src/events/events.service.ts - meteor-web-backend/src/events/events.controller.ts - meteor-frontend/src/components/ui/date-picker.tsx (new) - meteor-frontend/src/app/gallery/page.tsx - meteor-frontend/src/hooks/use-events.ts - meteor-frontend/src/services/events.ts ✅ All Acceptance Criteria Met 1. ✅ Backend API Enhancement: Accepts optional date parameter 2. ✅ Date Filtering Logic: Returns events for specific calendar date 3. ✅ Date Picker UI: Clean, accessible interface component 4. ✅ Automatic Re-fetching: Immediate data updates on date selection 5. ✅ Filtered Display: Correctly shows only events for selected date 6. ✅ Clear Filter: One-click reset to view all events 🧪 Quality Assurance - ✅ Backend Build: Successful compilation with no errors - ✅ Frontend Build: Successful Next.js build with no warnings - ✅ Linting: All ESLint checks pass - ✅ Functionality: Feature working as specified 🎉 Epic 2 Complete! With Story 2.9 completion, Epic 2: Commercialization & Core User Experience is now DONE! Epic 2 Achievements: - 🔐 Full-stack device status monitoring - 💳 Robust payment and subscription system - 🛡️ Subscription-based access control - 📊 Enhanced data browsing with detail pages - 📅 Date-based event filtering
400 lines
14 KiB
Rust
400 lines
14 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::camera::{CameraConfig, CameraSource};
|
|
use crate::storage::{StorageConfig, VideoQuality};
|
|
use crate::communication::CommunicationConfig;
|
|
|
|
/// Configuration structure for the meteor edge client
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
/// Whether the device has been successfully registered
|
|
pub registered: bool,
|
|
/// The hardware ID used for registration
|
|
pub hardware_id: String,
|
|
/// Timestamp of when registration was completed
|
|
pub registered_at: Option<String>,
|
|
/// The user profile ID this device is registered to
|
|
pub user_profile_id: Option<String>,
|
|
/// Device ID returned from the registration API
|
|
pub device_id: Option<String>,
|
|
/// JWT token for authentication with backend services
|
|
pub jwt_token: Option<String>,
|
|
}
|
|
|
|
impl Config {
|
|
/// Creates a new unregistered configuration
|
|
pub fn new(hardware_id: String) -> Self {
|
|
Self {
|
|
registered: false,
|
|
hardware_id,
|
|
registered_at: None,
|
|
user_profile_id: None,
|
|
device_id: None,
|
|
jwt_token: None,
|
|
}
|
|
}
|
|
|
|
/// Marks the configuration as registered with the given details
|
|
pub fn mark_registered(&mut self, user_profile_id: String, device_id: String, jwt_token: String) {
|
|
self.registered = true;
|
|
self.user_profile_id = Some(user_profile_id);
|
|
self.device_id = Some(device_id);
|
|
self.jwt_token = Some(jwt_token);
|
|
self.registered_at = Some(
|
|
chrono::Utc::now().to_rfc3339()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Configuration manager handles reading and writing config files
|
|
pub struct ConfigManager {
|
|
config_path: PathBuf,
|
|
}
|
|
|
|
impl ConfigManager {
|
|
/// Creates a new configuration manager
|
|
/// Uses system-appropriate config directory or falls back to a local path
|
|
pub fn new() -> Self {
|
|
let config_path = get_config_file_path();
|
|
Self { config_path }
|
|
}
|
|
|
|
/// Creates a configuration manager with a custom path (useful for testing)
|
|
pub fn with_path<P: AsRef<Path>>(path: P) -> Self {
|
|
Self {
|
|
config_path: path.as_ref().to_path_buf(),
|
|
}
|
|
}
|
|
|
|
/// Checks if a configuration file exists
|
|
pub fn config_exists(&self) -> bool {
|
|
self.config_path.exists()
|
|
}
|
|
|
|
/// Loads configuration from the file system
|
|
pub fn load_config(&self) -> Result<Config> {
|
|
let content = fs::read_to_string(&self.config_path)
|
|
.with_context(|| format!("Failed to read config file: {:?}", self.config_path))?;
|
|
|
|
let config: Config = toml::from_str(&content)
|
|
.context("Failed to parse config file as TOML")?;
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Saves configuration to the file system
|
|
pub fn save_config(&self, config: &Config) -> Result<()> {
|
|
// Ensure the parent directory exists
|
|
if let Some(parent) = self.config_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
|
|
}
|
|
|
|
let content = toml::to_string_pretty(config)
|
|
.context("Failed to serialize config to TOML")?;
|
|
|
|
fs::write(&self.config_path, content)
|
|
.with_context(|| format!("Failed to write config file: {:?}", self.config_path))?;
|
|
|
|
println!("✅ Configuration saved to: {:?}", self.config_path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Gets the path where the config file is stored
|
|
pub fn get_config_path(&self) -> &Path {
|
|
&self.config_path
|
|
}
|
|
}
|
|
|
|
/// Camera-specific configuration structure for TOML parsing
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CameraConfigToml {
|
|
pub source: String, // "device" or file path
|
|
pub device_id: Option<i32>,
|
|
pub fps: Option<f64>,
|
|
pub width: Option<i32>,
|
|
pub height: Option<i32>,
|
|
}
|
|
|
|
impl Default for CameraConfigToml {
|
|
fn default() -> Self {
|
|
Self {
|
|
source: "device".to_string(),
|
|
device_id: Some(0),
|
|
fps: Some(30.0),
|
|
width: Some(640),
|
|
height: Some(480),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Storage-specific configuration structure for TOML parsing
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct StorageConfigToml {
|
|
pub frame_buffer_size: Option<usize>,
|
|
pub storage_path: Option<String>,
|
|
pub retention_days: Option<u32>,
|
|
pub video_quality: Option<String>, // "low", "medium", "high"
|
|
pub cleanup_interval_hours: Option<u64>,
|
|
}
|
|
|
|
impl Default for StorageConfigToml {
|
|
fn default() -> Self {
|
|
Self {
|
|
frame_buffer_size: Some(200),
|
|
storage_path: Some("./meteor_events".to_string()),
|
|
retention_days: Some(30),
|
|
video_quality: Some("medium".to_string()),
|
|
cleanup_interval_hours: Some(24),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Communication-specific configuration structure for TOML parsing
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CommunicationConfigToml {
|
|
pub api_base_url: Option<String>,
|
|
pub retry_attempts: Option<u32>,
|
|
pub retry_delay_seconds: Option<u64>,
|
|
pub max_retry_delay_seconds: Option<u64>,
|
|
pub request_timeout_seconds: Option<u64>,
|
|
pub heartbeat_interval_seconds: Option<u64>,
|
|
}
|
|
|
|
impl Default for CommunicationConfigToml {
|
|
fn default() -> Self {
|
|
Self {
|
|
api_base_url: Some("http://localhost:3000".to_string()),
|
|
retry_attempts: Some(3),
|
|
retry_delay_seconds: Some(2),
|
|
max_retry_delay_seconds: Some(60),
|
|
request_timeout_seconds: Some(300),
|
|
heartbeat_interval_seconds: Some(300),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level configuration structure with camera, storage, and communication settings
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct AppConfig {
|
|
pub camera: Option<CameraConfigToml>,
|
|
pub storage: Option<StorageConfigToml>,
|
|
pub communication: Option<CommunicationConfigToml>,
|
|
}
|
|
|
|
/// Load camera configuration from TOML file
|
|
pub fn load_camera_config() -> Result<CameraConfig> {
|
|
let config_path = get_app_config_file_path();
|
|
|
|
let camera_config = if config_path.exists() {
|
|
let content = fs::read_to_string(&config_path)
|
|
.with_context(|| format!("Failed to read app config file: {:?}", config_path))?;
|
|
|
|
let app_config: AppConfig = toml::from_str(&content)
|
|
.context("Failed to parse app config file as TOML")?;
|
|
|
|
app_config.camera.unwrap_or_default()
|
|
} else {
|
|
println!("📄 No app config file found, using default camera settings");
|
|
CameraConfigToml::default()
|
|
};
|
|
|
|
// Convert TOML config to CameraConfig
|
|
let source = if camera_config.source == "device" {
|
|
CameraSource::Device(camera_config.device_id.unwrap_or(0))
|
|
} else {
|
|
CameraSource::File(camera_config.source.clone())
|
|
};
|
|
|
|
Ok(CameraConfig {
|
|
source,
|
|
fps: camera_config.fps.unwrap_or(30.0),
|
|
width: camera_config.width,
|
|
height: camera_config.height,
|
|
})
|
|
}
|
|
|
|
/// Load storage configuration from TOML file
|
|
pub fn load_storage_config() -> Result<StorageConfig> {
|
|
let config_path = get_app_config_file_path();
|
|
|
|
let storage_config = if config_path.exists() {
|
|
let content = fs::read_to_string(&config_path)
|
|
.with_context(|| format!("Failed to read app config file: {:?}", config_path))?;
|
|
|
|
let app_config: AppConfig = toml::from_str(&content)
|
|
.context("Failed to parse app config file as TOML")?;
|
|
|
|
app_config.storage.unwrap_or_default()
|
|
} else {
|
|
println!("📄 No app config file found, using default storage settings");
|
|
StorageConfigToml::default()
|
|
};
|
|
|
|
// Convert TOML config to StorageConfig
|
|
let video_quality = match storage_config.video_quality.as_deref() {
|
|
Some("low") => VideoQuality::Low,
|
|
Some("high") => VideoQuality::High,
|
|
_ => VideoQuality::Medium, // Default and fallback for "medium"
|
|
};
|
|
|
|
let base_storage_path = PathBuf::from(
|
|
storage_config.storage_path.unwrap_or_else(|| "./meteor_events".to_string())
|
|
);
|
|
|
|
Ok(StorageConfig {
|
|
frame_buffer_size: storage_config.frame_buffer_size.unwrap_or(200),
|
|
base_storage_path,
|
|
retention_days: storage_config.retention_days.unwrap_or(30),
|
|
video_quality,
|
|
cleanup_interval_hours: storage_config.cleanup_interval_hours.unwrap_or(24),
|
|
})
|
|
}
|
|
|
|
/// Load communication configuration from TOML file
|
|
pub fn load_communication_config() -> Result<CommunicationConfig> {
|
|
let config_path = get_app_config_file_path();
|
|
|
|
let communication_config = if config_path.exists() {
|
|
let content = fs::read_to_string(&config_path)
|
|
.with_context(|| format!("Failed to read app config file: {:?}", config_path))?;
|
|
|
|
let app_config: AppConfig = toml::from_str(&content)
|
|
.context("Failed to parse app config file as TOML")?;
|
|
|
|
app_config.communication.unwrap_or_default()
|
|
} else {
|
|
println!("📄 No app config file found, using default communication settings");
|
|
CommunicationConfigToml::default()
|
|
};
|
|
|
|
// Convert TOML config to CommunicationConfig
|
|
Ok(CommunicationConfig {
|
|
api_base_url: communication_config.api_base_url.unwrap_or_else(|| "http://localhost:3000".to_string()),
|
|
retry_attempts: communication_config.retry_attempts.unwrap_or(3),
|
|
retry_delay_seconds: communication_config.retry_delay_seconds.unwrap_or(2),
|
|
max_retry_delay_seconds: communication_config.max_retry_delay_seconds.unwrap_or(60),
|
|
request_timeout_seconds: communication_config.request_timeout_seconds.unwrap_or(300),
|
|
heartbeat_interval_seconds: communication_config.heartbeat_interval_seconds.unwrap_or(300),
|
|
})
|
|
}
|
|
|
|
/// Create a sample app configuration file
|
|
pub fn create_sample_app_config() -> Result<()> {
|
|
let config_path = get_app_config_file_path();
|
|
|
|
if config_path.exists() {
|
|
println!("📄 App config file already exists at: {:?}", config_path);
|
|
return Ok(());
|
|
}
|
|
|
|
let sample_config = AppConfig {
|
|
camera: Some(CameraConfigToml::default()),
|
|
storage: Some(StorageConfigToml::default()),
|
|
communication: Some(CommunicationConfigToml::default()),
|
|
};
|
|
|
|
// Ensure the parent directory exists
|
|
if let Some(parent) = config_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
|
|
}
|
|
|
|
let content = toml::to_string_pretty(&sample_config)
|
|
.context("Failed to serialize sample config to TOML")?;
|
|
|
|
fs::write(&config_path, content)
|
|
.with_context(|| format!("Failed to write sample config file: {:?}", config_path))?;
|
|
|
|
println!("✅ Sample app config created at: {:?}", config_path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the path for app configuration file (separate from device config)
|
|
fn get_app_config_file_path() -> PathBuf {
|
|
// Try standard system config location first
|
|
let system_config = Path::new("/etc/meteor-client/app-config.toml");
|
|
if system_config.parent().map_or(false, |p| p.exists()) {
|
|
return system_config.to_path_buf();
|
|
}
|
|
|
|
// Fallback to user config directory
|
|
if let Some(config_dir) = dirs::config_dir() {
|
|
let user_config = config_dir.join("meteor-client").join("app-config.toml");
|
|
return user_config;
|
|
}
|
|
|
|
// Last resort: local directory
|
|
PathBuf::from("meteor-app-config.toml")
|
|
}
|
|
|
|
/// Determines the appropriate config file path based on the system
|
|
fn get_config_file_path() -> PathBuf {
|
|
// Try standard system config location first
|
|
let system_config = Path::new("/etc/meteor-client/config.toml");
|
|
if system_config.parent().map_or(false, |p| p.exists()) {
|
|
return system_config.to_path_buf();
|
|
}
|
|
|
|
// Fallback to user config directory
|
|
if let Some(config_dir) = dirs::config_dir() {
|
|
let user_config = config_dir.join("meteor-client").join("config.toml");
|
|
return user_config;
|
|
}
|
|
|
|
// Last resort: local directory
|
|
PathBuf::from("meteor-client-config.toml")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::NamedTempFile;
|
|
|
|
#[test]
|
|
fn test_config_creation() {
|
|
let config = Config::new("TEST_DEVICE_123".to_string());
|
|
assert!(!config.registered);
|
|
assert_eq!(config.hardware_id, "TEST_DEVICE_123");
|
|
assert!(config.user_profile_id.is_none());
|
|
assert!(config.device_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_mark_registered() {
|
|
let mut config = Config::new("TEST_DEVICE_123".to_string());
|
|
config.mark_registered("user-456".to_string(), "device-789".to_string(), "test-jwt-token".to_string());
|
|
|
|
assert!(config.registered);
|
|
assert_eq!(config.user_profile_id.as_ref().unwrap(), "user-456");
|
|
assert_eq!(config.device_id.as_ref().unwrap(), "device-789");
|
|
assert_eq!(config.jwt_token.as_ref().unwrap(), "test-jwt-token");
|
|
assert!(config.registered_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_save_and_load() -> Result<()> {
|
|
let temp_file = NamedTempFile::new()?;
|
|
let config_manager = ConfigManager::with_path(temp_file.path());
|
|
|
|
let mut config = Config::new("TEST_DEVICE_456".to_string());
|
|
config.mark_registered("user-123".to_string(), "device-456".to_string(), "test-jwt-456".to_string());
|
|
|
|
// Save config
|
|
config_manager.save_config(&config)?;
|
|
assert!(config_manager.config_exists());
|
|
|
|
// Load config
|
|
let loaded_config = config_manager.load_config()?;
|
|
assert!(loaded_config.registered);
|
|
assert_eq!(loaded_config.hardware_id, "TEST_DEVICE_456");
|
|
assert_eq!(loaded_config.user_profile_id.as_ref().unwrap(), "user-123");
|
|
assert_eq!(loaded_config.device_id.as_ref().unwrap(), "device-456");
|
|
assert_eq!(loaded_config.jwt_token.as_ref().unwrap(), "test-jwt-456");
|
|
|
|
Ok(())
|
|
}
|
|
} |