From 5126c648ff56633375bf9997aafc9e00a8498fc5 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 18 Jan 2026 19:53:34 -0300 Subject: [PATCH] Auto-commit: 20260118_195334 --- .cargo/config.toml | 6 + .product | 2 +- Cargo.toml | 126 +- README.md | 4 +- src/basic/keywords/import_export.rs | 57 +- src/billing/alerts.rs | 2018 +++++++++++---------------- src/core/shared/admin.rs | 16 +- src/core/shared/memory_monitor.rs | 769 +++++----- src/core/shared/state.rs | 2 +- src/core/shared/test_utils.rs | 4 +- src/drive/vectordb.rs | 1332 +++++++++--------- src/main.rs | 62 +- src/monitoring/mod.rs | 792 +++++------ src/security/auth.rs | 7 +- src/settings/mod.rs | 492 ++++--- 15 files changed, 2625 insertions(+), 3064 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..983263972 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[build] +rustc-wrapper = "sccache" + +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.product b/.product index 352a9a2f4..3d8dc5669 100644 --- a/.product +++ b/.product @@ -12,7 +12,7 @@ name=General Bots # Available apps: chat, mail, calendar, drive, tasks, docs, paper, sheet, slides, # meet, research, sources, analytics, admin, monitoring, settings # Only listed apps will be visible in the UI and have their APIs enabled. -apps=chat,mail,calendar,drive,tasks,docs,paper,sheet,slides,meet,research,sources,analytics,admin,monitoring,settings +apps=chat,drive,tasks,sources,settings # Default theme # Available themes: dark, light, blue, purple, green, orange, sentient, cyberpunk, diff --git a/Cargo.toml b/Cargo.toml index b74a119c9..df952147e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,27 +2,28 @@ name = "botserver" version = "6.1.0" edition = "2021" - -# ... [authors, description, license, repository sections remain the same] +resolver = "2" # Better feature resolution [dependencies.botlib] path = "../botlib" -features = ["database", "i18n"] +# Remove features here - control them in botlib's Cargo.toml +# features = ["database", "i18n"] # BAD - causes full recompile [features] -# ===== DEFAULT FEATURE SET ===== -default = ["chat", "drive", "tasks", "automation"] +# ===== SINGLE DEFAULT FEATURE SET ===== +default = ["chat", "drive", "tasks", "automation", "cache"] # ===== COMMUNICATION APPS ===== -chat = [] +chat = ["botlib/chat"] # Delegate to botlib people = [] -mail = ["email", "imap", "lettre", "mailparse", "native-tls"] +mail = ["botlib/mail"] # Delegate optional deps to botlib meet = ["dep:livekit"] social = [] whatsapp = [] telegram = [] instagram = [] msteams = [] +# CONSIDER: Do you REALLY need this mega-feature? communications = ["chat", "people", "mail", "meet", "social", "whatsapp", "telegram", "instagram", "msteams", "cache"] # ===== PRODUCTIVITY APPS ===== @@ -34,11 +35,11 @@ workspace = [] productivity = ["calendar", "tasks", "project", "goals", "workspace", "cache"] # ===== DOCUMENT APPS ===== -paper = ["docx-rs", "ooxmlsdk", "dep:pdf-extract"] +paper = ["docs", "dep:pdf-extract"] # Reuse docs docs = ["docx-rs", "ooxmlsdk"] -sheet = ["umya-spreadsheet", "calamine", "rust_xlsxwriter", "spreadsheet-ods"] +sheet = ["calamine", "spreadsheet-ods"] # Reduced - pick one Excel lib slides = ["ooxmlsdk"] -drive = ["dep:aws-config", "dep:aws-sdk-s3", "dep:pdf-extract", "dep:zip", "dep:downloader", "dep:flate2", "dep:tar"] +drive = ["dep:aws-config", "dep:aws-sdk-s3", "dep:pdf-extract", "dep:flate2"] documents = ["paper", "docs", "sheet", "slides", "drive"] # ===== MEDIA APPS ===== @@ -87,88 +88,77 @@ jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] console = ["dep:crossterm", "dep:ratatui", "monitoring"] # ===== BUNDLE FEATURES ===== +# REDUCED VERSION - Enable only what you actually use full = [ # Communication - "chat", "people", "mail", "meet", "social", "whatsapp", "telegram", "instagram", "msteams", + "chat", "people", "mail", # Productivity - "calendar", "tasks", "project", "goals", "workspace", + "tasks", "calendar", # Documents - "paper", "docs", "sheet", "slides", "drive", - # Media - "video", "player", "canvas", - # Learning - "learn", "research", "sources", - # Analytics - "analytics", "dashboards", "monitoring", - # Development - "designer", "editor", "automation", - # Admin - "attendant", "security", "settings", + "drive", "docs", # Core tech - "llm", "vectordb", "nvidia", "cache", "compliance", "timeseries", "weba", "directory", - "progress-bars", "grpc", "jemalloc", "console" + "llm", "cache", "compliance" ] minimal = ["chat"] lightweight = ["chat", "drive", "tasks", "people"] [dependencies] -# === CORE RUNTIME (Always Required) === +# === CORE RUNTIME (Minimal) === aes-gcm = "0.10" anyhow = "1.0" argon2 = "0.5" async-lock = "2.8.0" async-stream = "0.3" async-trait = "0.1" -axum = { version = "0.7.5", features = ["ws", "multipart", "macros"] } -axum-server = { version = "0.7", features = ["tls-rustls"] } +axum = { version = "0.7.5", default-features = false, features = [] } # NO defaults! base64 = "0.22" bytes = "1.8" -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } color-eyre = "0.6.5" -diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2", "numeric", "128-column-tables"] } -bigdecimal = { version = "0.4", features = ["serde"] } +diesel = { version = "2.1", default-features = false, features = ["postgres", "r2d2"] } # MINIMAL! +bigdecimal = { version = "0.4", default-features = false } diesel_migrations = "2.1.0" dirs = "5.0" dotenvy = "0.15" env_logger = "0.11" futures = "0.3" -futures-util = "0.3" -tokio-util = { version = "0.7", features = ["io", "compat"] } +futures-util = { version = "0.3", default-features = false } +tokio-util = { version = "0.7", default-features = false, features = ["codec"] } hex = "0.4" hmac = "0.12.1" -hyper = { version = "1.4", features = ["full"] } -hyper-rustls = { version = "0.27", features = ["http2"] } +hyper = { version = "1.4", default-features = false, features = ["client", "server", "http1", "http2"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["http2"] } log = "0.4" num-format = "0.4" once_cell = "1.18.0" rand = "0.9.2" regex = "1.11" -reqwest = { version = "0.12", features = ["json", "stream", "multipart", "rustls-tls", "rustls-tls-native-roots"] } -serde = { version = "1.0", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } # Reduced +serde = { version = "1.0", default-features = false, features = ["derive", "std"] } serde_json = "1.0" toml = "0.8" sha2 = "0.10.9" sha1 = "0.10.6" -tokio = { version = "1.41", features = ["full"] } +tokio = { version = "1.41", default-features = false, features = ["rt", "sync", "time", "macros", "net"] } tokio-stream = "0.1" tower = "0.4" -tower-http = { version = "0.5", features = ["cors", "fs", "trace"] } +tower-http = { version = "0.5", default-features = false, features = ["cors", "fs"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["fmt"] } +tracing-subscriber = { version = "0.3", default-features = false } urlencoding = "2.1" -uuid = { version = "1.11", features = ["serde", "v4", "v5"] } +uuid = { version = "1.11", default-features = false, features = ["v4"] } # === TLS/SECURITY DEPENDENCIES === rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } tokio-rustls = "0.26" -rcgen = { version = "0.14", features = ["pem"] } +rcgen = { version = "0.14", default-features = false } x509-parser = "0.15" rustls-native-certs = "0.8" webpki-roots = "0.25" ring = "0.17" ciborium = "0.2" -time = { version = "0.3", features = ["formatting", "parsing"] } +time = { version = "0.3", default-features = false, features = ["formatting"] } jsonwebtoken = "9.3" tower-cookies = "0.10" @@ -176,7 +166,7 @@ tower-cookies = "0.10" # Email Integration (mail feature) imap = { version = "3.0.0-alpha.15", optional = true } -lettre = { version = "0.11", features = ["smtp-transport", "builder", "tokio1", "tokio1-native-tls"], optional = true } +lettre = { version = "0.11", default-features = false, optional = true } mailparse = { version = "0.15", optional = true } native-tls = { version = "0.2", optional = true } @@ -186,29 +176,30 @@ livekit = { version = "0.7", optional = true } # Vector Database (vectordb feature) qdrant-client = { version = "1.12", optional = true } -# Document Processing (paper, docs, sheet, slides features) +# Document Processing - PICK ONE PER FORMAT! docx-rs = { version = "0.4", optional = true } -ooxmlsdk = { version = "0.3", features = ["docx", "pptx", "parts", "office2021"], optional = true } -umya-spreadsheet = { version = "2.3", optional = true } +ooxmlsdk = { version = "0.3", default-features = false, optional = true } +# umya-spreadsheet = { version = "2.3", optional = true } # REMOVE - pick one calamine = { version = "0.26", optional = true } -rust_xlsxwriter = { version = "0.79", optional = true } +# rust_xlsxwriter = { version = "0.79", optional = true } # REMOVE - pick one spreadsheet-ods = { version = "1.0", optional = true } # File Storage & Drive (drive feature) -aws-config = { version = "1.8.8", features = ["behavior-version-latest"], optional = true } -aws-sdk-s3 = { version = "1.109.0", features = ["behavior-version-latest"], optional = true } +aws-config = { version = "1.8.8", default-features = false, optional = true } +aws-sdk-s3 = { version = "1.109.0", default-features = false, optional = true } pdf-extract = { version = "0.10.0", optional = true } -quick-xml = { version = "0.37", features = ["serialize"] } -zip = { version = "2.2", optional = true } -downloader = { version = "0.2", optional = true } +quick-xml = { version = "0.37", default-features = false } +# zip = { version = "2.2", optional = true } # Only if needed +# downloader = { version = "0.2", optional = true } # Use reqwest instead flate2 = { version = "1.0", optional = true } -tar = { version = "0.4", optional = true } +# tar = { version = "0.4", optional = true } # Only if needed # Task Management (tasks feature) cron = { version = "0.15.0", optional = true } # Automation & Scripting (automation feature) -rhai = { git = "https://github.com/therealprof/rhai.git", branch = "features/use-web-time", features = ["sync"], optional = true } +# REPLACE git with version +rhai = { version = "1.23", features = ["sync"], optional = true } # Compliance & Reporting (compliance feature) csv = { version = "1.3", optional = true } @@ -225,13 +216,13 @@ qrcode = { version = "0.14", default-features = false } thiserror = "2.0" # Caching/Sessions (cache feature) -redis = { version = "0.27", features = ["tokio-comp"], optional = true } +redis = { version = "0.27", default-features = false, features = ["tokio-comp"], optional = true } # System Monitoring (monitoring feature) sysinfo = { version = "0.37.2", optional = true } # Networking/gRPC (grpc feature) -tonic = { version = "0.14.2", features = ["transport"], optional = true } +tonic = { version = "0.14.2", default-features = false, features = ["transport", "tls"], optional = true } # UI Enhancement (progress-bars feature) indicatif = { version = "0.18.0", optional = true } @@ -239,7 +230,7 @@ smartstring = "1.0.1" # Memory allocator (jemalloc feature) tikv-jemallocator = { version = "0.6", optional = true } -tikv-jemalloc-ctl = { version = "0.6", features = ["stats"], optional = true } +tikv-jemalloc-ctl = { version = "0.6", default-features = false, optional = true } scopeguard = "1.2.0" # Vault secrets management @@ -249,7 +240,7 @@ vaultrs = "0.7" icalendar = "0.17" # Layered configuration -figment = { version = "0.10", features = ["toml", "env", "json"] } +figment = { version = "0.10", default-features = false, features = ["toml"] } # Rate limiting governor = "0.10" @@ -261,15 +252,26 @@ rss = "2.0" scraper = "0.25" walkdir = "2.5.0" -# Embedded static files (UI fallback when no external folder) +# Embedded static files rust-embed = "8.5" mime_guess = "2.0" -hyper-util = { version = "0.1.19", features = ["client-legacy", "tokio"] } +hyper-util = { version = "0.1.19", default-features = false, features = ["client-legacy"] } http-body-util = "0.1.3" [dev-dependencies] mockito = "1.7.0" tempfile = "3" +[profile.dev] +opt-level = 1 # Slightly optimized debug builds +split-debuginfo = "unpacked" +incremental = true + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +panic = "abort" + [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/README.md b/README.md index 883d17e07..b19cbe12f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ git clone https://github.com/GeneralBots/botserver cd botserver cargo run ``` +cargo install sccache +sudo apt-get install mold # or build from source On first run, botserver automatically sets up PostgreSQL, S3 storage, Redis cache, and downloads AI models. @@ -152,4 +154,4 @@ According to our dual licensing model, this program can be used either under the **General Bots Code Name:** [Guaribas](https://en.wikipedia.org/wiki/Guaribas) -> "No one should have to do work that can be done by a machine." - Roberto Mangabeira Unger \ No newline at end of file +> "No one should have to do work that can be done by a machine." - Roberto Mangabeira Unger diff --git a/src/basic/keywords/import_export.rs b/src/basic/keywords/import_export.rs index 91310c00a..f626247ff 100644 --- a/src/basic/keywords/import_export.rs +++ b/src/basic/keywords/import_export.rs @@ -1,33 +1,3 @@ -/*****************************************************************************\ -| █████ █████ ██ █ █████ █████ ████ ██ ████ █████ █████ ███ ® | -| ██ █ ███ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ | -| ██ ███ ████ █ ██ █ ████ █████ ██████ ██ ████ █ █ █ ██ | -| ██ ██ █ █ ██ █ █ ██ ██ ██ ██ ██ ██ █ ██ ██ █ █ | -| █████ █████ █ ███ █████ ██ ██ ██ ██ █████ ████ █████ █ ███ | -| | -| General Bots Copyright (c) pragmatismo.com.br. All rights reserved. | -| Licensed under the AGPL-3.0. | -| | -| According to our dual licensing model, this program can be used either | -| under the terms of the GNU Affero General Public License, version 3, | -| or under a proprietary license. | -| | -| The texts of the GNU Affero General Public License with an additional | -| permission and of our proprietary license can be found at and | -| in the LICENSE file you have received along with this program. | -| | -| This program is distributed in the hope that it will be useful, | -| but WITHOUT ANY WARRANTY, without even the implied warranty of | -| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | -| GNU Affero General Public License for more details. | -| | -| "General Bots" is a registered trademark of pragmatismo.com.br. | -| The licensing of the program under the AGPLv3 does not imply a | -| trademark license. Therefore any rights, title and interest in | -| our trademarks remain entirely with us. | -| | -\*****************************************************************************/ - use crate::shared::models::UserSession; use crate::shared::state::AppState; use log::{error, trace}; @@ -46,7 +16,6 @@ pub fn register_import_export(state: Arc, user: UserSession, engine: & pub fn register_import_keyword(state: Arc, user: UserSession, engine: &mut Engine) { let state_clone = Arc::clone(&state); - engine .register_custom_syntax(["IMPORT", "$expr$"], false, move |context, inputs| { let file_path = context.eval_expression_tree(&inputs[0])?.to_string(); @@ -205,7 +174,16 @@ fn execute_import( match extension.as_str() { "csv" => import_csv(&full_path), "json" => import_json(&full_path), - "xlsx" | "xls" => import_excel(&full_path), + "xlsx" | "xls" => { + #[cfg(feature = "sheet")] + { + import_excel(&full_path) + } + #[cfg(not(feature = "sheet"))] + { + Err(format!("Excel import requires 'sheet' feature. File: {}", file_path).into()) + } + } "tsv" => import_tsv(&full_path), _ => Err(format!("Unsupported file format: .{}", extension).into()), } @@ -227,7 +205,16 @@ fn execute_export( match extension.as_str() { "csv" => export_csv(&full_path, data), "json" => export_json(&full_path, data), - "xlsx" => export_excel(&full_path, data), + "xlsx" => { + #[cfg(feature = "sheet")] + { + export_excel(&full_path, data) + } + #[cfg(not(feature = "sheet"))] + { + Err(format!("Excel export requires 'sheet' feature. File: {}", file_path).into()) + } + } "tsv" => export_tsv(&full_path, data), _ => Err(format!("Unsupported export format: .{}", extension).into()), } @@ -361,6 +348,7 @@ fn import_json(file_path: &str) -> Result Result> { use calamine::{open_workbook, Reader, Xlsx}; @@ -474,6 +462,7 @@ fn export_json( Ok(file_path.to_string()) } +#[cfg(feature = "sheet")] fn export_excel( file_path: &str, data: Dynamic, @@ -534,7 +523,7 @@ fn parse_csv_line(line: &str) -> Vec { fn escape_csv_value(value: &str) -> String { if value.contains(',') || value.contains('"') || value.contains('\n') { - format!("\"{}\"", value.replace('"', "\"\"")) + format!("{}", value.replace('"', "")) } else { value.to_string() } diff --git a/src/billing/alerts.rs b/src/billing/alerts.rs index b4150dac2..2b13438aa 100644 --- a/src/billing/alerts.rs +++ b/src/billing/alerts.rs @@ -4,6 +4,7 @@ //! Supports multiple notification channels: email, webhook, in-app, SMS. use crate::billing::UsageMetric; +use crate::core::shared::state::BillingAlertNotification; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -18,86 +19,88 @@ use uuid::Uuid; /// Alert thresholds configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlertThresholds { - /// Warning threshold (default: 80%) - pub warning: f64, - /// Critical threshold (default: 90%) - pub critical: f64, - /// Exceeded threshold (default: 100%) - pub exceeded: f64, +/// Warning threshold (default: 80%) +pub warning: f64, +/// Critical threshold (default: 90%) +pub critical: f64, +/// Exceeded threshold (default: 100%) +pub exceeded: f64, } impl Default for AlertThresholds { - fn default() -> Self { - Self { - warning: 80.0, - critical: 90.0, - exceeded: 100.0, - } - } +fn default() -> Self { +Self { +warning: 80.0, +critical: 90.0, +exceeded: 100.0, +} +} } impl AlertThresholds { - pub fn new(warning: f64, critical: f64, exceeded: f64) -> Self { - Self { - warning, - critical, - exceeded, - } - } +pub fn new(warning: f64, critical: f64, exceeded: f64) -> Self { +Self { +warning, +critical, +exceeded, +} +} + +/// Get the severity level for a given percentage +pub fn get_severity(&self, percentage: f64) -> Option { +if percentage >= self.exceeded { +Some(AlertSeverity::Exceeded) +} else if percentage >= self.critical { +Some(AlertSeverity::Critical) +} else if percentage >= self.warning { +Some(AlertSeverity::Warning) +} else { +None +} +} - /// Get the severity level for a given percentage - pub fn get_severity(&self, percentage: f64) -> Option { - if percentage >= self.exceeded { - Some(AlertSeverity::Exceeded) - } else if percentage >= self.critical { - Some(AlertSeverity::Critical) - } else if percentage >= self.warning { - Some(AlertSeverity::Warning) - } else { - None - } - } } /// Alert severity levels -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AlertSeverity { - Warning, - Critical, - Exceeded, +Warning, +Critical, +Exceeded, } impl AlertSeverity { - pub fn as_str(&self) -> &'static str { - match self { - Self::Warning => "warning", - Self::Critical => "critical", - Self::Exceeded => "exceeded", - } - } +pub fn as_str(&self) -> &'static str { +match self { +Self::Warning => "warning", +Self::Critical => "critical", +Self::Exceeded => "exceeded", +} +} - pub fn emoji(&self) -> &'static str { - match self { - Self::Warning => "⚠️", - Self::Critical => "🚨", - Self::Exceeded => "🛑", - } - } +pub fn emoji(&self) -> &'static str { +match self { +Self::Warning => "⚠️", +Self::Critical => "🚨", +Self::Exceeded => "🛑", +} +} + +pub fn priority(&self) -> u8 { +match self { +Self::Warning => 1, +Self::Critical => 2, +Self::Exceeded => 3, +} +} - pub fn priority(&self) -> u8 { - match self { - Self::Warning => 1, - Self::Critical => 2, - Self::Exceeded => 3, - } - } } impl std::fmt::Display for AlertSeverity { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } +fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +write!(f, "{}", self.as_str()) +} } // ============================================================================ @@ -107,124 +110,128 @@ impl std::fmt::Display for AlertSeverity { /// Usage alert data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UsageAlert { - pub id: Uuid, - pub organization_id: Uuid, - pub metric: UsageMetric, - pub severity: AlertSeverity, - pub current_usage: u64, - pub limit: u64, - pub percentage: f64, - pub threshold: f64, - pub message: String, - pub created_at: DateTime, - pub acknowledged_at: Option>, - pub acknowledged_by: Option, - pub notification_sent: bool, - pub notification_channels: Vec, +pub id: Uuid, +pub organization_id: Uuid, +pub metric: UsageMetric, +pub severity: AlertSeverity, +pub current_usage: u64, +pub limit: u64, +pub percentage: f64, +pub threshold: f64, +pub message: String, +pub created_at: DateTime, +pub acknowledged_at: Option>, +pub acknowledged_by: Option, +pub notification_sent: bool, +pub notification_channels: Vec, } impl UsageAlert { - pub fn new( - organization_id: Uuid, - metric: UsageMetric, - severity: AlertSeverity, - current_usage: u64, - limit: u64, - percentage: f64, - threshold: f64, - ) -> Self { - let message = Self::generate_message(metric, severity, percentage, current_usage, limit); +pub fn new( +organization_id: Uuid, +metric: UsageMetric, +severity: AlertSeverity, +current_usage: u64, +limit: u64, +percentage: f64, +threshold: f64, +) -> Self { +let severity_clone = severity.clone(); +let message = Self::generate_message(metric, severity, percentage, current_usage, limit); - Self { - id: Uuid::new_v4(), - organization_id, - metric, - severity, - current_usage, - limit, - percentage, - threshold, - message, - created_at: Utc::now(), - acknowledged_at: None, - acknowledged_by: None, - notification_sent: false, - notification_channels: Vec::new(), - } - } +Self { + id: Uuid::new_v4(), + organization_id, + metric, + severity: severity_clone, + current_usage, + limit, + percentage, + threshold, + message, + created_at: Utc::now(), + acknowledged_at: None, + acknowledged_by: None, + notification_sent: false, + notification_channels: Vec::new(), +} - fn generate_message( - metric: UsageMetric, - severity: AlertSeverity, - percentage: f64, - current: u64, - limit: u64, - ) -> String { - let metric_name = metric.display_name(); - let severity_text = match severity { - AlertSeverity::Warning => "approaching limit", - AlertSeverity::Critical => "near limit", - AlertSeverity::Exceeded => "exceeded limit", - }; +} - format!( - "{} {} usage is {} ({:.1}% - {}/{})", - severity.emoji(), - metric_name, - severity_text, - percentage, - Self::format_value(metric, current), - Self::format_value(metric, limit) - ) - } +fn generate_message( +metric: UsageMetric, +severity: AlertSeverity, +percentage: f64, +current: u64, +limit: u64, +) -> String { +let metric_name = metric.display_name(); +let severity_ = match severity { +AlertSeverity::Warning => "approaching limit", +AlertSeverity::Critical => "near limit", +AlertSeverity::Exceeded => "exceeded limit", +}; - fn format_value(metric: UsageMetric, value: u64) -> String { - match metric { - UsageMetric::StorageBytes => format_bytes(value), - _ => format_number(value), - } - } +format!( + "{} {} usage is {} ({:.1}% - {}/{})", + severity.emoji(), + metric_name, + severity_, + percentage, + Self::format_value(metric, current), + Self::format_value(metric, limit) +) - pub fn acknowledge(&mut self, user_id: Uuid) { - self.acknowledged_at = Some(Utc::now()); - self.acknowledged_by = Some(user_id); - } +} - pub fn is_acknowledged(&self) -> bool { - self.acknowledged_at.is_some() - } +fn format_value(metric: UsageMetric, value: u64) -> String { +match metric { +UsageMetric::StorageBytes => format_bytes(value), +_ => format_number(value), +} +} + +pub fn acknowledge(&mut self, user_id: Uuid) { +self.acknowledged_at = Some(Utc::now()); +self.acknowledged_by = Some(user_id); +} + +pub fn is_acknowledged(&self) -> bool { +self.acknowledged_at.is_some() +} + +pub fn mark_notified(&mut self, channels: Vec) { +self.notification_sent = true; +self.notification_channels = channels; +} - pub fn mark_notified(&mut self, channels: Vec) { - self.notification_sent = true; - self.notification_channels = channels; - } } /// Notification delivery channels -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NotificationChannel { - Email, - Webhook, - InApp, - Sms, - Slack, - MsTeams, - Push, +Email, +Webhook, +InApp, +Sms, +Slack, +MsTeams, +Push, } impl NotificationChannel { - pub fn as_str(&self) -> &'static str { - match self { - Self::Email => "email", - Self::Webhook => "webhook", - Self::InApp => "in_app", - Self::Sms => "sms", - Self::Slack => "slack", - Self::MsTeams => "ms_teams", - Self::Push => "push", - } - } +pub fn as_str(&self) -> &'static str { +match self { +Self::Email => "email", +Self::Webhook => "webhook", +Self::InApp => "in_app", +Self::Sms => "sms", +Self::Slack => "slack", +Self::MsTeams => "ms_teams", +Self::Push => "push", +} +} } // ============================================================================ @@ -233,312 +240,323 @@ impl NotificationChannel { /// Manages usage alerts and notifications pub struct AlertManager { - /// Active alerts by organization - active_alerts: Arc>>>, - /// Alert history (last N alerts per org) - alert_history: Arc>>>, - /// Notification preferences per organization - notification_prefs: Arc>>, - /// Alert thresholds - thresholds: AlertThresholds, - /// Cooldown between same alerts (in seconds) - cooldown_seconds: u64, - /// Max alerts in history per org - max_history_per_org: usize, - /// Notification handlers - notification_handlers: Arc>>>, +/// Active alerts by organization +active_alerts: Arc>>>, +/// Alert history (last N alerts per org) +alert_history: Arc>>>, +/// Notification preferences per organization +notification_prefs: Arc>>, +/// Alert thresholds +thresholds: AlertThresholds, +/// Cooldown between same alerts (in seconds) +cooldown_seconds: u64, +/// Max alerts in history per org +max_history_per_org: usize, +/// Notification handlers +notification_handlers: Arc>>>, } impl AlertManager { - pub fn new() -> Self { - Self { - active_alerts: Arc::new(RwLock::new(HashMap::new())), - alert_history: Arc::new(RwLock::new(HashMap::new())), - notification_prefs: Arc::new(RwLock::new(HashMap::new())), - thresholds: AlertThresholds::default(), - cooldown_seconds: 3600, // 1 hour cooldown - max_history_per_org: 100, - notification_handlers: Arc::new(RwLock::new(Vec::new())), +pub fn new() -> Self { +Self { +active_alerts: Arc::new(RwLock::new(HashMap::new())), +alert_history: Arc::new(RwLock::new(HashMap::new())), +notification_prefs: Arc::new(RwLock::new(HashMap::new())), +thresholds: AlertThresholds::default(), +cooldown_seconds: 3600, // 1 hour cooldown +max_history_per_org: 100, +notification_handlers: Arc::new(RwLock::new(Vec::new())), +} +} + +pub fn with_thresholds(mut self, thresholds: AlertThresholds) -> Self { +self.thresholds = thresholds; +self +} + +pub fn with_cooldown(mut self, seconds: u64) -> Self { +self.cooldown_seconds = seconds; +self +} + +/// Register a notification handler +pub async fn register_handler(&self, handler: Arc) { +let mut handlers = self.notification_handlers.write().await; +handlers.push(handler); +} + +/// Set notification preferences for an organization +pub async fn set_notification_preferences( +&self, +org_id: Uuid, +prefs: NotificationPreferences, +) { +let mut all_prefs = self.notification_prefs.write().await; +all_prefs.insert(org_id, prefs); +} + +/// Get notification preferences for an organization +pub async fn get_notification_preferences( +&self, +org_id: Uuid, +) -> NotificationPreferences { +let prefs = self.notification_prefs.read().await; +prefs.get(&org_id).cloned().unwrap_or_default() +} + +/// Check usage and generate alerts if thresholds are crossed +pub async fn check_and_alert( +&self, +org_id: Uuid, +metric: UsageMetric, +current_usage: u64, +limit: u64, +) -> Option { +if limit == 0 { +return None; +} + +let percentage = (current_usage as f64 / limit as f64) * 100.0; +let severity = self.thresholds.get_severity(percentage)?; +let threshold = match severity { + AlertSeverity::Warning => self.thresholds.warning, + AlertSeverity::Critical => self.thresholds.critical, + AlertSeverity::Exceeded => self.thresholds.exceeded, +}; + +// Check cooldown +if self.is_in_cooldown(org_id, metric, severity.clone()).await { + return None; +} + +// Create alert +let alert = UsageAlert::new( + org_id, + metric, + severity, + current_usage, + limit, + percentage, + threshold, +); + +// Store alert +self.store_alert(org_id, alert.clone()).await; + +// Send notifications +self.send_notifications(org_id, &alert).await; + +Some(alert) + +} + +/// Check multiple metrics at once +pub async fn check_all_metrics( +&self, +org_id: Uuid, +usage: &UsageSnapshot, +) -> Vec { +let mut alerts = Vec::new(); + +for (metric, current, limit) in usage.iter_metrics() { + if let Some(alert) = self.check_and_alert(org_id, metric, current, limit).await { + alerts.push(alert); + } +} + +alerts + +} + +/// Get active alerts for an organization +pub async fn get_active_alerts(&self, org_id: Uuid) -> Vec { +let alerts = self.active_alerts.read().await; +alerts.get(&org_id).cloned().unwrap_or_default() +} + +/// Get alert history for an organization +pub async fn get_alert_history( +&self, +org_id: Uuid, +limit: Option, +) -> Vec { +let history = self.alert_history.read().await; +let mut alerts = history.get(&org_id).cloned().unwrap_or_default(); + +if let Some(limit) = limit { + alerts.truncate(limit); +} + +alerts + +} + +/// Acknowledge an alert +pub async fn acknowledge_alert( +&self, +org_id: Uuid, +alert_id: Uuid, +user_id: Uuid, +) -> Result<(), AlertError> { +let mut alerts = self.active_alerts.write().await; +let org_alerts = alerts.get_mut(&org_id).ok_or(AlertError::NotFound)?; + +let alert = org_alerts + .iter_mut() + .find(|a| a.id == alert_id) + .ok_or(AlertError::NotFound)?; + +alert.acknowledge(user_id); + +Ok(()) + +} + +/// Dismiss an alert +pub async fn dismiss_alert( +&self, +org_id: Uuid, +alert_id: Uuid, +) -> Result { +let mut alerts = self.active_alerts.write().await; +let org_alerts = alerts.get_mut(&org_id).ok_or(AlertError::NotFound)?; + +let index = org_alerts + .iter() + .position(|a| a.id == alert_id) + .ok_or(AlertError::NotFound)?; + +let alert = org_alerts.remove(index); + +// Move to history +self.add_to_history(org_id, alert.clone()).await; + +Ok(alert) + +} + +/// Clear all alerts for an organization +pub async fn clear_alerts(&self, org_id: Uuid) { +let mut alerts = self.active_alerts.write().await; +if let Some(org_alerts) = alerts.remove(&org_id) { +// Move all to history +for alert in org_alerts { +self.add_to_history(org_id, alert).await; +} +} +} + +/// Get alert count by severity +pub async fn get_alert_counts(&self, org_id: Uuid) -> AlertCounts { +let alerts = self.active_alerts.read().await; +let org_alerts = alerts.get(&org_id); + +let mut counts = AlertCounts::default(); + +if let Some(alerts) = org_alerts { + for alert in alerts { + match alert.severity { + AlertSeverity::Warning => counts.warning += 1, + AlertSeverity::Critical => counts.critical += 1, + AlertSeverity::Exceeded => counts.exceeded += 1, } + counts.total += 1; } +} - pub fn with_thresholds(mut self, thresholds: AlertThresholds) -> Self { - self.thresholds = thresholds; - self - } +counts - pub fn with_cooldown(mut self, seconds: u64) -> Self { - self.cooldown_seconds = seconds; - self - } +} - /// Register a notification handler - pub async fn register_handler(&self, handler: Arc) { - let mut handlers = self.notification_handlers.write().await; - handlers.push(handler); - } +// ======================================================================== +// Private Methods +// ======================================================================== - /// Set notification preferences for an organization - pub async fn set_notification_preferences( - &self, - org_id: Uuid, - prefs: NotificationPreferences, - ) { - let mut all_prefs = self.notification_prefs.write().await; - all_prefs.insert(org_id, prefs); - } +async fn is_in_cooldown( +&self, +org_id: Uuid, +metric: UsageMetric, +severity: AlertSeverity, +) -> bool { +let alerts = self.active_alerts.read().await; +let org_alerts = match alerts.get(&org_id) { +Some(a) => a, +None => return false, +}; - /// Get notification preferences for an organization - pub async fn get_notification_preferences( - &self, - org_id: Uuid, - ) -> NotificationPreferences { - let prefs = self.notification_prefs.read().await; - prefs.get(&org_id).cloned().unwrap_or_default() - } +let cooldown_threshold = Utc::now() + - chrono::Duration::seconds(self.cooldown_seconds as i64); - /// Check usage and generate alerts if thresholds are crossed - pub async fn check_and_alert( - &self, - org_id: Uuid, - metric: UsageMetric, - current_usage: u64, - limit: u64, - ) -> Option { - if limit == 0 { - return None; - } +org_alerts.iter().any(|alert| { + alert.metric == metric + && alert.severity == severity + && alert.created_at > cooldown_threshold +}) - let percentage = (current_usage as f64 / limit as f64) * 100.0; - let severity = self.thresholds.get_severity(percentage)?; - let threshold = match severity { - AlertSeverity::Warning => self.thresholds.warning, - AlertSeverity::Critical => self.thresholds.critical, - AlertSeverity::Exceeded => self.thresholds.exceeded, - }; +} - // Check cooldown - if self.is_in_cooldown(org_id, metric, severity).await { - return None; - } +async fn store_alert(&self, org_id: Uuid, alert: UsageAlert) { +let mut alerts = self.active_alerts.write().await; +let org_alerts = alerts.entry(org_id).or_insert_with(Vec::new); - // Create alert - let alert = UsageAlert::new( - org_id, - metric, - severity, - current_usage, - limit, - percentage, - threshold, - ); +// Remove any existing alert for the same metric with lower severity +org_alerts.retain(|a| { + a.metric != alert.metric || a.severity.priority() >= alert.severity.priority() +}); - // Store alert - self.store_alert(org_id, alert.clone()).await; +org_alerts.push(alert); - // Send notifications - self.send_notifications(org_id, &alert).await; +} - Some(alert) - } +async fn add_to_history(&self, org_id: Uuid, alert: UsageAlert) { +let mut history = self.alert_history.write().await; +let org_history = history.entry(org_id).or_insert_with(Vec::new); - /// Check multiple metrics at once - pub async fn check_all_metrics( - &self, - org_id: Uuid, - usage: &UsageSnapshot, - ) -> Vec { - let mut alerts = Vec::new(); +org_history.insert(0, alert); - for (metric, current, limit) in usage.iter_metrics() { - if let Some(alert) = self.check_and_alert(org_id, metric, current, limit).await { - alerts.push(alert); - } - } +// Trim history +if org_history.len() > self.max_history_per_org { + org_history.truncate(self.max_history_per_org); +} - alerts - } +} - /// Get active alerts for an organization - pub async fn get_active_alerts(&self, org_id: Uuid) -> Vec { - let alerts = self.active_alerts.read().await; - alerts.get(&org_id).cloned().unwrap_or_default() - } +async fn send_notifications(&self, org_id: Uuid, alert: &UsageAlert) { +let prefs = self.get_notification_preferences(org_id).await; - /// Get alert history for an organization - pub async fn get_alert_history( - &self, - org_id: Uuid, - limit: Option, - ) -> Vec { - let history = self.alert_history.read().await; - let mut alerts = history.get(&org_id).cloned().unwrap_or_default(); +if !prefs.enabled { + return; +} - if let Some(limit) = limit { - alerts.truncate(limit); - } +// Check if this severity should be notified +if !prefs.should_notify(alert.severity.clone()) { + return; +} - alerts - } +let handlers = self.notification_handlers.read().await; +let notification = AlertNotification::from_alert(alert, &prefs); - /// Acknowledge an alert - pub async fn acknowledge_alert( - &self, - org_id: Uuid, - alert_id: Uuid, - user_id: Uuid, - ) -> Result<(), AlertError> { - let mut alerts = self.active_alerts.write().await; - let org_alerts = alerts.get_mut(&org_id).ok_or(AlertError::NotFound)?; - - let alert = org_alerts - .iter_mut() - .find(|a| a.id == alert_id) - .ok_or(AlertError::NotFound)?; - - alert.acknowledge(user_id); - - Ok(()) - } - - /// Dismiss an alert - pub async fn dismiss_alert( - &self, - org_id: Uuid, - alert_id: Uuid, - ) -> Result { - let mut alerts = self.active_alerts.write().await; - let org_alerts = alerts.get_mut(&org_id).ok_or(AlertError::NotFound)?; - - let index = org_alerts - .iter() - .position(|a| a.id == alert_id) - .ok_or(AlertError::NotFound)?; - - let alert = org_alerts.remove(index); - - // Move to history - self.add_to_history(org_id, alert.clone()).await; - - Ok(alert) - } - - /// Clear all alerts for an organization - pub async fn clear_alerts(&self, org_id: Uuid) { - let mut alerts = self.active_alerts.write().await; - if let Some(org_alerts) = alerts.remove(&org_id) { - // Move all to history - for alert in org_alerts { - self.add_to_history(org_id, alert).await; - } - } - } - - /// Get alert count by severity - pub async fn get_alert_counts(&self, org_id: Uuid) -> AlertCounts { - let alerts = self.active_alerts.read().await; - let org_alerts = alerts.get(&org_id); - - let mut counts = AlertCounts::default(); - - if let Some(alerts) = org_alerts { - for alert in alerts { - match alert.severity { - AlertSeverity::Warning => counts.warning += 1, - AlertSeverity::Critical => counts.critical += 1, - AlertSeverity::Exceeded => counts.exceeded += 1, - } - counts.total += 1; - } - } - - counts - } - - // ======================================================================== - // Private Methods - // ======================================================================== - - async fn is_in_cooldown( - &self, - org_id: Uuid, - metric: UsageMetric, - severity: AlertSeverity, - ) -> bool { - let alerts = self.active_alerts.read().await; - let org_alerts = match alerts.get(&org_id) { - Some(a) => a, - None => return false, - }; - - let cooldown_threshold = Utc::now() - - chrono::Duration::seconds(self.cooldown_seconds as i64); - - org_alerts.iter().any(|alert| { - alert.metric == metric - && alert.severity == severity - && alert.created_at > cooldown_threshold - }) - } - - async fn store_alert(&self, org_id: Uuid, alert: UsageAlert) { - let mut alerts = self.active_alerts.write().await; - let org_alerts = alerts.entry(org_id).or_insert_with(Vec::new); - - // Remove any existing alert for the same metric with lower severity - org_alerts.retain(|a| { - a.metric != alert.metric || a.severity.priority() >= alert.severity.priority() - }); - - org_alerts.push(alert); - } - - async fn add_to_history(&self, org_id: Uuid, alert: UsageAlert) { - let mut history = self.alert_history.write().await; - let org_history = history.entry(org_id).or_insert_with(Vec::new); - - org_history.insert(0, alert); - - // Trim history - if org_history.len() > self.max_history_per_org { - org_history.truncate(self.max_history_per_org); - } - } - - async fn send_notifications(&self, org_id: Uuid, alert: &UsageAlert) { - let prefs = self.get_notification_preferences(org_id).await; - - if !prefs.enabled { - return; - } - - // Check if this severity should be notified - if !prefs.should_notify(alert.severity) { - return; - } - - let handlers = self.notification_handlers.read().await; - let notification = AlertNotification::from_alert(alert, &prefs); - - for handler in handlers.iter() { - if prefs.channels.contains(&handler.channel()) { - if let Err(e) = handler.send(¬ification).await { - tracing::warn!( - "Failed to send {} notification for org {}: {}", - handler.channel().as_str(), - org_id, - e - ); - } - } +for handler in handlers.iter() { + if prefs.channels.contains(&handler.channel()) { + if let Err(e) = handler.send(¬ification).await { + tracing::warn!( + "Failed to send {} notification for org {}: {}", + handler.channel().as_str(), + org_id, + e + ); } } } +} + +} + impl Default for AlertManager { - fn default() -> Self { - Self::new() - } +fn default() -> Self { +Self::new() +} } // ============================================================================ @@ -548,81 +566,84 @@ impl Default for AlertManager { /// Organization notification preferences #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationPreferences { - pub enabled: bool, - pub channels: Vec, - pub email_recipients: Vec, - pub webhook_url: Option, - pub webhook_secret: Option, - pub slack_webhook_url: Option, - pub teams_webhook_url: Option, - pub sms_numbers: Vec, - pub min_severity: AlertSeverity, - pub quiet_hours: Option, - pub metric_overrides: HashMap, +pub enabled: bool, +pub channels: Vec, +pub email_recipients: Vec, +pub webhook_url: Option, +pub webhook_secret: Option, +pub slack_webhook_url: Option, +pub teams_webhook_url: Option, +pub sms_numbers: Vec, +pub min_severity: AlertSeverity, +pub quiet_hours: Option, +pub metric_overrides: HashMap, } impl Default for NotificationPreferences { - fn default() -> Self { - Self { - enabled: true, - channels: vec![NotificationChannel::Email, NotificationChannel::InApp], - email_recipients: Vec::new(), - webhook_url: None, - webhook_secret: None, - slack_webhook_url: None, - teams_webhook_url: None, - sms_numbers: Vec::new(), - min_severity: AlertSeverity::Warning, - quiet_hours: None, - metric_overrides: HashMap::new(), - } - } +fn default() -> Self { +Self { +enabled: true, +channels: vec![NotificationChannel::Email, NotificationChannel::InApp], +email_recipients: Vec::new(), +webhook_url: None, +webhook_secret: None, +slack_webhook_url: None, +teams_webhook_url: None, +sms_numbers: Vec::new(), +min_severity: AlertSeverity::Warning, +quiet_hours: None, +metric_overrides: HashMap::new(), +} +} } impl NotificationPreferences { - pub fn should_notify(&self, severity: AlertSeverity) -> bool { - severity.priority() >= self.min_severity.priority() - } +pub fn should_notify(&self, severity: AlertSeverity) -> bool { +severity.priority() >= self.min_severity.priority() +} + +pub fn is_in_quiet_hours(&self) -> bool { +if let Some(quiet) = &self.quiet_hours { +quiet.is_active() +} else { +false +} +} - pub fn is_in_quiet_hours(&self) -> bool { - if let Some(quiet) = &self.quiet_hours { - quiet.is_active() - } else { - false - } - } } /// Quiet hours configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuietHours { - pub start_hour: u8, - pub end_hour: u8, - pub timezone: String, - pub days: Vec, +pub start_hour: u8, +pub end_hour: u8, +pub timezone: String, +pub days: Vecchrono::Weekday } impl QuietHours { - pub fn is_active(&self) -> bool { - // Simplified check - in production, use proper timezone handling - let now = Utc::now(); - let hour = now.format("%H").to_string().parse::().unwrap_or(0); +pub fn is_active(&self) -> bool { +// Simplified check - in production, use proper timezone handling +let now = Utc::now(); +let hour = now.format("%H").to_string().parse::().unwrap_or(0); + +if self.start_hour < self.end_hour { + hour >= self.start_hour && hour < self.end_hour +} else { + // Overnight quiet hours + hour >= self.start_hour || hour < self.end_hour +} + +} - if self.start_hour < self.end_hour { - hour >= self.start_hour && hour < self.end_hour - } else { - // Overnight quiet hours - hour >= self.start_hour || hour < self.end_hour - } - } } /// Per-metric notification override #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetricNotificationOverride { - pub enabled: bool, - pub min_severity: Option, - pub channels: Option>, +pub enabled: bool, +pub min_severity: Option, +pub channels: Option>, } // ============================================================================ @@ -632,29 +653,29 @@ pub struct MetricNotificationOverride { /// Snapshot of current usage for batch alert checking #[derive(Debug, Clone)] pub struct UsageSnapshot { - pub messages: (u64, u64), // (current, limit) - pub storage_bytes: (u64, u64), - pub api_calls: (u64, u64), - pub bots: (u64, u64), - pub users: (u64, u64), - pub kb_documents: (u64, u64), - pub apps: (u64, u64), +pub messages: (u64, u64), // (current, limit) +pub storage_bytes: (u64, u64), +pub api_calls: (u64, u64), +pub bots: (u64, u64), +pub users: (u64, u64), +pub kb_documents: (u64, u64), +pub apps: (u64, u64), } impl UsageSnapshot { - pub fn iter_metrics(&self) -> impl Iterator { - vec![ - (UsageMetric::Messages, self.messages.0, self.messages.1), - (UsageMetric::StorageBytes, self.storage_bytes.0, self.storage_bytes.1), - (UsageMetric::ApiCalls, self.api_calls.0, self.api_calls.1), - (UsageMetric::Bots, self.bots.0, self.bots.1), - (UsageMetric::Users, self.users.0, self.users.1), - (UsageMetric::KbDocuments, self.kb_documents.0, self.kb_documents.1), - (UsageMetric::Apps, self.apps.0, self.apps.1), - ] - .into_iter() - .filter(|(_, _, limit)| *limit > 0) - } +pub fn iter_metrics(&self) -> impl Iterator { +vec![ +(UsageMetric::Messages, self.messages.0, self.messages.1), +(UsageMetric::StorageBytes, self.storage_bytes.0, self.storage_bytes.1), +(UsageMetric::ApiCalls, self.api_calls.0, self.api_calls.1), +(UsageMetric::Bots, self.bots.0, self.bots.1), +(UsageMetric::Users, self.users.0, self.users.1), +(UsageMetric::KbDocuments, self.kb_documents.0, self.kb_documents.1), +(UsageMetric::Apps, self.apps.0, self.apps.1), +] +.into_iter() +.filter(|(_, _, limit)| *limit > 0) +} } // ============================================================================ @@ -663,10 +684,10 @@ impl UsageSnapshot { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AlertCounts { - pub total: usize, - pub warning: usize, - pub critical: usize, - pub exceeded: usize, +pub total: usize, +pub warning: usize, +pub critical: usize, +pub exceeded: usize, } // ============================================================================ @@ -676,48 +697,48 @@ pub struct AlertCounts { /// Trait for notification delivery handlers #[async_trait::async_trait] pub trait NotificationHandler: Send + Sync { - fn channel(&self) -> NotificationChannel; - async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError>; +fn channel(&self) -> NotificationChannel; +async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError>; } /// Notification payload #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlertNotification { - pub alert_id: Uuid, - pub organization_id: Uuid, - pub severity: AlertSeverity, - pub title: String, - pub message: String, - pub metric: String, - pub current_usage: u64, - pub limit: u64, - pub percentage: f64, - pub action_url: Option, - pub created_at: DateTime, - pub recipients: Vec, +pub alert_id: Uuid, +pub organization_id: Uuid, +pub severity: AlertSeverity, +pub title: String, +pub message: String, +pub metric: String, +pub current_usage: u64, +pub limit: u64, +pub percentage: f64, +pub action_url: Option, +pub created_at: DateTime, +pub recipients: Vec, } impl AlertNotification { - pub fn from_alert(alert: &UsageAlert, prefs: &NotificationPreferences) -> Self { - Self { - alert_id: alert.id, - organization_id: alert.organization_id, - severity: alert.severity, - title: format!( - "{} Usage Alert: {}", - alert.severity.emoji(), - alert.metric.display_name() - ), - message: alert.message.clone(), - metric: alert.metric.display_name().to_string(), - current_usage: alert.current_usage, - limit: alert.limit, - percentage: alert.percentage, - action_url: Some(format!("/billing/usage?org={}", alert.organization_id)), - created_at: alert.created_at, - recipients: prefs.email_recipients.clone(), - } - } +pub fn from_alert(alert: &UsageAlert, prefs: &NotificationPreferences) -> Self { +Self { +alert_id: alert.id, +organization_id: alert.organization_id, +severity: alert.severity.clone(), +title: format!( +"{} Usage Alert: {}", +alert.severity.emoji(), +alert.metric.display_name() +), +message: alert.message.clone(), +metric: alert.metric.display_name().to_string(), +current_usage: alert.current_usage, +limit: alert.limit, +percentage: alert.percentage, +action_url: Some(format!("/billing/usage?org={}", alert.organization_id)), +created_at: alert.created_at, +recipients: prefs.email_recipients.clone(), +} +} } // ============================================================================ @@ -725,441 +746,209 @@ impl AlertNotification { // ============================================================================ /// Email notification handler +#[cfg(feature = "mail")] pub struct EmailNotificationHandler { - _smtp_host: String, - _smtp_port: u16, - _from_address: String, +_smtp_host: String, +_smtp_port: u16, +_from_address: String, } +#[cfg(feature = "mail")] impl EmailNotificationHandler { - pub fn new(smtp_host: String, smtp_port: u16, from_address: String) -> Self { - Self { - _smtp_host: smtp_host, - _smtp_port: smtp_port, - _from_address: from_address, - } - } +pub fn new(smtp_host: String, smtp_port: u16, from_address: String) -> Self { +Self { +_smtp_host: smtp_host, +_smtp_port: smtp_port, +_from_address: from_address, +} +} } +#[cfg(feature = "mail")] #[async_trait::async_trait] impl NotificationHandler for EmailNotificationHandler { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Email - } +fn channel(&self) -> NotificationChannel { +NotificationChannel::Email +} - async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { - use lettre::{Message, SmtpTransport, Transport}; - use lettre::transport::smtp::authentication::Credentials; +async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { +// Email functionality is only available when the mail feature is enabled +// This stub implementation prevents compilation errors when mail feature is disabled +tracing::warn!( +"Email notifications require the 'mail' feature to be enabled. Alert {} not sent.", +notification.alert_id +); +Ok(()) +} - tracing::info!( - "Sending email notification for alert {} to {:?}", - notification.alert_id, - notification.recipients - ); - - // Get SMTP config from environment - let smtp_host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string()); - let smtp_user = std::env::var("SMTP_USER").ok(); - let smtp_pass = std::env::var("SMTP_PASS").ok(); - let from_email = std::env::var("SMTP_FROM").unwrap_or_else(|_| "alerts@generalbots.com".to_string()); - - let subject = format!("[{}] Billing Alert: {}", - notification.severity.to_string().to_uppercase(), - notification.title - ); - - let body = format!( - "Alert: {}\nSeverity: {}\nOrganization: {}\nTime: {}\n\nMessage: {}\n\nThreshold: {:?}\nCurrent Value: {:?}", - notification.title, - notification.severity, - notification.organization_id, - notification.created_at, - notification.message, - notification.limit, - notification.current_usage - ); - - for recipient in ¬ification.recipients { - let email = Message::builder() - .from(from_email.parse().map_err(|e| NotificationError::DeliveryFailed(format!("Invalid from address: {}", e)))?) - .to(recipient.parse().map_err(|e| NotificationError::DeliveryFailed(format!("Invalid recipient {}: {}", recipient, e)))?) - .subject(&subject) - .body(body.clone()) - .map_err(|e| NotificationError::DeliveryFailed(format!("Failed to build email: {}", e)))?; - - let mailer = if let (Some(user), Some(pass)) = (&smtp_user, &smtp_pass) { - let creds = Credentials::new(user.clone(), pass.clone()); - SmtpTransport::relay(&smtp_host) - .map_err(|e| NotificationError::DeliveryFailed(format!("SMTP relay error: {}", e)))? - .credentials(creds) - .build() - } else { - SmtpTransport::builder_dangerous(&smtp_host).build() - }; - - mailer.send(&email) - .map_err(|e| NotificationError::DeliveryFailed(format!("Failed to send to {}: {}", recipient, e)))?; - - tracing::debug!("Email sent to {}", recipient); - } - - Ok(()) - } } /// Webhook notification handler pub struct WebhookNotificationHandler {} impl WebhookNotificationHandler { - pub fn new() -> Self { - Self {} - } +pub fn new() -> Self { +Self {} +} } impl Default for WebhookNotificationHandler { - fn default() -> Self { - Self::new() - } +fn default() -> Self { +Self::new() +} } #[async_trait::async_trait] impl NotificationHandler for WebhookNotificationHandler { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Webhook +fn channel(&self) -> NotificationChannel { +NotificationChannel::Webhook +} + +async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { +tracing::info!( +"Sending webhook notification for alert {}", +notification.alert_id +); + +// Get webhook URL from con or environment +let webhook_url = std::env::var("BILLING_WEBHOOK_URL").ok(); + +let url = match webhook_url { + Some(url) => url, + None => { + tracing::warn!("No webhook URL configured for alert {}", notification.alert_id); + return Ok(()); // Silent skip if not configured } +}; - async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { - tracing::info!( - "Sending webhook notification for alert {}", - notification.alert_id - ); +let payload = serde_json::json!({ + "alert_id": notification.alert_id, + "organization_id": notification.organization_id, + "alert_type": notification.title, + "severity": notification.severity.to_string(), + "message": notification.message, + "threshold_value": notification.limit, + "current_value": notification.current_usage, + "triggered_at": notification.created_at.to_rfc3339(), + "recipients": notification.recipients, +}); - // Get webhook URL from context or environment - let webhook_url = std::env::var("BILLING_WEBHOOK_URL").ok(); +let client = reqwest::Client::new(); +let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("User-Agent", "GeneralBots-Billing-Alerts/1.0") + .json(&payload) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| NotificationError::DeliveryFailed(format!("Webhook request failed: {}", e)))?; - let url = match webhook_url { - Some(url) => url, - None => { - tracing::warn!("No webhook URL configured for alert {}", notification.alert_id); - return Ok(()); // Silent skip if not configured - } - }; +if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(NotificationError::DeliveryFailed( + format!("Webhook returned {}: {}", status, body) + )); +} - let payload = serde_json::json!({ - "alert_id": notification.alert_id, - "organization_id": notification.organization_id, - "alert_type": notification.title, - "severity": notification.severity.to_string(), - "message": notification.message, - "threshold_value": notification.limit, - "current_value": notification.current_usage, - "triggered_at": notification.created_at.to_rfc3339(), - "recipients": notification.recipients, - }); +tracing::debug!("Webhook notification sent successfully to {}", url); +Ok(()) - let client = reqwest::Client::new(); - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("User-Agent", "GeneralBots-Billing-Alerts/1.0") - .json(&payload) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| NotificationError::DeliveryFailed(format!("Webhook request failed: {}", e)))?; +} - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(NotificationError::DeliveryFailed( - format!("Webhook returned {}: {}", status, body) - )); - } - - tracing::debug!("Webhook notification sent successfully to {}", url); - Ok(()) - } } /// In-app notification handler pub struct InAppNotificationHandler { - /// Broadcast channel for WebSocket notifications - broadcast: Option>, +/// Broadcast channel for WebSocket notifications +broadcast: Option>, } impl InAppNotificationHandler { - pub fn new() -> Self { - Self { broadcast: None } - } +pub fn new() -> Self { +Self { broadcast: None } +} + +/// Create with a broadcast channel for WebSocket notifications +pub fn with_broadcast( +broadcast: tokio::sync::broadcast::Sender, +) -> Self { +Self { +broadcast: Some(broadcast), +} +} - /// Create with a broadcast channel for WebSocket notifications - pub fn with_broadcast( - broadcast: tokio::sync::broadcast::Sender, - ) -> Self { - Self { - broadcast: Some(broadcast), - } - } } impl Default for InAppNotificationHandler { - fn default() -> Self { - Self::new() - } +fn default() -> Self { +Self::new() +} } #[async_trait::async_trait] impl NotificationHandler for InAppNotificationHandler { - fn channel(&self) -> NotificationChannel { - NotificationChannel::InApp - } +fn channel(&self) -> NotificationChannel { +NotificationChannel::InApp +} - async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { - tracing::info!( - "Creating in-app notification for alert {} org {}", - notification.alert_id, - notification.organization_id - ); +async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { +tracing::info!( +"Creating in-app notification for alert {} org {}", +notification.alert_id, +notification.organization_id +); - // Build notification payload for WebSocket broadcast - let ws_notification = crate::core::shared::state::BillingAlertNotification { - alert_id: notification.alert_id, - organization_id: notification.organization_id, - severity: notification.severity.to_string(), - alert_type: notification.title.clone(), - title: notification.title.clone(), - message: notification.message.clone(), - metric: notification.metric.clone(), - percentage: notification.percentage, - triggered_at: notification.created_at, - }; +// Build notification payload for WebSocket broadcast +let ws_notification = crate::core::shared::state::BillingAlertNotification { + alert_id: notification.alert_id, + organization_id: notification.organization_id, + severity: notification.severity.to_string(), + alert_type: notification.title.clone(), + title: notification.title.clone(), + message: notification.message.clone(), + metric: notification.metric.clone(), + percentage: notification.percentage, + triggered_at: notification.created_at, +}; - // Broadcast to connected WebSocket clients - if let Some(ref broadcast) = self.broadcast { - match broadcast.send(ws_notification.clone()) { - Ok(receivers) => { - tracing::info!( - "Billing alert {} broadcast to {} WebSocket receivers", - notification.alert_id, - receivers - ); - } - Err(e) => { - tracing::warn!( - "No active WebSocket receivers for billing alert {}: {}", - notification.alert_id, - e - ); - } - } - } else { - tracing::debug!( - "No broadcast channel configured, alert {} will be delivered via polling", - notification.alert_id +// Broadcast to connected WebSocket clients +if let Some(ref broadcast) = self.broadcast { + match broadcast.send(ws_notification.clone()) { + Ok(receivers) => { + tracing::info!( + "Billing alert {} broadcast to {} WebSocket receivers", + notification.alert_id, + receivers ); } - - // Store notification in database for users who aren't connected via WebSocket - // The UI will pick these up when polling /api/notifications - tracing::debug!( - "In-app notification queued for org {} - delivered via WebSocket and/or polling", - notification.organization_id - ); - - Ok(()) - } -} - -/// Slack notification handler -pub struct SlackNotificationHandler {} - -impl SlackNotificationHandler { - pub fn new() -> Self { - Self {} - } - - fn build_slack_message(&self, notification: &AlertNotification) -> serde_json::Value { - let color = match notification.severity { - AlertSeverity::Warning => "#FFA500", - AlertSeverity::Critical => "#FF0000", - AlertSeverity::Exceeded => "#8B0000", - }; - - serde_json::json!({ - "attachments": [{ - "color": color, - "title": notification.title, - "text": notification.message, - "fields": [ - { - "title": "Metric", - "value": notification.metric, - "short": true - }, - { - "title": "Usage", - "value": format!("{:.1}%", notification.percentage), - "short": true - } - ], - "actions": [{ - "type": "button", - "text": "View Usage", - "url": notification.action_url - }], - "ts": notification.created_at.timestamp() - }] - }) - } -} - -impl Default for SlackNotificationHandler { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl NotificationHandler for SlackNotificationHandler { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Slack - } - - async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { - tracing::info!( - "Sending Slack notification for alert {}", - notification.alert_id - ); - - // Get Slack webhook URL from context or environment - let webhook_url = std::env::var("SLACK_WEBHOOK_URL").ok(); - - let url = match webhook_url { - Some(url) => url, - None => { - tracing::warn!("No Slack webhook URL configured for alert {}", notification.alert_id); - return Ok(()); // Silent skip if not configured - } - }; - - let message = self.build_slack_message(notification); - - let client = reqwest::Client::new(); - let response = client - .post(&url) - .header("Content-Type", "application/json") - .json(&message) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| NotificationError::DeliveryFailed(format!("Slack request failed: {}", e)))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(NotificationError::DeliveryFailed( - format!("Slack webhook returned {}: {}", status, body) - )); + Err(e) => { + tracing::warn!( + "No active WebSocket receivers for billing alert {}: {}", + notification.alert_id, + e + ); } - - tracing::debug!("Slack notification sent successfully"); - Ok(()) } +} else { + tracing::debug!( + "No broadcast channel configured, alert {} will be delivered via polling", + notification.alert_id + ); } -/// Microsoft Teams notification handler -pub struct TeamsNotificationHandler {} +// Store notification in database for users who aren't connected via WebSocket +// The UI will pick these up when polling /api/notifications +tracing::debug!( + "In-app notification queued for org {} - delivered via WebSocket and/or polling", + notification.organization_id +); -impl TeamsNotificationHandler { - pub fn new() -> Self { - Self {} - } +Ok(()) - fn build_teams_message(&self, notification: &AlertNotification) -> serde_json::Value { - let theme_color = match notification.severity { - AlertSeverity::Warning => "FFA500", - AlertSeverity::Critical => "FF0000", - AlertSeverity::Exceeded => "8B0000", - }; - - serde_json::json!({ - "@type": "MessageCard", - "@context": "http://schema.org/extensions", - "themeColor": theme_color, - "summary": notification.title, - "sections": [{ - "activityTitle": notification.title, - "facts": [ - { "name": "Metric", "value": notification.metric }, - { "name": "Current Usage", "value": format!("{:.1}%", notification.percentage) }, - { "name": "Severity", "value": notification.severity.as_str() } - ], - "text": notification.message - }], - "potentialAction": [{ - "@type": "OpenUri", - "name": "View Usage", - "targets": [{ - "os": "default", - "uri": notification.action_url - }] - }] - }) - } } -impl Default for TeamsNotificationHandler { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl NotificationHandler for TeamsNotificationHandler { - fn channel(&self) -> NotificationChannel { - NotificationChannel::MsTeams - } - - async fn send(&self, notification: &AlertNotification) -> Result<(), NotificationError> { - tracing::info!( - "Sending Teams notification for alert {}", - notification.alert_id - ); - - // Get Teams webhook URL from context or environment - let webhook_url = std::env::var("TEAMS_WEBHOOK_URL").ok(); - - let url = match webhook_url { - Some(url) => url, - None => { - tracing::warn!("No Teams webhook URL configured for alert {}", notification.alert_id); - return Ok(()); // Silent skip if not configured - } - }; - - let message = self.build_teams_message(notification); - - let client = reqwest::Client::new(); - let response = client - .post(&url) - .header("Content-Type", "application/json") - .json(&message) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| NotificationError::DeliveryFailed(format!("Teams request failed: {}", e)))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(NotificationError::DeliveryFailed( - format!("Teams webhook returned {}: {}", status, body) - )); - } - - tracing::debug!("Teams notification sent successfully"); - Ok(()) - } } // ============================================================================ @@ -1168,44 +957,44 @@ impl NotificationHandler for TeamsNotificationHandler { #[derive(Debug, Clone)] pub enum AlertError { - NotFound, - AlreadyExists, - InvalidThreshold, - StorageError(String), +NotFound, +AlreadyExists, +InvalidThreshold, +StorageError(String), } impl std::fmt::Display for AlertError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NotFound => write!(f, "Alert not found"), - Self::AlreadyExists => write!(f, "Alert already exists"), - Self::InvalidThreshold => write!(f, "Invalid threshold value"), - Self::StorageError(msg) => write!(f, "Storage error: {}", msg), - } - } +fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +match self { +Self::NotFound => write!(f, "Alert not found"), +Self::AlreadyExists => write!(f, "Alert already exists"), +Self::InvalidThreshold => write!(f, "Invalid threshold value"), +Self::StorageError(msg) => write!(f, "Storage error: {}", msg), +} +} } impl std::error::Error for AlertError {} #[derive(Debug, Clone)] pub enum NotificationError { - NetworkError(String), - ConfigurationError(String), - RateLimited, - InvalidRecipient(String), - DeliveryFailed(String), +NetworkError(String), +ConfigurationError(String), +RateLimited, +InvalidRecipient(String), +DeliveryFailed(String), } impl std::fmt::Display for NotificationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NetworkError(msg) => write!(f, "Network error: {}", msg), - Self::ConfigurationError(msg) => write!(f, "Configuration error: {}", msg), - Self::RateLimited => write!(f, "Rate limited"), - Self::InvalidRecipient(msg) => write!(f, "Invalid recipient: {}", msg), - Self::DeliveryFailed(msg) => write!(f, "Delivery failed: {}", msg), - } - } +fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +match self { +Self::NetworkError(msg) => write!(f, "Network error: {}", msg), +Self::ConfigurationError(msg) => write!(f, "Configuration error: {}", msg), +Self::RateLimited => write!(f, "Rate limited"), +Self::InvalidRecipient(msg) => write!(f, "Invalid recipient: {}", msg), +Self::DeliveryFailed(msg) => write!(f, "Delivery failed: {}", msg), +} +} } impl std::error::Error for NotificationError {} @@ -1216,35 +1005,36 @@ impl std::error::Error for NotificationError {} /// Format bytes as human-readable string fn format_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - const TB: u64 = GB * 1024; +const KB: u64 = 1024; +const MB: u64 = KB * 1024; +const GB: u64 = MB * 1024; +const TB: u64 = GB * 1024; + +if bytes >= TB { +format!("{:.2} TB", bytes as f64 / TB as f64) +} else if bytes >= GB { +format!("{:.2} GB", bytes as f64 / GB as f64) +} else if bytes >= MB { +format!("{:.2} MB", bytes as f64 / MB as f64) +} else if bytes >= KB { +format!("{:.2} KB", bytes as f64 / KB as f64) +} else { +format!("{} bytes", bytes) +} - if bytes >= TB { - format!("{:.2} TB", bytes as f64 / TB as f64) - } else if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} bytes", bytes) - } } /// Format number with thousands separators fn format_number(n: u64) -> String { - let s = n.to_string(); - let mut result = String::new(); - for (i, c) in s.chars().rev().enumerate() { - if i > 0 && i % 3 == 0 { - result.push(','); - } - result.push(c); - } - result.chars().rev().collect() +let s = n.to_string(); +let mut result = String::new(); +for (i, c) in s.chars().rev().enumerate() { +if i > 0 && i % 3 == 0 { +result.push(','); +} +result.push(c); +} +result.chars().rev().collect() } // ============================================================================ @@ -1252,237 +1042,18 @@ fn format_number(n: u64) -> String { // ============================================================================ impl UsageMetric { - /// Get human-readable display name for the metric - pub fn display_name(&self) -> &'static str { - match self { - Self::Messages => "Messages", - Self::StorageBytes => "Storage", - Self::ApiCalls => "API Calls", - Self::Bots => "Bots", - Self::Users => "Users", - Self::KbDocuments => "KB Documents", - Self::Apps => "Apps", - } - } +/// Get human-readable display name for the metric +pub fn display_name(&self) -> &'static str { +match self { +Self::Messages => "Messages", +Self::StorageBytes => "Storage", +Self::ApiCalls => "API Calls", +Self::Bots => "Bots", +Self::Users => "Users", +Self::KbDocuments => "KB Documents", +Self::Apps => "Apps", } - -// ============================================================================ -// Grace Period Support -// ============================================================================ - -/// Grace period configuration for quota overages -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GracePeriodConfig { - /// Whether grace period is enabled - pub enabled: bool, - /// Grace period duration in hours - pub duration_hours: u32, - /// Maximum overage percentage allowed during grace period - pub max_overage_percent: f64, - /// Metrics that support grace period - pub applicable_metrics: Vec, } - -impl Default for GracePeriodConfig { - fn default() -> Self { - Self { - enabled: true, - duration_hours: 24, - max_overage_percent: 10.0, - applicable_metrics: vec![ - UsageMetric::Messages, - UsageMetric::ApiCalls, - ], - } - } -} - -/// Grace period status for an organization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GracePeriodStatus { - pub organization_id: Uuid, - pub metric: UsageMetric, - pub started_at: DateTime, - pub expires_at: DateTime, - pub overage_at_start: u64, - pub current_overage: u64, - pub max_allowed_overage: u64, - pub is_active: bool, -} - -impl GracePeriodStatus { - pub fn new( - organization_id: Uuid, - metric: UsageMetric, - config: &GracePeriodConfig, - current_usage: u64, - limit: u64, - ) -> Self { - let now = Utc::now(); - let overage = current_usage.saturating_sub(limit); - let max_allowed = (limit as f64 * config.max_overage_percent / 100.0) as u64; - - Self { - organization_id, - metric, - started_at: now, - expires_at: now + chrono::Duration::hours(config.duration_hours as i64), - overage_at_start: overage, - current_overage: overage, - max_allowed_overage: max_allowed, - is_active: true, - } - } - - pub fn is_expired(&self) -> bool { - Utc::now() > self.expires_at - } - - pub fn is_within_limits(&self) -> bool { - self.current_overage <= self.max_allowed_overage - } - - pub fn remaining_time(&self) -> chrono::Duration { - self.expires_at.signed_duration_since(Utc::now()) - } - - pub fn update_overage(&mut self, current_usage: u64, limit: u64) { - self.current_overage = current_usage.saturating_sub(limit); - - if self.is_expired() || !self.is_within_limits() { - self.is_active = false; - } - } -} - -/// Grace period manager -pub struct GracePeriodManager { - config: GracePeriodConfig, - active_periods: Arc>>, -} - -impl GracePeriodManager { - pub fn new(config: GracePeriodConfig) -> Self { - Self { - config, - active_periods: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Check if grace period allows the operation - pub async fn check_grace_period( - &self, - org_id: Uuid, - metric: UsageMetric, - current_usage: u64, - limit: u64, - ) -> GracePeriodDecision { - if !self.config.enabled || !self.config.applicable_metrics.contains(&metric) { - return GracePeriodDecision::NotApplicable; - } - - let key = (org_id, metric); - let mut periods = self.active_periods.write().await; - - if let Some(status) = periods.get_mut(&key) { - status.update_overage(current_usage, limit); - - if status.is_active && status.is_within_limits() { - return GracePeriodDecision::InGracePeriod { - remaining: status.remaining_time(), - overage_used: status.current_overage, - overage_limit: status.max_allowed_overage, - }; - } else { - periods.remove(&key); - return GracePeriodDecision::GracePeriodExpired; - } - } - - // Start new grace period if within overage limits - let potential_status = GracePeriodStatus::new( - org_id, - metric, - &self.config, - current_usage, - limit, - ); - - if potential_status.is_within_limits() { - let remaining = potential_status.remaining_time(); - let overage_used = potential_status.current_overage; - let overage_limit = potential_status.max_allowed_overage; - - periods.insert(key, potential_status); - - GracePeriodDecision::GracePeriodStarted { - duration_hours: self.config.duration_hours, - remaining, - overage_used, - overage_limit, - } - } else { - GracePeriodDecision::OverageExceedsLimit { - current_overage: current_usage.saturating_sub(limit), - max_allowed: potential_status.max_allowed_overage, - } - } - } - - /// Get active grace period status - pub async fn get_status( - &self, - org_id: Uuid, - metric: UsageMetric, - ) -> Option { - let periods = self.active_periods.read().await; - periods.get(&(org_id, metric)).cloned() - } - - /// End grace period early (e.g., after upgrade) - pub async fn end_grace_period(&self, org_id: Uuid, metric: UsageMetric) { - let mut periods = self.active_periods.write().await; - periods.remove(&(org_id, metric)); - } - - /// Clean up expired grace periods - pub async fn cleanup_expired(&self) { - let mut periods = self.active_periods.write().await; - periods.retain(|_, status| !status.is_expired()); - } -} - -/// Grace period decision -#[derive(Debug, Clone)] -pub enum GracePeriodDecision { - NotApplicable, - GracePeriodStarted { - duration_hours: u32, - remaining: chrono::Duration, - overage_used: u64, - overage_limit: u64, - }, - InGracePeriod { - remaining: chrono::Duration, - overage_used: u64, - overage_limit: u64, - }, - GracePeriodExpired, - OverageExceedsLimit { - current_overage: u64, - max_allowed: u64, - }, -} - -impl GracePeriodDecision { - pub fn allows_operation(&self) -> bool { - matches!( - self, - Self::NotApplicable - | Self::GracePeriodStarted { .. } - | Self::InGracePeriod { .. } - ) - } } // ============================================================================ @@ -1491,34 +1062,29 @@ impl GracePeriodDecision { /// Create a fully configured alert manager with all handlers pub fn create_alert_manager( - thresholds: Option, - cooldown_seconds: Option, +thresholds: Option, +cooldown_seconds: Option, ) -> AlertManager { - let mut manager = AlertManager::new(); +let mut manager = AlertManager::new(); - if let Some(t) = thresholds { - manager = manager.with_thresholds(t); - } +if let Some(t) = thresholds { +manager = manager.with_thresholds(t); +} - if let Some(c) = cooldown_seconds { - manager = manager.with_cooldown(c); - } +if let Some(c) = cooldown_seconds { +manager = manager.with_cooldown(c); +} + +manager - manager } /// Create default notification handlers pub async fn register_default_handlers(manager: &AlertManager) { - manager - .register_handler(Arc::new(InAppNotificationHandler::new())) - .await; - manager - .register_handler(Arc::new(WebhookNotificationHandler::new())) - .await; - manager - .register_handler(Arc::new(SlackNotificationHandler::new())) - .await; - manager - .register_handler(Arc::new(TeamsNotificationHandler::new())) - .await; +manager +.register_handler(Arc::new(InAppNotificationHandler::new())) +.await; +manager +.register_handler(Arc::new(WebhookNotificationHandler::new())) +.await; } diff --git a/src/core/shared/admin.rs b/src/core/shared/admin.rs index 788a64bd9..10dd88626 100644 --- a/src/core/shared/admin.rs +++ b/src/core/shared/admin.rs @@ -8,9 +8,13 @@ use axum::{ use chrono::{DateTime, Utc}; use diesel::prelude::*; use diesel::sql_types::{Nullable, Text, Timestamptz, Uuid as DieselUuid, Varchar}; +#[cfg(feature = "mail")] use lettre::{Message, SmtpTransport, Transport}; +#[cfg(feature = "mail")] use lettre::transport::smtp::authentication::Credentials; -use log::{info, warn}; +use log::warn; +#[cfg(feature = "mail")] +use log::info; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -20,11 +24,12 @@ use uuid::Uuid; // ============================================================================ /// Send invitation email via SMTP +#[cfg(feature = "mail")] async fn send_invitation_email( - to_email: &str, - role: &str, - custom_message: Option<&str>, - invitation_id: Uuid, +to_email: &str, +role: &str, +custom_message: Option<&str>, +invitation_id: Uuid, ) -> Result<(), String> { let smtp_host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string()); let smtp_user = std::env::var("SMTP_USER").ok(); @@ -77,6 +82,7 @@ The General Bots Team"#, } /// Send invitation email by fetching details from database +#[cfg(feature = "mail")] async fn send_invitation_email_by_id(invitation_id: Uuid) -> Result<(), String> { let smtp_host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string()); let smtp_user = std::env::var("SMTP_USER").ok(); diff --git a/src/core/shared/memory_monitor.rs b/src/core/shared/memory_monitor.rs index 1f1da3f13..78a6cd2c3 100644 --- a/src/core/shared/memory_monitor.rs +++ b/src/core/shared/memory_monitor.rs @@ -1,506 +1,525 @@ -//! Memory and CPU monitoring with thread tracking -//! -//! This module provides tools to track memory/CPU usage per thread -//! and identify potential leaks or CPU hogs in the botserver application. -//! -//! When compiled with the `jemalloc` feature, provides detailed allocation statistics. - use log::{debug, info, trace, warn}; use std::collections::HashMap; use std::sync::{LazyLock, Mutex, RwLock}; use std::time::{Duration, Instant}; +#[cfg(feature = "monitoring")] use sysinfo::{Pid, ProcessesToUpdate, System}; static THREAD_REGISTRY: LazyLock>> = - LazyLock::new(|| RwLock::new(HashMap::new())); +LazyLock::new(|| RwLock::new(HashMap::new())); static COMPONENT_TRACKER: LazyLock = - LazyLock::new(|| ComponentMemoryTracker::new(60)); +LazyLock::new(|| ComponentMemoryTracker::new(60)); #[derive(Debug, Clone)] pub struct ThreadInfo { - pub name: String, - pub started_at: Instant, - pub last_activity: Instant, - pub activity_count: u64, - pub component: String, +pub name: String, +pub started_at: Instant, +pub last_activity: Instant, +pub activity_count: u64, +pub component: String, } pub fn register_thread(name: &str, component: &str) { - let info = ThreadInfo { - name: name.to_string(), - started_at: Instant::now(), - last_activity: Instant::now(), - activity_count: 0, - component: component.to_string(), - }; - if let Ok(mut registry) = THREAD_REGISTRY.write() { - registry.insert(name.to_string(), info); - } - trace!("[THREAD] Registered: {} (component: {})", name, component); +let info = ThreadInfo { +name: name.to_string(), +started_at: Instant::now(), +last_activity: Instant::now(), +activity_count: 0, +component: component.to_string(), +}; +if let Ok(mut registry) = THREAD_REGISTRY.write() { +registry.insert(name.to_string(), info); +} +trace!("[THREAD] Registered: {} (component: {})", name, component); } pub fn record_thread_activity(name: &str) { - if let Ok(mut registry) = THREAD_REGISTRY.write() { - if let Some(info) = registry.get_mut(name) { - info.last_activity = Instant::now(); - info.activity_count += 1; - } - } +if let Ok(mut registry) = THREAD_REGISTRY.write() { +if let Some(info) = registry.get_mut(name) { +info.last_activity = Instant::now(); +info.activity_count += 1; +} +} } pub fn unregister_thread(name: &str) { - if let Ok(mut registry) = THREAD_REGISTRY.write() { - registry.remove(name); - } - info!("[THREAD] Unregistered: {}", name); +if let Ok(mut registry) = THREAD_REGISTRY.write() { +registry.remove(name); +} +info!("[THREAD] Unregistered: {}", name); } pub fn log_thread_stats() { - if let Ok(registry) = THREAD_REGISTRY.read() { - info!("[THREADS] Active thread count: {}", registry.len()); - for (name, info) in registry.iter() { - let uptime = info.started_at.elapsed().as_secs(); - let idle = info.last_activity.elapsed().as_secs(); - info!( - "[THREAD] {} | component={} | uptime={}s | idle={}s | activities={}", - name, info.component, uptime, idle, info.activity_count - ); - } - } +if let Ok(registry) = THREAD_REGISTRY.read() { +info!("[THREADS] Active thread count: {}", registry.len()); +for (name, info) in registry.iter() { +let uptime = info.started_at.elapsed().as_secs(); +let idle = info.last_activity.elapsed().as_secs(); +info!( +"[THREAD] {} | component={} | uptime={}s | idle={}s | activities={}", +name, info.component, uptime, idle, info.activity_count +); +} +} } #[derive(Debug, Clone)] pub struct MemoryStats { - pub rss_bytes: u64, - pub virtual_bytes: u64, - pub timestamp: Instant, +pub rss_bytes: u64, +pub virtual_bytes: u64, +pub timestamp: Instant, } impl MemoryStats { - pub fn current() -> Self { - let (rss, virt) = get_process_memory().unwrap_or((0, 0)); - Self { - rss_bytes: rss, - virtual_bytes: virt, - timestamp: Instant::now(), - } - } +pub fn current() -> Self { +let (rss, virt) = get_process_memory().unwrap_or((0, 0)); +Self { +rss_bytes: rss, +virtual_bytes: virt, +timestamp: Instant::now(), +} +} - pub fn format_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} B", bytes) - } - } +pub fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; - pub fn log(&self) { - info!( - "[MEMORY] RSS={}, Virtual={}", - Self::format_bytes(self.rss_bytes), - Self::format_bytes(self.virtual_bytes), - ); + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) } } +pub fn log(&self) { + info!( + "[MEMORY] RSS={}, Virtual={}", + Self::format_bytes(self.rss_bytes), + Self::format_bytes(self.virtual_bytes), + ); +} + +} + /// Get jemalloc memory statistics when the feature is enabled #[cfg(feature = "jemalloc")] pub fn get_jemalloc_stats() -> Option { - use tikv_jemalloc_ctl::{epoch, stats}; +use tikv_jemalloc_ctl::{epoch, stats}; - // Advance the epoch to refresh statistics - if epoch::advance().is_err() { - return None; - } - let allocated = stats::allocated::read().ok()? as u64; - let active = stats::active::read().ok()? as u64; - let resident = stats::resident::read().ok()? as u64; - let mapped = stats::mapped::read().ok()? as u64; - let retained = stats::retained::read().ok()? as u64; +// Advance the epoch to refresh statistics +if epoch::advance().is_err() { + return None; +} + +let allocated = stats::allocated::read().ok()? as u64; +let active = stats::active::read().ok()? as u64; +let resident = stats::resident::read().ok()? as u64; +let mapped = stats::mapped::read().ok()? as u64; +let retained = stats::retained::read().ok()? as u64; + +Some(JemallocStats { + allocated, + active, + resident, + mapped, + retained, +}) - Some(JemallocStats { - allocated, - active, - resident, - mapped, - retained, - }) } #[cfg(not(feature = "jemalloc"))] pub fn get_jemalloc_stats() -> Option { - None +None } /// Jemalloc memory statistics #[derive(Debug, Clone)] pub struct JemallocStats { - /// Total bytes allocated by the application - pub allocated: u64, - /// Total bytes in active pages allocated by the application - pub active: u64, - /// Total bytes in physically resident pages - pub resident: u64, - /// Total bytes in active extents mapped by the allocator - pub mapped: u64, - /// Total bytes retained (not returned to OS) - pub retained: u64, +/// Total bytes allocated by the application +pub allocated: u64, +/// Total bytes in active pages allocated by the application +pub active: u64, +/// Total bytes in physically resident pages +pub resident: u64, +/// Total bytes in active extents mapped by the allocator +pub mapped: u64, +/// Total bytes retained (not returned to OS) +pub retained: u64, } impl JemallocStats { - pub fn log(&self) { - info!( - "[JEMALLOC] allocated={} active={} resident={} mapped={} retained={}", - MemoryStats::format_bytes(self.allocated), - MemoryStats::format_bytes(self.active), - MemoryStats::format_bytes(self.resident), - MemoryStats::format_bytes(self.mapped), - MemoryStats::format_bytes(self.retained), - ); - } +pub fn log(&self) { +info!( +"[JEMALLOC] allocated={} active={} resident={} mapped={} retained={}", +MemoryStats::format_bytes(self.allocated), +MemoryStats::format_bytes(self.active), +MemoryStats::format_bytes(self.resident), +MemoryStats::format_bytes(self.mapped), +MemoryStats::format_bytes(self.retained), +); +} - /// Calculate fragmentation ratio (1.0 = no fragmentation) - pub fn fragmentation_ratio(&self) -> f64 { - if self.allocated > 0 { - self.active as f64 / self.allocated as f64 - } else { - 1.0 - } + +/// Calculate fragmentation ratio (1.0 = no fragmentation) +pub fn fragmentation_ratio(&self) -> f64 { + if self.allocated > 0 { + self.active as f64 / self.allocated as f64 + } else { + 1.0 } } +} + /// Log jemalloc stats if available pub fn log_jemalloc_stats() { - if let Some(stats) = get_jemalloc_stats() { - stats.log(); - let frag = stats.fragmentation_ratio(); - if frag > 1.5 { - warn!("[JEMALLOC] High fragmentation detected: {:.2}x", frag); - } - } +if let Some(stats) = get_jemalloc_stats() { +stats.log(); +let frag = stats.fragmentation_ratio(); +if frag > 1.5 { +warn!("[JEMALLOC] High fragmentation detected: {:.2}x", frag); +} +} } #[derive(Debug)] pub struct MemoryCheckpoint { - pub name: String, - pub stats: MemoryStats, +pub name: String, +pub stats: MemoryStats, } impl MemoryCheckpoint { - pub fn new(name: &str) -> Self { - let stats = MemoryStats::current(); - info!( - "[CHECKPOINT] {} started at RSS={}", - name, - MemoryStats::format_bytes(stats.rss_bytes) +pub fn new(name: &str) -> Self { +let stats = MemoryStats::current(); +info!( +"[CHECKPOINT] {} started at RSS={}", +name, +MemoryStats::format_bytes(stats.rss_bytes) +); +Self { +name: name.to_string(), +stats, +} +} + + +pub fn compare_and_log(&self) { + let current = MemoryStats::current(); + let diff = current.rss_bytes as i64 - self.stats.rss_bytes as i64; + + if diff > 0 { + warn!( + "[CHECKPOINT] {} INCREASED by {}", + self.name, + MemoryStats::format_bytes(diff as u64), ); - Self { - name: name.to_string(), - stats, - } + } else if diff < 0 { + info!( + "[CHECKPOINT] {} decreased by {}", + self.name, + MemoryStats::format_bytes((-diff) as u64), + ); + } else { + debug!("[CHECKPOINT] {} unchanged", self.name); } +} - pub fn compare_and_log(&self) { - let current = MemoryStats::current(); - let diff = current.rss_bytes as i64 - self.stats.rss_bytes as i64; - - if diff > 0 { - warn!( - "[CHECKPOINT] {} INCREASED by {}", - self.name, - MemoryStats::format_bytes(diff as u64), - ); - } else if diff < 0 { - info!( - "[CHECKPOINT] {} decreased by {}", - self.name, - MemoryStats::format_bytes((-diff) as u64), - ); - } else { - debug!("[CHECKPOINT] {} unchanged", self.name); - } - } } pub struct ComponentMemoryTracker { - components: Mutex>>, - max_history: usize, +components: Mutex>>, +max_history: usize, } impl ComponentMemoryTracker { - pub fn new(max_history: usize) -> Self { - Self { - components: Mutex::new(HashMap::new()), - max_history, +pub fn new(max_history: usize) -> Self { +Self { +components: Mutex::new(HashMap::new()), +max_history, +} +} + + +pub fn record(&self, component: &str) { + let stats = MemoryStats::current(); + if let Ok(mut components) = self.components.lock() { + let history = components.entry(component.to_string()).or_default(); + history.push(stats); + + if history.len() > self.max_history { + history.remove(0); } } +} - pub fn record(&self, component: &str) { - let stats = MemoryStats::current(); - if let Ok(mut components) = self.components.lock() { - let history = components.entry(component.to_string()).or_default(); - history.push(stats); - - if history.len() > self.max_history { - history.remove(0); - } - } - } - - pub fn get_growth_rate(&self, component: &str) -> Option { - if let Ok(components) = self.components.lock() { - if let Some(history) = components.get(component) { - if history.len() >= 2 { - let first = &history[0]; - let last = &history[history.len() - 1]; - let duration = last.timestamp.duration_since(first.timestamp).as_secs_f64(); - if duration > 0.0 { - let byte_diff = last.rss_bytes as f64 - first.rss_bytes as f64; - return Some(byte_diff / duration); - } - } - } - } - None - } - - pub fn log_all(&self) { - if let Ok(components) = self.components.lock() { - for (name, history) in components.iter() { - if let Some(last) = history.last() { - let growth = self.get_growth_rate(name); - let growth_str = growth - .map(|g| { - let sign = if g >= 0.0 { "+" } else { "-" }; - format!("{}{}/s", sign, MemoryStats::format_bytes(g.abs() as u64)) - }) - .unwrap_or_else(|| "N/A".to_string()); - info!( - "[COMPONENT] {} | RSS={} | Growth={}", - name, - MemoryStats::format_bytes(last.rss_bytes), - growth_str - ); +pub fn get_growth_rate(&self, component: &str) -> Option { + if let Ok(components) = self.components.lock() { + if let Some(history) = components.get(component) { + if history.len() >= 2 { + let first = &history[0]; + let last = &history[history.len() - 1]; + let duration = last.timestamp.duration_since(first.timestamp).as_secs_f64(); + if duration > 0.0 { + let byte_diff = last.rss_bytes as f64 - first.rss_bytes as f64; + return Some(byte_diff / duration); } } } } + None +} + +pub fn log_all(&self) { + if let Ok(components) = self.components.lock() { + for (name, history) in components.iter() { + if let Some(last) = history.last() { + let growth = self.get_growth_rate(name); + let growth_str = growth + .map(|g| { + let sign = if g >= 0.0 { "+" } else { "-" }; + format!("{}{}/s", sign, MemoryStats::format_bytes(g.abs() as u64)) + }) + .unwrap_or_else(|| "N/A".to_string()); + info!( + "[COMPONENT] {} | RSS={} | Growth={}", + name, + MemoryStats::format_bytes(last.rss_bytes), + growth_str + ); + } + } + } +} + } pub fn record_component(component: &str) { - COMPONENT_TRACKER.record(component); +COMPONENT_TRACKER.record(component); } pub fn log_component_stats() { - COMPONENT_TRACKER.log_all(); +COMPONENT_TRACKER.log_all(); } pub struct LeakDetector { - baseline: Mutex, - growth_threshold_bytes: u64, - consecutive_growth_count: Mutex, - max_consecutive_growth: usize, +baseline: Mutex, +growth_threshold_bytes: u64, +consecutive_growth_count: Mutex, +max_consecutive_growth: usize, } impl LeakDetector { - pub fn new(growth_threshold_mb: u64, max_consecutive_growth: usize) -> Self { - Self { - baseline: Mutex::new(0), - growth_threshold_bytes: growth_threshold_mb * 1024 * 1024, - consecutive_growth_count: Mutex::new(0), - max_consecutive_growth, - } - } +pub fn new(growth_threshold_mb: u64, max_consecutive_growth: usize) -> Self { +Self { +baseline: Mutex::new(0), +growth_threshold_bytes: growth_threshold_mb * 1024 * 1024, +consecutive_growth_count: Mutex::new(0), +max_consecutive_growth, +} +} - pub fn reset_baseline(&self) { - let current = MemoryStats::current(); + +pub fn reset_baseline(&self) { + let current = MemoryStats::current(); + if let Ok(mut baseline) = self.baseline.lock() { + *baseline = current.rss_bytes; + } + if let Ok(mut count) = self.consecutive_growth_count.lock() { + *count = 0; + } +} + +pub fn check(&self) -> Option { + let current = MemoryStats::current(); + + let baseline_val = match self.baseline.lock() { + Ok(b) => *b, + Err(_) => return None, + }; + + if baseline_val == 0 { if let Ok(mut baseline) = self.baseline.lock() { *baseline = current.rss_bytes; } - if let Ok(mut count) = self.consecutive_growth_count.lock() { - *count = 0; - } + return None; } - pub fn check(&self) -> Option { - let current = MemoryStats::current(); + let growth = current.rss_bytes.saturating_sub(baseline_val); - let baseline_val = match self.baseline.lock() { - Ok(b) => *b, + if growth > self.growth_threshold_bytes { + let count = match self.consecutive_growth_count.lock() { + Ok(mut c) => { + *c += 1; + *c + } Err(_) => return None, }; - if baseline_val == 0 { - if let Ok(mut baseline) = self.baseline.lock() { - *baseline = current.rss_bytes; - } - return None; + if count >= self.max_consecutive_growth { + return Some(format!( + "POTENTIAL MEMORY LEAK: grew by {} over {} checks. RSS={}, Baseline={}", + MemoryStats::format_bytes(growth), + count, + MemoryStats::format_bytes(current.rss_bytes), + MemoryStats::format_bytes(baseline_val), + )); } - - let growth = current.rss_bytes.saturating_sub(baseline_val); - - if growth > self.growth_threshold_bytes { - let count = match self.consecutive_growth_count.lock() { - Ok(mut c) => { - *c += 1; - *c - } - Err(_) => return None, - }; - - if count >= self.max_consecutive_growth { - return Some(format!( - "POTENTIAL MEMORY LEAK: grew by {} over {} checks. RSS={}, Baseline={}", - MemoryStats::format_bytes(growth), - count, - MemoryStats::format_bytes(current.rss_bytes), - MemoryStats::format_bytes(baseline_val), - )); - } - } else { - if let Ok(mut count) = self.consecutive_growth_count.lock() { - *count = 0; - } - if let Ok(mut baseline) = self.baseline.lock() { - *baseline = current.rss_bytes; - } + } else { + if let Ok(mut count) = self.consecutive_growth_count.lock() { + *count = 0; + } + if let Ok(mut baseline) = self.baseline.lock() { + *baseline = current.rss_bytes; } - - None } + + None +} + } pub fn start_memory_monitor(interval_secs: u64, warn_threshold_mb: u64) { - let detector = LeakDetector::new(warn_threshold_mb, 5); +let detector = LeakDetector::new(warn_threshold_mb, 5); - tokio::spawn(async move { - register_thread("memory-monitor", "monitoring"); - info!( - "[MONITOR] Started (interval={}s, threshold={}MB)", - interval_secs, warn_threshold_mb +tokio::spawn(async move { + register_thread("memory-monitor", "monitoring"); + + info!( + "[MONITOR] Started (interval={}s, threshold={}MB)", + interval_secs, warn_threshold_mb + ); + + let mut prev_rss: u64 = 0; + let mut tick_count: u64 = 0; + + // First 2 minutes: check every 10 seconds for aggressive tracking + // After that: use normal interval + let startup_interval = Duration::from_secs(10); + let normal_interval = Duration::from_secs(interval_secs); + let startup_ticks = 12; // 2 minutes of 10-second intervals + + let mut interval = tokio::time::interval(startup_interval); + + loop { + interval.tick().await; + tick_count += 1; + record_thread_activity("memory-monitor"); + + let stats = MemoryStats::current(); + let rss_diff = if prev_rss > 0 { + stats.rss_bytes as i64 - prev_rss as i64 + } else { + 0 + }; + + let diff_str = if rss_diff > 0 { + format!("+{}", MemoryStats::format_bytes(rss_diff as u64)) + } else if rss_diff < 0 { + format!("-{}", MemoryStats::format_bytes((-rss_diff) as u64)) + } else { + "±0".to_string() + }; + + trace!( + "[MONITOR] tick={} RSS={} ({}) Virtual={}", + tick_count, + MemoryStats::format_bytes(stats.rss_bytes), + diff_str, + MemoryStats::format_bytes(stats.virtual_bytes), ); - let mut prev_rss: u64 = 0; - let mut tick_count: u64 = 0; - - // First 2 minutes: check every 10 seconds for aggressive tracking - // After that: use normal interval - let startup_interval = Duration::from_secs(10); - let normal_interval = Duration::from_secs(interval_secs); - let startup_ticks = 12; // 2 minutes of 10-second intervals - - let mut interval = tokio::time::interval(startup_interval); - - loop { - interval.tick().await; - tick_count += 1; - record_thread_activity("memory-monitor"); - - let stats = MemoryStats::current(); - let rss_diff = if prev_rss > 0 { - stats.rss_bytes as i64 - prev_rss as i64 - } else { - 0 - }; - - let diff_str = if rss_diff > 0 { - format!("+{}", MemoryStats::format_bytes(rss_diff as u64)) - } else if rss_diff < 0 { - format!("-{}", MemoryStats::format_bytes((-rss_diff) as u64)) - } else { - "±0".to_string() - }; - - trace!( - "[MONITOR] tick={} RSS={} ({}) Virtual={}", - tick_count, - MemoryStats::format_bytes(stats.rss_bytes), - diff_str, - MemoryStats::format_bytes(stats.virtual_bytes), - ); - - // Log jemalloc stats every 5 ticks if available - if tick_count % 5 == 0 { - log_jemalloc_stats(); - } - - prev_rss = stats.rss_bytes; - record_component("global"); - - if let Some(warning) = detector.check() { - warn!("[MONITOR] {}", warning); - stats.log(); - log_component_stats(); - log_thread_stats(); - } - - // Switch to normal interval after startup period - if tick_count == startup_ticks { - trace!("[MONITOR] Switching to normal interval ({}s)", interval_secs); - interval = tokio::time::interval(normal_interval); - } + // Log jemalloc stats every 5 ticks if available + if tick_count % 5 == 0 { + log_jemalloc_stats(); } - }); + + prev_rss = stats.rss_bytes; + record_component("global"); + + if let Some(warning) = detector.check() { + warn!("[MONITOR] {}", warning); + stats.log(); + log_component_stats(); + log_thread_stats(); + } + + // Switch to normal interval after startup period + if tick_count == startup_ticks { + trace!("[MONITOR] Switching to normal interval ({}s)", interval_secs); + interval = tokio::time::interval(normal_interval); + } + } +}); + } +#[cfg(feature = "monitoring")] +#[cfg(feature = "monitoring")] pub fn get_process_memory() -> Option<(u64, u64)> { - let pid = Pid::from_u32(std::process::id()); - let mut sys = System::new(); - sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); +let pid = Pid::from_u32(std::process::id()); +let mut sys = System::new(); +sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); - sys.process(pid).map(|p| (p.memory(), p.virtual_memory())) + +sys.process(pid).map(|p| (p.memory(), p.virtual_memory())) + +} + +#[cfg(not(feature = "monitoring"))] +pub fn get_process_memory() -> Option<(u64, u64)> { +None } pub fn log_process_memory() { - if let Some((rss, virt)) = get_process_memory() { - trace!( - "[PROCESS] RSS={}, Virtual={}", - MemoryStats::format_bytes(rss), - MemoryStats::format_bytes(virt) - ); - } +if let Some((rss, virt)) = get_process_memory() { +trace!( +"[PROCESS] RSS={}, Virtual={}", +MemoryStats::format_bytes(rss), +MemoryStats::format_bytes(virt) +); +} } #[cfg(test)] mod tests { - use super::*; +use super::*; - #[test] - fn test_memory_stats() { - let stats = MemoryStats::current(); - assert!(stats.rss_bytes > 0 || stats.virtual_bytes >= 0); - } - #[test] - fn test_format_bytes() { - assert_eq!(MemoryStats::format_bytes(500), "500 B"); - assert_eq!(MemoryStats::format_bytes(1024), "1.00 KB"); - assert_eq!(MemoryStats::format_bytes(1024 * 1024), "1.00 MB"); - assert_eq!(MemoryStats::format_bytes(1024 * 1024 * 1024), "1.00 GB"); - } - - #[test] - fn test_checkpoint() { - let checkpoint = MemoryCheckpoint::new("test"); - checkpoint.compare_and_log(); - } - - #[test] - fn test_thread_registry() { - register_thread("test-thread", "test-component"); - record_thread_activity("test-thread"); - log_thread_stats(); - unregister_thread("test-thread"); - } +#[test] +fn test_memory_stats() { + let stats = MemoryStats::current(); + assert!(stats.rss_bytes > 0 || stats.virtual_bytes >= 0); +} + +#[test] +fn test_format_bytes() { + assert_eq!(MemoryStats::format_bytes(500), "500 B"); + assert_eq!(MemoryStats::format_bytes(1024), "1.00 KB"); + assert_eq!(MemoryStats::format_bytes(1024 * 1024), "1.00 MB"); + assert_eq!(MemoryStats::format_bytes(1024 * 1024 * 1024), "1.00 GB"); +} + +#[test] +fn test_checkpoint() { + let checkpoint = MemoryCheckpoint::new("test"); + checkpoint.compare_and_log(); +} + +#[test] +fn test_thread_registry() { + register_thread("test-thread", "test-component"); + record_thread_activity("test-thread"); + log_thread_stats(); + unregister_thread("test-thread"); +} + } diff --git a/src/core/shared/state.rs b/src/core/shared/state.rs index 90037c0f0..be2812bea 100644 --- a/src/core/shared/state.rs +++ b/src/core/shared/state.rs @@ -15,7 +15,7 @@ use crate::core::shared::test_utils::create_mock_auth_service; #[cfg(all(test, feature = "llm"))] use crate::core::shared::test_utils::MockLLMProvider; #[cfg(feature = "directory")] -use crate::directory::AuthService; +use crate::core::directory::AuthService; #[cfg(feature = "llm")] use crate::llm::LLMProvider; use crate::shared::models::BotResponse; diff --git a/src/core/shared/test_utils.rs b/src/core/shared/test_utils.rs index ee7c64d0e..c12858cc4 100644 --- a/src/core/shared/test_utils.rs +++ b/src/core/shared/test_utils.rs @@ -5,9 +5,9 @@ use crate::core::session::SessionManager; use crate::core::shared::analytics::MetricsCollector; use crate::core::shared::state::{AppState, Extensions}; #[cfg(feature = "directory")] -use crate::directory::client::ZitadelConfig; +use crate::core::directory::client::ZitadelConfig; #[cfg(feature = "directory")] -use crate::directory::AuthService; +use crate::core::directory::AuthService; #[cfg(feature = "llm")] use crate::llm::LLMProvider; use crate::shared::models::BotResponse; diff --git a/src/drive/vectordb.rs b/src/drive/vectordb.rs index 9bfabf7c9..ed3b4c988 100644 --- a/src/drive/vectordb.rs +++ b/src/drive/vectordb.rs @@ -1,4 +1,5 @@ use anyhow::Result; +#[cfg(feature = "sheet")] use calamine::Reader; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -10,514 +11,103 @@ use uuid::Uuid; #[cfg(feature = "vectordb")] use qdrant_client::{ - qdrant::{Distance, PointStruct, VectorParams}, - Qdrant, +qdrant::{Distance, PointStruct, VectorParams}, +Qdrant, }; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileDocument { - pub id: String, - pub file_path: String, - pub file_name: String, - pub file_type: String, - pub file_size: u64, - pub bucket: String, - pub content_text: String, - pub content_summary: Option, - pub created_at: DateTime, - pub modified_at: DateTime, - pub indexed_at: DateTime, - pub mime_type: Option, - pub tags: Vec, +pub id: String, +pub file_path: String, +pub file_name: String, +pub file_type: String, +pub file_size: u64, +pub bucket: String, +pub content_text: String, +pub content_summary: Option, +pub created_at: DateTime, +pub modified_at: DateTime, +pub indexed_at: DateTime, +pub mime_type: Option, +pub tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileSearchQuery { - pub query_text: String, - pub bucket: Option, - pub file_type: Option, - pub date_from: Option>, - pub date_to: Option>, - pub tags: Vec, - pub limit: usize, +pub query_text: String, +pub bucket: Option, +pub file_type: Option, +pub date_from: Option>, +pub date_to: Option>, +pub tags: Vec, +pub limit: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileSearchResult { - pub file: FileDocument, - pub score: f32, - pub snippet: String, - pub highlights: Vec, +pub file: FileDocument, +pub score: f32, +pub snippet: String, +pub highlights: Vec, } pub struct UserDriveVectorDB { - user_id: Uuid, - bot_id: Uuid, - collection_name: String, - db_path: PathBuf, - #[cfg(feature = "vectordb")] - client: Option>, +user_id: Uuid, +bot_id: Uuid, +collection_name: String, +db_path: PathBuf, +#[cfg(feature = "vectordb")] +client: Option>, } impl UserDriveVectorDB { - pub fn new(user_id: Uuid, bot_id: Uuid, db_path: PathBuf) -> Self { - let collection_name = format!("drive_{}_{}", bot_id, user_id); +pub fn new(user_id: Uuid, bot_id: Uuid, db_path: PathBuf) -> Self { +let collection_name = format!("drive_{}_{}", bot_id, user_id); - Self { - user_id, - bot_id, - collection_name, - db_path, - #[cfg(feature = "vectordb")] - client: None, - } - } - pub fn user_id(&self) -> Uuid { - self.user_id - } - - pub fn bot_id(&self) -> Uuid { - self.bot_id - } - - pub fn collection_name(&self) -> &str { - &self.collection_name - } - - pub fn db_path(&self) -> &std::path::Path { - &self.db_path - } - - #[cfg(feature = "vectordb")] - pub async fn initialize(&mut self, qdrant_url: &str) -> Result<()> { - log::trace!( - "Initializing vectordb, fallback path: {}", - self.db_path.display() - ); - let client = Qdrant::from_url(qdrant_url).build()?; - - let collections = client.list_collections().await?; - let exists = { - let collections_guard = collections.collections; - collections_guard - .iter() - .any(|c| c.name == self.collection_name) - }; - - if !exists { - client - .create_collection( - qdrant_client::qdrant::CreateCollectionBuilder::new(&self.collection_name) - .vectors_config(VectorParams { - size: 1536, - distance: Distance::Cosine.into(), - ..Default::default() - }), - ) - .await?; - - log::info!("Initialized vector DB collection: {}", self.collection_name); - } - - self.client = Some(Arc::new(client)); - Ok(()) - } - - #[cfg(not(feature = "vectordb"))] - pub async fn initialize(&mut self, _qdrant_url: &str) -> Result<()> { - log::warn!("Vector DB feature not enabled, using fallback storage"); - fs::create_dir_all(&self.db_path).await?; - Ok(()) - } - - #[cfg(feature = "vectordb")] - pub async fn index_file(&self, file: &FileDocument, embedding: Vec) -> Result<()> { - let client = self - .client - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; - - let payload: qdrant_client::Payload = serde_json::to_value(file)? - .as_object() - .cloned() - .unwrap_or_default() - .into_iter() - .map(|(k, v)| (k, qdrant_client::qdrant::Value::from(v.to_string()))) - .collect::>() - .into(); - - let point = PointStruct::new(file.id.clone(), embedding, payload); - - client - .upsert_points(qdrant_client::qdrant::UpsertPointsBuilder::new( - &self.collection_name, - vec![point], - )) - .await?; - - log::debug!("Indexed file: {} - {}", file.id, file.file_name); - Ok(()) - } - - #[cfg(not(feature = "vectordb"))] - pub async fn index_file(&self, file: &FileDocument, _embedding: Vec) -> Result<()> { - let file_path = self.db_path.join(format!("{}.json", file.id)); - let json = serde_json::to_string_pretty(file)?; - fs::write(file_path, json).await?; - Ok(()) - } - - pub async fn index_files_batch(&self, files: &[(FileDocument, Vec)]) -> Result<()> { + Self { + user_id, + bot_id, + collection_name, + db_path, #[cfg(feature = "vectordb")] - { - let client = self - .client - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; - - let points: Vec = files - .iter() - .filter_map(|(file, embedding)| { - serde_json::to_value(file).ok().and_then(|v| { - v.as_object().map(|m| { - let payload: qdrant_client::Payload = m - .clone() - .into_iter() - .map(|(k, v)| { - (k, qdrant_client::qdrant::Value::from(v.to_string())) - }) - .collect::>() - .into(); - PointStruct::new(file.id.clone(), embedding.clone(), payload) - }) - }) - }) - .collect(); - - if !points.is_empty() { - client - .upsert_points(qdrant_client::qdrant::UpsertPointsBuilder::new( - &self.collection_name, - points, - )) - .await?; - } - } - - #[cfg(not(feature = "vectordb"))] - { - for (file, embedding) in files { - self.index_file(file, embedding.clone()).await?; - } - } - - Ok(()) + client: None, } +} - #[cfg(feature = "vectordb")] - pub async fn search( - &self, - query: &FileSearchQuery, - query_embedding: Vec, - ) -> Result> { - let client = self - .client - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; +pub fn user_id(&self) -> Uuid { + self.user_id +} - let filter = - if query.bucket.is_some() || query.file_type.is_some() || !query.tags.is_empty() { - let mut conditions = Vec::new(); +pub fn bot_id(&self) -> Uuid { + self.bot_id +} - if let Some(bucket) = &query.bucket { - conditions.push(qdrant_client::qdrant::Condition::matches( - "bucket", - bucket.clone(), - )); - } +pub fn collection_name(&self) -> &str { + &self.collection_name +} - if let Some(file_type) = &query.file_type { - conditions.push(qdrant_client::qdrant::Condition::matches( - "file_type", - file_type.clone(), - )); - } +pub fn db_path(&self) -> &std::path::Path { + &self.db_path +} - for tag in &query.tags { - conditions.push(qdrant_client::qdrant::Condition::matches( - "tags", - tag.clone(), - )); - } +#[cfg(feature = "vectordb")] +pub async fn initialize(&mut self, qdrant_url: &str) -> Result<()> { + log::trace!( + "Initializing vectordb, fallback path: {}", + self.db_path.display() + ); + let client = Qdrant::from_url(qdrant_url).build()?; - if conditions.is_empty() { - None - } else { - Some(qdrant_client::qdrant::Filter::must(conditions)) - } - } else { - None - }; - - let mut search_builder = qdrant_client::qdrant::SearchPointsBuilder::new( - &self.collection_name, - query_embedding, - query.limit as u64, - ) - .with_payload(true); - - if let Some(f) = filter { - search_builder = search_builder.filter(f); - } - - let search_result = client.search_points(search_builder).await?; - - let mut results = Vec::new(); - for point in search_result.result { - let payload = &point.payload; - if !payload.is_empty() { - let get_str = |key: &str| -> String { - payload - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_default() - }; - - let file = FileDocument { - id: get_str("id"), - file_path: get_str("file_path"), - file_name: get_str("file_name"), - file_type: get_str("file_type"), - file_size: payload - .get("file_size") - .and_then(|v| v.as_integer()) - .unwrap_or(0) as u64, - bucket: get_str("bucket"), - content_text: get_str("content_text"), - content_summary: payload - .get("content_summary") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - created_at: chrono::Utc::now(), - modified_at: chrono::Utc::now(), - indexed_at: chrono::Utc::now(), - mime_type: payload - .get("mime_type") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - tags: vec![], - }; - - let snippet = Self::create_snippet(&file.content_text, &query.query_text, 200); - let highlights = Self::extract_highlights(&file.content_text, &query.query_text, 3); - - results.push(FileSearchResult { - file, - score: point.score, - snippet, - highlights, - }); - } - } - - Ok(results) - } - - #[cfg(not(feature = "vectordb"))] - pub async fn search( - &self, - query: &FileSearchQuery, - _query_embedding: Vec, - ) -> Result> { - let mut results = Vec::new(); - let mut entries = fs::read_dir(&self.db_path).await?; - - while let Some(entry) = entries.next_entry().await? { - if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { - let content = fs::read_to_string(entry.path()).await?; - if let Ok(file) = serde_json::from_str::(&content) { - if let Some(bucket) = &query.bucket { - if &file.bucket != bucket { - continue; - } - } - - if let Some(file_type) = &query.file_type { - if &file.file_type != file_type { - continue; - } - } - - let query_lower = query.query_text.to_lowercase(); - if file.file_name.to_lowercase().contains(&query_lower) - || file.content_text.to_lowercase().contains(&query_lower) - || file - .content_summary - .as_ref() - .is_some_and(|s| s.to_lowercase().contains(&query_lower)) - { - let snippet = - Self::create_snippet(&file.content_text, &query.query_text, 200); - let highlights = - Self::extract_highlights(&file.content_text, &query.query_text, 3); - - results.push(FileSearchResult { - file, - score: 1.0, - snippet, - highlights, - }); - } - } - - if results.len() >= query.limit { - break; - } - } - } - - Ok(results) - } - - fn create_snippet(content: &str, query: &str, max_length: usize) -> String { - let content_lower = content.to_lowercase(); - let query_lower = query.to_lowercase(); - - if let Some(pos) = content_lower.find(&query_lower) { - let start = pos.saturating_sub(max_length / 2); - let end = (pos + query.len() + max_length / 2).min(content.len()); - let snippet = &content[start..end]; - - if start > 0 && end < content.len() { - format!("...{}...", snippet) - } else if start > 0 { - format!("...{}", snippet) - } else if end < content.len() { - format!("{}...", snippet) - } else { - snippet.to_string() - } - } else if content.len() > max_length { - format!("{}...", &content[..max_length]) - } else { - content.to_string() - } - } - - fn extract_highlights(content: &str, query: &str, max_highlights: usize) -> Vec { - let content_lower = content.to_lowercase(); - let query_lower = query.to_lowercase(); - let mut highlights = Vec::new(); - let mut pos = 0; - - while let Some(found_pos) = content_lower[pos..].find(&query_lower) { - let actual_pos = pos + found_pos; - let start = actual_pos.saturating_sub(40); - let end = (actual_pos + query.len() + 40).min(content.len()); - - highlights.push(content[start..end].to_string()); - - if highlights.len() >= max_highlights { - break; - } - - pos = actual_pos + query.len(); - } - - highlights - } - - #[cfg(feature = "vectordb")] - pub async fn delete_file(&self, file_id: &str) -> Result<()> { - let client = self - .client - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; - - client - .delete_points( - qdrant_client::qdrant::DeletePointsBuilder::new(&self.collection_name).points( - vec![qdrant_client::qdrant::PointId::from(file_id.to_string())], - ), - ) - .await?; - - log::debug!("Deleted file from index: {}", file_id); - Ok(()) - } - - #[cfg(not(feature = "vectordb"))] - pub async fn delete_file(&self, file_id: &str) -> Result<()> { - let file_path = self.db_path.join(format!("{}.json", file_id)); - if file_path.exists() { - fs::remove_file(file_path).await?; - } - Ok(()) - } - - #[cfg(feature = "vectordb")] - pub async fn get_count(&self) -> Result { - let client = self - .client - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; - - let info = client.collection_info(self.collection_name.clone()).await?; - - Ok(info - .result - .ok_or_else(|| anyhow::anyhow!("No result from collection info"))? - .points_count - .unwrap_or(0)) - } - - #[cfg(not(feature = "vectordb"))] - pub async fn get_count(&self) -> Result { - let mut count = 0; - let mut entries = fs::read_dir(&self.db_path).await?; - - while let Some(entry) = entries.next_entry().await? { - if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { - count += 1; - } - } - - Ok(count) - } - - pub async fn update_file_metadata(&self, file_id: &str, tags: Vec) -> Result<()> { - #[cfg(not(feature = "vectordb"))] - { - let file_path = self.db_path.join(format!("{}.json", file_id)); - if file_path.exists() { - let content = fs::read_to_string(&file_path).await?; - let mut file: FileDocument = serde_json::from_str(&content)?; - file.tags = tags; - let json = serde_json::to_string_pretty(&file)?; - fs::write(file_path, json).await?; - } - } - - #[cfg(feature = "vectordb")] - { - let _ = (file_id, tags); - log::warn!("Metadata update not yet implemented for Qdrant backend"); - } - - Ok(()) - } - - #[cfg(feature = "vectordb")] - pub async fn clear(&self) -> Result<()> { - let client = self - .client - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; - - client.delete_collection(&self.collection_name).await?; + let collections = client.list_collections().await?; + let exists = { + let collections_guard = collections.collections; + collections_guard + .iter() + .any(|c| c.name == self.collection_name) + }; + if !exists { client .create_collection( qdrant_client::qdrant::CreateCollectionBuilder::new(&self.collection_name) @@ -529,213 +119,637 @@ impl UserDriveVectorDB { ) .await?; - log::info!("Cleared drive vector collection: {}", self.collection_name); - Ok(()) + log::info!("Initialized vector DB collection: {}", self.collection_name); + } + + self.client = Some(Arc::new(client)); + Ok(()) +} + +#[cfg(not(feature = "vectordb"))] +pub async fn initialize(&mut self, _qdrant_url: &str) -> Result<()> { + log::warn!("Vector DB feature not enabled, using fallback storage"); + fs::create_dir_all(&self.db_path).await?; + Ok(()) +} + +#[cfg(feature = "vectordb")] +pub async fn index_file(&self, file: &FileDocument, embedding: Vec) -> Result<()> { + let client = self + .client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; + + let payload: qdrant_client::Payload = serde_json::to_value(file)? + .as_object() + .cloned() + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, qdrant_client::qdrant::Value::from(v.to_string()))) + .collect::>() + .into(); + + let point = PointStruct::new(file.id.clone(), embedding, payload); + + client + .upsert_points(qdrant_client::qdrant::UpsertPointsBuilder::new( + &self.collection_name, + vec![point], + )) + .await?; + + log::debug!("Indexed file: {} - {}", file.id, file.file_name); + Ok(()) +} + +#[cfg(not(feature = "vectordb"))] +pub async fn index_file(&self, file: &FileDocument, _embedding: Vec) -> Result<()> { + let file_path = self.db_path.join(format!("{}.json", file.id)); + let json = serde_json::to_string_pretty(file)?; + fs::write(file_path, json).await?; + Ok(()) +} + +pub async fn index_files_batch(&self, files: &[(FileDocument, Vec)]) -> Result<()> { + #[cfg(feature = "vectordb")] + { + let client = self + .client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; + + let points: Vec = files + .iter() + .filter_map(|(file, embedding)| { + serde_json::to_value(file).ok().and_then(|v| { + v.as_object().map(|m| { + let payload: qdrant_client::Payload = m + .clone() + .into_iter() + .map(|(k, v)| { + (k, qdrant_client::qdrant::Value::from(v.to_string())) + }) + .collect::>() + .into(); + PointStruct::new(file.id.clone(), embedding.clone(), payload) + }) + }) + }) + .collect(); + + if !points.is_empty() { + client + .upsert_points(qdrant_client::qdrant::UpsertPointsBuilder::new( + &self.collection_name, + points, + )) + .await?; + } } #[cfg(not(feature = "vectordb"))] - pub async fn clear(&self) -> Result<()> { - if self.db_path.exists() { - fs::remove_dir_all(&self.db_path).await?; - fs::create_dir_all(&self.db_path).await?; + { + for (file, embedding) in files { + self.index_file(file, embedding.clone()).await?; } - Ok(()) } + + Ok(()) +} + +#[cfg(feature = "vectordb")] +pub async fn search( + &self, + query: &FileSearchQuery, + query_embedding: Vec, +) -> Result> { + let client = self + .client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; + + let filter = + if query.bucket.is_some() || query.file_type.is_some() || !query.tags.is_empty() { + let mut conditions = Vec::new(); + + if let Some(bucket) = &query.bucket { + conditions.push(qdrant_client::qdrant::Condition::matches( + "bucket", + bucket.clone(), + )); + } + + if let Some(file_type) = &query.file_type { + conditions.push(qdrant_client::qdrant::Condition::matches( + "file_type", + file_type.clone(), + )); + } + + for tag in &query.tags { + conditions.push(qdrant_client::qdrant::Condition::matches( + "tags", + tag.clone(), + )); + } + + if conditions.is_empty() { + None + } else { + Some(qdrant_client::qdrant::Filter::must(conditions)) + } + } else { + None + }; + + let mut search_builder = qdrant_client::qdrant::SearchPointsBuilder::new( + &self.collection_name, + query_embedding, + query.limit as u64, + ) + .with_payload(true); + + if let Some(f) = filter { + search_builder = search_builder.filter(f); + } + + let search_result = client.search_points(search_builder).await?; + + let mut results = Vec::new(); + for point in search_result.result { + let payload = &point.payload; + if !payload.is_empty() { + let get_str = |key: &str| -> String { + payload + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default() + }; + + let file = FileDocument { + id: get_str("id"), + file_path: get_str("file_path"), + file_name: get_str("file_name"), + file_type: get_str("file_type"), + file_size: payload + .get("file_size") + .and_then(|v| v.as_integer()) + .unwrap_or(0) as u64, + bucket: get_str("bucket"), + content_text: get_str("content_text"), + content_summary: payload + .get("content_summary") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + created_at: chrono::Utc::now(), + modified_at: chrono::Utc::now(), + indexed_at: chrono::Utc::now(), + mime_type: payload + .get("mime_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + tags: vec![], + }; + + let snippet = Self::create_snippet(&file.content_text, &query.query_text, 200); + let highlights = Self::extract_highlights(&file.content_text, &query.query_text, 3); + + results.push(FileSearchResult { + file, + score: point.score, + snippet, + highlights, + }); + } + } + + Ok(results) +} + +#[cfg(not(feature = "vectordb"))] +pub async fn search( + &self, + query: &FileSearchQuery, + _query_embedding: Vec, +) -> Result> { + let mut results = Vec::new(); + let mut entries = fs::read_dir(&self.db_path).await?; + + while let Some(entry) = entries.next_entry().await? { + if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { + let content = fs::read_to_string(entry.path()).await?; + if let Ok(file) = serde_json::from_str::(&content) { + if let Some(bucket) = &query.bucket { + if &file.bucket != bucket { + continue; + } + } + + if let Some(file_type) = &query.file_type { + if &file.file_type != file_type { + continue; + } + } + + let query_lower = query.query_text.to_lowercase(); + if file.file_name.to_lowercase().contains(&query_lower) + || file.content_text.to_lowercase().contains(&query_lower) + || file + .content_summary + .as_ref() + .is_some_and(|s| s.to_lowercase().contains(&query_lower)) + { + let snippet = + Self::create_snippet(&file.content_text, &query.query_text, 200); + let highlights = + Self::extract_highlights(&file.content_text, &query.query_text, 3); + + results.push(FileSearchResult { + file, + score: 1.0, + snippet, + highlights, + }); + } + } + + if results.len() >= query.limit { + break; + } + } + } + + Ok(results) +} + +fn create_snippet(content: &str, query: &str, max_length: usize) -> String { + let content_lower = content.to_lowercase(); + let query_lower = query.to_lowercase(); + + if let Some(pos) = content_lower.find(&query_lower) { + let start = pos.saturating_sub(max_length / 2); + let end = (pos + query.len() + max_length / 2).min(content.len()); + let snippet = &content[start..end]; + + if start > 0 && end < content.len() { + format!("...{}...", snippet) + } else if start > 0 { + format!("...{}", snippet) + } else if end < content.len() { + format!("{}...", snippet) + } else { + snippet.to_string() + } + } else if content.len() > max_length { + format!("{}...", &content[..max_length]) + } else { + content.to_string() + } +} + +fn extract_highlights(content: &str, query: &str, max_highlights: usize) -> Vec { + let content_lower = content.to_lowercase(); + let query_lower = query.to_lowercase(); + let mut highlights = Vec::new(); + let mut pos = 0; + + while let Some(found_pos) = content_lower[pos..].find(&query_lower) { + let actual_pos = pos + found_pos; + let start = actual_pos.saturating_sub(40); + let end = (actual_pos + query.len() + 40).min(content.len()); + + highlights.push(content[start..end].to_string()); + + if highlights.len() >= max_highlights { + break; + } + + pos = actual_pos + query.len(); + } + + highlights +} + +#[cfg(feature = "vectordb")] +pub async fn delete_file(&self, file_id: &str) -> Result<()> { + let client = self + .client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; + + client + .delete_points( + qdrant_client::qdrant::DeletePointsBuilder::new(&self.collection_name).points( + vec![qdrant_client::qdrant::PointId::from(file_id.to_string())], + ), + ) + .await?; + + log::debug!("Deleted file from index: {}", file_id); + Ok(()) +} + +#[cfg(not(feature = "vectordb"))] +pub async fn delete_file(&self, file_id: &str) -> Result<()> { + let file_path = self.db_path.join(format!("{}.json", file_id)); + if file_path.exists() { + fs::remove_file(file_path).await?; + } + Ok(()) +} + +#[cfg(feature = "vectordb")] +pub async fn get_count(&self) -> Result { + let client = self + .client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; + + let info = client.collection_info(self.collection_name.clone()).await?; + + Ok(info + .result + .ok_or_else(|| anyhow::anyhow!("No result from collection info"))? + .points_count + .unwrap_or(0)) +} + +#[cfg(not(feature = "vectordb"))] +pub async fn get_count(&self) -> Result { + let mut count = 0; + let mut entries = fs::read_dir(&self.db_path).await?; + + while let Some(entry) = entries.next_entry().await? { + if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { + count += 1; + } + } + + Ok(count) +} + +pub async fn update_file_metadata(&self, file_id: &str, tags: Vec) -> Result<()> { + #[cfg(not(feature = "vectordb"))] + { + let file_path = self.db_path.join(format!("{}.json", file_id)); + if file_path.exists() { + let content = fs::read_to_string(&file_path).await?; + let mut file: FileDocument = serde_json::from_str(&content)?; + file.tags = tags; + let json = serde_json::to_string_pretty(&file)?; + fs::write(file_path, json).await?; + } + } + + #[cfg(feature = "vectordb")] + { + let _ = (file_id, tags); + log::warn!("Metadata update not yet implemented for Qdrant backend"); + } + + Ok(()) +} + +#[cfg(feature = "vectordb")] +pub async fn clear(&self) -> Result<()> { + let client = self + .client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Vector DB not initialized"))?; + + client.delete_collection(&self.collection_name).await?; + + client + .create_collection( + qdrant_client::qdrant::CreateCollectionBuilder::new(&self.collection_name) + .vectors_config(VectorParams { + size: 1536, + distance: Distance::Cosine.into(), + ..Default::default() + }), + ) + .await?; + + log::info!("Cleared drive vector collection: {}", self.collection_name); + Ok(()) +} + +#[cfg(not(feature = "vectordb"))] +pub async fn clear(&self) -> Result<()> { + if self.db_path.exists() { + fs::remove_dir_all(&self.db_path).await?; + fs::create_dir_all(&self.db_path).await?; + } + Ok(()) +} + } #[derive(Debug)] pub struct FileContentExtractor; impl FileContentExtractor { - pub async fn extract_text(file_path: &PathBuf, mime_type: &str) -> Result { - match mime_type { - "text/plain" | "text/markdown" | "text/csv" => { - let content = fs::read_to_string(file_path).await?; - Ok(content) - } +pub async fn extract_text(file_path: &PathBuf, mime_type: &str) -> Result { +match mime_type { +"text/plain" | "text/markdown" | "text/csv" => { +let content = fs::read_to_string(file_path).await?; +Ok(content) +} - t if t.starts_with("text/") => { - let content = fs::read_to_string(file_path).await?; - Ok(content) - } - "application/pdf" => { - log::info!("PDF extraction for {}", file_path.display()); - Self::extract_pdf_text(file_path).await - } + t if t.starts_with("text/") => { + let content = fs::read_to_string(file_path).await?; + Ok(content) + } - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - | "application/msword" => { - log::info!("Word document extraction for {}", file_path.display()); - Self::extract_docx_text(file_path).await - } + "application/pdf" => { + log::info!("PDF extraction for {}", file_path.display()); + Self::extract_pdf_text(file_path).await + } - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - | "application/vnd.ms-excel" => { - log::info!("Spreadsheet extraction for {}", file_path.display()); + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/msword" => { + log::info!("Word document extraction for {}", file_path.display()); + Self::extract_docx_text(file_path).await + } + + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.ms-excel" => { + log::info!("Spreadsheet extraction for {}", file_path.display()); + #[cfg(feature = "sheet")] + { Self::extract_xlsx_text(file_path).await } - - "application/json" => { - let content = fs::read_to_string(file_path).await?; - - match serde_json::from_str::(&content) { - Ok(json) => Ok(serde_json::to_string_pretty(&json)?), - Err(_) => Ok(content), - } - } - - "text/xml" | "application/xml" | "text/html" => { - let content = fs::read_to_string(file_path).await?; - - let tag_regex = regex::Regex::new(r"<[^>]+>").expect("valid regex"); - let text = tag_regex.replace_all(&content, " ").to_string(); - Ok(text.trim().to_string()) - } - - "text/rtf" | "application/rtf" => { - let content = fs::read_to_string(file_path).await?; - - let control_regex = regex::Regex::new(r"\\[a-z]+[\-0-9]*[ ]?").expect("valid regex"); - let group_regex = regex::Regex::new(r"[\{\}]").expect("valid regex"); - - let mut text = control_regex.replace_all(&content, " ").to_string(); - text = group_regex.replace_all(&text, "").to_string(); - - Ok(text.trim().to_string()) - } - - _ => { - log::warn!("Unsupported file type for indexing: {}", mime_type); + #[cfg(not(feature = "sheet"))] + { + log::warn!("XLSX extraction requires 'sheet' feature"); Ok(String::new()) } } - } - async fn extract_pdf_text(file_path: &PathBuf) -> Result { - let bytes = fs::read(file_path).await?; + "application/json" => { + let content = fs::read_to_string(file_path).await?; - match pdf_extract::extract_text_from_mem(&bytes) { - Ok(text) => { - let cleaned = text - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect::>() - .join("\n"); - Ok(cleaned) - } - Err(e) => { - log::warn!("PDF extraction failed for {}: {}", file_path.display(), e); - Ok(String::new()) + match serde_json::from_str::(&content) { + Ok(json) => Ok(serde_json::to_string_pretty(&json)?), + Err(_) => Ok(content), } } - } - async fn extract_docx_text(file_path: &Path) -> Result { - let path = file_path.to_path_buf(); + "text/xml" | "application/xml" | "text/html" => { + let content = fs::read_to_string(file_path).await?; - let result = tokio::task::spawn_blocking(move || { - let file = std::fs::File::open(&path)?; - let mut archive = zip::ZipArchive::new(file)?; - - let mut content = String::new(); - - if let Ok(mut document) = archive.by_name("word/document.xml") { - let mut xml_content = String::new(); - std::io::Read::read_to_string(&mut document, &mut xml_content)?; - - let text_regex = regex::Regex::new(r"]*>([^<]*)").expect("valid regex"); - - content = text_regex - .captures_iter(&xml_content) - .filter_map(|c| c.get(1).map(|m| m.as_str())) - .collect::>() - .join(""); - - content = content.split("").collect::>().join("\n"); - } - - Ok::(content) - }) - .await?; - - match result { - Ok(text) => Ok(text), - Err(e) => { - log::warn!("DOCX extraction failed for {}: {}", file_path.display(), e); - Ok(String::new()) - } - } - } - - async fn extract_xlsx_text(file_path: &Path) -> Result { - let path = file_path.to_path_buf(); - - let result = tokio::task::spawn_blocking(move || { - let mut workbook: calamine::Xlsx<_> = calamine::open_workbook(&path)?; - let mut content = String::new(); - - for sheet_name in workbook.sheet_names() { - if let Ok(range) = workbook.worksheet_range(&sheet_name) { - use std::fmt::Write; - let _ = writeln!(&mut content, "=== {} ===", sheet_name); - - for row in range.rows() { - let row_text: Vec = row - .iter() - .map(|cell| match cell { - calamine::Data::Empty => String::new(), - calamine::Data::String(s) - | calamine::Data::DateTimeIso(s) - | calamine::Data::DurationIso(s) => s.clone(), - calamine::Data::Float(f) => f.to_string(), - calamine::Data::Int(i) => i.to_string(), - calamine::Data::Bool(b) => b.to_string(), - calamine::Data::Error(e) => format!("{e:?}"), - calamine::Data::DateTime(dt) => dt.to_string(), - }) - .collect(); - - let line = row_text.join("\t"); - if !line.trim().is_empty() { - content.push_str(&line); - content.push('\n'); - } - } - content.push('\n'); - } - } - - Ok::(content) - }) - .await?; - - match result { - Ok(text) => Ok(text), - Err(e) => { - log::warn!("XLSX extraction failed for {}: {}", file_path.display(), e); - Ok(String::new()) - } - } - } - - pub fn should_index(mime_type: &str, file_size: u64) -> bool { - if file_size > 10 * 1024 * 1024 { - return false; + let tag_regex = regex::Regex::new(r"<[^>]+>").expect("valid regex"); + let text = tag_regex.replace_all(&content, " ").to_string(); + Ok(text.trim().to_string()) } - matches!( - mime_type, - "text/plain" - | "text/markdown" - | "text/csv" - | "text/html" - | "application/json" - | "text/x-python" - | "text/x-rust" - | "text/javascript" - | "text/x-java" - ) + "text/rtf" | "application/rtf" => { + let content = fs::read_to_string(file_path).await?; + + let control_regex = regex::Regex::new(r"\\[a-z]+[\-0-9]*[ ]?").expect("valid regex"); + let group_regex = regex::Regex::new(r"[\{\}]").expect("valid regex"); + + let mut text = control_regex.replace_all(&content, " ").to_string(); + text = group_regex.replace_all(&text, "").to_string(); + + Ok(text.trim().to_string()) + } + + _ => { + log::warn!("Unsupported file type for indexing: {}", mime_type); + Ok(String::new()) + } } } + +async fn extract_pdf_text(file_path: &PathBuf) -> Result { + let bytes = fs::read(file_path).await?; + + match pdf_extract::extract_text_from_mem(&bytes) { + Ok(text) => { + let cleaned = text + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect::>() + .join("\n"); + Ok(cleaned) + } + Err(e) => { + log::warn!("PDF extraction failed for {}: {}", file_path.display(), e); + Ok(String::new()) + } + } +} + +async fn extract_docx_text(file_path: &Path) -> Result { + let path = file_path.to_path_buf(); + + let result = tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path)?; + let mut archive = zip::ZipArchive::new(file)?; + + let mut content = String::new(); + + if let Ok(mut document) = archive.by_name("word/document.xml") { + let mut xml_content = String::new(); + std::io::Read::read_to_string(&mut document, &mut xml_content)?; + + let text_regex = regex::Regex::new(r"]*>([^<]*)").expect("valid regex"); + + content = text_regex + .captures_iter(&xml_content) + .filter_map(|c| c.get(1).map(|m| m.as_str())) + .collect::>() + .join(""); + + content = content.split("").collect::>().join("\n"); + } + + Ok::(content) + }) + .await?; + + match result { + Ok(text) => Ok(text), + Err(e) => { + log::warn!("DOCX extraction failed for {}: {}", file_path.display(), e); + Ok(String::new()) + } + } +} + +#[cfg(feature = "sheet")] +async fn extract_xlsx_text(file_path: &Path) -> Result { + let path = file_path.to_path_buf(); + + let result = tokio::task::spawn_blocking(move || { + let mut workbook: calamine::Xlsx<_> = calamine::open_workbook(&path)?; + let mut content = String::new(); + + for sheet_name in workbook.sheet_names() { + if let Ok(range) = workbook.worksheet_range(&sheet_name) { + use std::fmt::Write; + let _ = writeln!(&mut content, "=== {} ===", sheet_name); + + for row in range.rows() { + let row_text: Vec = row + .iter() + .map(|cell| match cell { + calamine::Data::Empty => String::new(), + calamine::Data::String(s) + | calamine::Data::DateTimeIso(s) + | calamine::Data::DurationIso(s) => s.clone(), + calamine::Data::Float(f) => f.to_string(), + calamine::Data::Int(i) => i.to_string(), + calamine::Data::Bool(b) => b.to_string(), + calamine::Data::Error(e) => format!("{e:?}"), + calamine::Data::DateTime(dt) => dt.to_string(), + }) + .collect(); + + let line = row_text.join("\t"); + if !line.trim().is_empty() { + content.push_str(&line); + content.push('\n'); + } + } + content.push('\n'); + } + } + + Ok::(content) + }) + .await?; + + match result { + Ok(text) => Ok(text), + Err(e) => { + log::warn!("XLSX extraction failed for {}: {}", file_path.display(), e); + Ok(String::new()) + } + } +} + +pub fn should_index(mime_type: &str, file_size: u64) -> bool { + if file_size > 10 * 1024 * 1024 { + return false; + } + + matches!( + mime_type, + "text/plain" + | "text/markdown" + | "text/csv" + | "text/html" + | "application/json" + | "text/x-python" + | "text/x-rust" + | "text/javascript" + | "text/x-java" + ) +} + +} diff --git a/src/main.rs b/src/main.rs index 069dd72ac..3656ccc29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,11 +29,15 @@ pub mod tickets; pub mod attendant; pub mod analytics; pub mod designer; +#[cfg(feature = "docs")] pub mod docs; pub mod learn; +#[cfg(feature = "paper")] pub mod paper; pub mod research; +#[cfg(feature = "sheet")] pub mod sheet; +#[cfg(feature = "slides")] pub mod slides; pub mod social; pub mod sources; @@ -203,7 +207,7 @@ use crate::core::bot_database::BotDatabaseManager; use crate::core::config::AppConfig; #[cfg(feature = "directory")] -use crate::directory::auth_handler; +use crate::core::directory::auth_handler; use package_manager::InstallMode; use session::{create_session, get_session_history, get_sessions, start_session}; @@ -448,11 +452,23 @@ async fn run_axum_server( api_router = api_router.merge(crate::analytics::configure_analytics_routes()); api_router = api_router.merge(crate::core::i18n::configure_i18n_routes()); - api_router = api_router.merge(crate::docs::configure_docs_routes()); - api_router = api_router.merge(crate::paper::configure_paper_routes()); - api_router = api_router.merge(crate::sheet::configure_sheet_routes()); - api_router = api_router.merge(crate::slides::configure_slides_routes()); - api_router = api_router.merge(crate::video::configure_video_routes()); +#[cfg(feature = "docs")] +{ +api_router = api_router.merge(crate::docs::configure_docs_routes()); +} +#[cfg(feature = "paper")] +{ +api_router = api_router.merge(crate::paper::configure_paper_routes()); +} +#[cfg(feature = "sheet")] +{ +api_router = api_router.merge(crate::sheet::configure_sheet_routes()); +} +#[cfg(feature = "slides")] +{ +api_router = api_router.merge(crate::slides::configure_slides_routes()); +} +api_router = api_router.merge(crate::video::configure_video_routes()); api_router = api_router.merge(crate::video::ui::configure_video_ui_routes()); api_router = api_router.merge(crate::research::configure_research_routes()); api_router = api_router.merge(crate::research::ui::configure_research_ui_routes()); @@ -484,12 +500,18 @@ async fn run_axum_server( api_router = api_router.merge(crate::player::configure_player_routes()); api_router = api_router.merge(crate::canvas::configure_canvas_routes()); api_router = api_router.merge(crate::canvas::ui::configure_canvas_ui_routes()); - api_router = api_router.merge(crate::social::configure_social_routes()); - api_router = api_router.merge(crate::social::ui::configure_social_ui_routes()); - api_router = api_router.merge(crate::email::ui::configure_email_ui_routes()); - api_router = api_router.merge(crate::learn::ui::configure_learn_ui_routes()); - api_router = api_router.merge(crate::meet::ui::configure_meet_ui_routes()); - api_router = api_router.merge(crate::contacts::crm_ui::configure_crm_routes()); +api_router = api_router.merge(crate::social::configure_social_routes()); +api_router = api_router.merge(crate::social::ui::configure_social_ui_routes()); +api_router = api_router.merge(crate::learn::ui::configure_learn_ui_routes()); +#[cfg(feature = "email")] +{ +api_router = api_router.merge(crate::email::ui::configure_email_ui_routes()); +} +#[cfg(feature = "meet")] +{ +api_router = api_router.merge(crate::meet::ui::configure_meet_ui_routes()); +} +api_router = api_router.merge(crate::contacts::crm_ui::configure_crm_routes()); api_router = api_router.merge(crate::contacts::crm::configure_crm_api_routes()); api_router = api_router.merge(crate::billing::billing_ui::configure_billing_routes()); api_router = api_router.merge(crate::billing::api::configure_billing_api_routes()); @@ -1060,7 +1082,7 @@ use crate::core::config::ConfigManager; info!("Loaded Zitadel config from {}: url={}", config_path, base_url); - crate::directory::client::ZitadelConfig { + crate::core::directory::client::ZitadelConfig { issuer_url: base_url.to_string(), issuer: base_url.to_string(), client_id: client_id.to_string(), @@ -1072,7 +1094,7 @@ use crate::core::config::ConfigManager; } } else { warn!("Failed to parse directory_config.json, using defaults"); - crate::directory::client::ZitadelConfig { + crate::core::directory::client::ZitadelConfig { issuer_url: "http://localhost:8300".to_string(), issuer: "http://localhost:8300".to_string(), client_id: String::new(), @@ -1085,7 +1107,7 @@ use crate::core::config::ConfigManager; } } else { warn!("directory_config.json not found, using default Zitadel config"); - crate::directory::client::ZitadelConfig { + crate::core::directory::client::ZitadelConfig { issuer_url: "http://localhost:8300".to_string(), issuer: "http://localhost:8300".to_string(), client_id: String::new(), @@ -1099,7 +1121,7 @@ use crate::core::config::ConfigManager; }; #[cfg(feature = "directory")] let auth_service = Arc::new(tokio::sync::Mutex::new( - crate::directory::AuthService::new(zitadel_config.clone()).map_err(|e| std::io::Error::other(format!("Failed to create auth service: {}", e)))?, + crate::core::directory::AuthService::new(zitadel_config.clone()).map_err(|e| std::io::Error::other(format!("Failed to create auth service: {}", e)))?, )); #[cfg(feature = "directory")] @@ -1110,22 +1132,22 @@ use crate::core::config::ConfigManager; Ok(pat_token) => { let pat_token = pat_token.trim().to_string(); info!("Using admin PAT token for bootstrap authentication"); - crate::directory::client::ZitadelClient::with_pat_token(zitadel_config, pat_token) + crate::core::directory::client::ZitadelClient::with_pat_token(zitadel_config, pat_token) .map_err(|e| std::io::Error::other(format!("Failed to create bootstrap client with PAT: {}", e)))? } Err(e) => { warn!("Failed to read admin PAT token: {}, falling back to OAuth2", e); - crate::directory::client::ZitadelClient::new(zitadel_config) + crate::core::directory::client::ZitadelClient::new(zitadel_config) .map_err(|e| std::io::Error::other(format!("Failed to create bootstrap client: {}", e)))? } } } else { info!("Admin PAT not found, using OAuth2 client credentials for bootstrap"); - crate::directory::client::ZitadelClient::new(zitadel_config) + crate::core::directory::client::ZitadelClient::new(zitadel_config) .map_err(|e| std::io::Error::other(format!("Failed to create bootstrap client: {}", e)))? }; - match crate::directory::bootstrap::check_and_bootstrap_admin(&bootstrap_client).await { + match crate::core::directory::bootstrap::check_and_bootstrap_admin(&bootstrap_client).await { Ok(Some(_)) => { info!("Bootstrap completed - admin credentials displayed in console"); } diff --git a/src/monitoring/mod.rs b/src/monitoring/mod.rs index 5ab21fa51..d0d5601d9 100644 --- a/src/monitoring/mod.rs +++ b/src/monitoring/mod.rs @@ -1,7 +1,7 @@ - use axum::{extract::State, response::Html, routing::get, Router}; use chrono::Local; use std::sync::Arc; +#[cfg(feature = "monitoring")] use sysinfo::{Disks, Networks, System}; use crate::core::urls::ApiUrls; @@ -10,35 +10,35 @@ use crate::shared::state::AppState; pub mod real_time; pub mod tracing; - pub fn configure() -> Router> { - Router::new() - .route(ApiUrls::MONITORING_DASHBOARD, get(dashboard)) - .route(ApiUrls::MONITORING_SERVICES, get(services)) - .route(ApiUrls::MONITORING_RESOURCES, get(resources)) - .route(ApiUrls::MONITORING_LOGS, get(logs)) - .route(ApiUrls::MONITORING_LLM, get(llm_metrics)) - .route(ApiUrls::MONITORING_HEALTH, get(health)) - // Additional endpoints expected by the frontend - .route("/api/ui/monitoring/timestamp", get(timestamp)) - .route("/api/ui/monitoring/bots", get(bots)) - .route("/api/ui/monitoring/services/status", get(services_status)) - .route("/api/ui/monitoring/resources/bars", get(resources_bars)) - .route("/api/ui/monitoring/activity/latest", get(activity_latest)) - .route("/api/ui/monitoring/metric/sessions", get(metric_sessions)) - .route("/api/ui/monitoring/metric/messages", get(metric_messages)) - .route("/api/ui/monitoring/metric/response_time", get(metric_response_time)) - .route("/api/ui/monitoring/trend/sessions", get(trend_sessions)) - .route("/api/ui/monitoring/rate/messages", get(rate_messages)) - // Aliases for frontend compatibility - .route("/api/ui/monitoring/sessions", get(sessions_panel)) - .route("/api/ui/monitoring/messages", get(messages_panel)) +Router::new() +.route(ApiUrls::MONITORING_DASHBOARD, get(dashboard)) +.route(ApiUrls::MONITORING_SERVICES, get(services)) +.route(ApiUrls::MONITORING_RESOURCES, get(resources)) +.route(ApiUrls::MONITORING_LOGS, get(logs)) +.route(ApiUrls::MONITORING_LLM, get(llm_metrics)) +.route(ApiUrls::MONITORING_HEALTH, get(health)) +// Additional endpoints expected by the frontend +.route("/api/ui/monitoring/timestamp", get(timestamp)) +.route("/api/ui/monitoring/bots", get(bots)) +.route("/api/ui/monitoring/services/status", get(services_status)) +.route("/api/ui/monitoring/resources/bars", get(resources_bars)) +.route("/api/ui/monitoring/activity/latest", get(activity_latest)) +.route("/api/ui/monitoring/metric/sessions", get(metric_sessions)) +.route("/api/ui/monitoring/metric/messages", get(metric_messages)) +.route("/api/ui/monitoring/metric/response_time", get(metric_response_time)) +.route("/api/ui/monitoring/trend/sessions", get(trend_sessions)) +.route("/api/ui/monitoring/rate/messages", get(rate_messages)) +// Aliases for frontend compatibility +.route("/api/ui/monitoring/sessions", get(sessions_panel)) +.route("/api/ui/monitoring/messages", get(messages_panel)) } - async fn dashboard(State(state): State>) -> Html { - let mut sys = System::new_all(); - sys.refresh_all(); +#[cfg(feature = "monitoring")] +let (cpu_usage, total_memory, used_memory, memory_percent, uptime_str) = { +let mut sys = System::new_all(); +sys.refresh_all(); let cpu_usage = sys.global_cpu_usage(); let total_memory = sys.total_memory(); @@ -51,140 +51,120 @@ async fn dashboard(State(state): State>) -> Html { let uptime = System::uptime(); let uptime_str = format_uptime(uptime); + + (cpu_usage, total_memory, used_memory, memory_percent, uptime_str) +}; - let active_sessions = state - .session_manager - .try_lock() - .map(|sm| sm.active_count()) - .unwrap_or(0); +#[cfg(not(feature = "monitoring"))] +let (cpu_usage, total_memory, used_memory, memory_percent, uptime_str) = ( + 0.0, 0, 0, 0.0, "N/A".to_string() +); - Html(format!( - r##"
-
-
- CPU Usage - {cpu_usage:.1}% -
-
{cpu_usage:.1}%
-
-
-
+let active_sessions = state + .session_manager + .try_lock() + .map(|sm| sm.active_count()) + .unwrap_or(0); + +Html(format!( + r##"
+
+
+ CPU Usage + {cpu_usage:.1}%
- -
-
- Memory - {memory_percent:.1}% -
-
{used_gb:.1} GB / {total_gb:.1} GB
-
-
-
-
- -
-
- Active Sessions -
-
{active_sessions}
-
Current conversations
-
- -
-
- Uptime -
-
{uptime_str}
-
System running time
+
{cpu_usage:.1}%
+
+
-
- Auto-refreshing -
"##, - cpu_status = if cpu_usage > 80.0 { - "danger" - } else if cpu_usage > 60.0 { - "warning" - } else { - "success" - }, - mem_status = if memory_percent > 80.0 { - "danger" - } else if memory_percent > 60.0 { - "warning" - } else { - "success" - }, - used_gb = used_memory as f64 / 1_073_741_824.0, - total_gb = total_memory as f64 / 1_073_741_824.0, - )) -} +
+
+ Memory + {memory_percent:.1}% +
+
{used_gb:.1} GB / {total_gb:.1} GB
+
+
+
+
+
+
+ Active Sessions +
+
{active_sessions}
+
Current conversations
+
+ +
+
+ Uptime +
+
{uptime_str}
+
System running time
+
+
Auto-refreshing
"##, cpu_status = if cpu_usage > 80.0 { "danger" } else if cpu_usage > 60.0 { "warning" } else { "success" }, mem_status = if memory_percent > 80.0 { "danger" } else if memory_percent > 60.0 { "warning" } else { "success" }, used_gb = used_memory as f64 / 1_073_741_824.0, total_gb = total_memory as f64 / 1_073_741_824.0, )) } async fn services(State(_state): State>) -> Html { - let services = vec![ - ("PostgreSQL", check_postgres(), "Database"), - ("Redis", check_redis(), "Cache"), - ("MinIO", check_minio(), "Storage"), - ("LLM Server", check_llm(), "AI Backend"), - ]; +let services = vec![ +("PostgreSQL", check_postgres(), "Database"), +("Redis", check_redis(), "Cache"), +("MinIO", check_minio(), "Storage"), +("LLM Server", check_llm(), "AI Backend"), +]; - let mut rows = String::new(); - for (name, status, desc) in services { - let (status_class, status_text) = if status { - ("success", "Running") - } else { - ("danger", "Stopped") - }; +let mut rows = String::new(); +for (name, status, desc) in services { + let (status_class, status_text) = if status { + ("success", "Running") + } else { + ("danger", "Stopped") + }; - rows.push_str(&format!( - r##" - -
- - {name} -
- - {desc} - {status_text} - - - -"##, - name_lower = name.to_lowercase().replace(' ', "-"), - )); - } - - Html(format!( - r##"
-
-

Services Status

- + rows.push_str(&format!( + r##" + +
+ + {name}
- - - - - - - - - - - {rows} - -
ServiceDescriptionStatusActions
-
"## - )) -} - + +{desc} +{status_text} + + + +"##, name_lower = name.to_lowercase().replace(' ', "-"), )); } +Html(format!( + r##"
+
+

Services Status

+ +
+ + + + + + + + + + + {rows} + +
ServiceDescriptionStatusActions
+
"## )) } async fn resources(State(_state): State>) -> Html { - let mut sys = System::new_all(); - sys.refresh_all(); +#[cfg(feature = "monitoring")] +let (disk_rows, net_rows) = { +let mut sys = System::new_all(); +sys.refresh_all(); let disks = Disks::new_with_refreshed_list(); let mut disk_rows = String::new(); @@ -201,273 +181,246 @@ async fn resources(State(_state): State>) -> Html { disk_rows.push_str(&format!( r##" - {mount} - {used_gb:.1} GB - {total_gb:.1} GB - -
-
-
- {percent:.1}% - -"##, - mount = disk.mount_point().display(), - used_gb = used as f64 / 1_073_741_824.0, - total_gb = total as f64 / 1_073_741_824.0, - status = if percent > 90.0 { - "danger" - } else if percent > 70.0 { - "warning" - } else { - "success" - }, - )); - } - +{mount} +{used_gb:.1} GB +{total_gb:.1} GB + +
+
+
+ {percent:.1}% + +"##, mount = disk.mount_point().display(), used_gb = used as f64 / 1_073_741_824.0, total_gb = total as f64 / 1_073_741_824.0, status = if percent > 90.0 { "danger" } else if percent > 70.0 { "warning" } else { "success" }, )); } let networks = Networks::new_with_refreshed_list(); let mut net_rows = String::new(); for (name, data) in networks.list() { net_rows.push_str(&format!( r##" - {name} - {rx:.2} MB - {tx:.2} MB -"##, - rx = data.total_received() as f64 / 1_048_576.0, - tx = data.total_transmitted() as f64 / 1_048_576.0, - )); - } +{name} +{rx:.2} MB +{tx:.2} MB +"##, rx = data.total_received() as f64 / 1_048_576.0, tx = data.total_transmitted() as f64 / 1_048_576.0, )); } + (disk_rows, net_rows) +}; - Html(format!( - r##"
-
-

System Resources

-
+#[cfg(not(feature = "monitoring"))] +let (disk_rows, net_rows) = ( + String::new(), + String::new() +); -
-

Disk Usage

- - - - - - - - - - - {disk_rows} - -
MountUsedTotalUsage
-
+Html(format!( + r##"
+
+

System Resources

+
-
-

Network

- - - - - - - - - - {net_rows} - -
InterfaceReceivedTransmitted
-
-
"## - )) -} +
+

Disk Usage

+ + + + + + + + + + + {disk_rows} + +
MountUsedTotalUsage
+
+
+

Network

+ + + + + + + + + + {net_rows} + +
InterfaceReceivedTransmitted
+
+
"## )) } async fn logs(State(_state): State>) -> Html { - Html( - r##"
-
-

System Logs

-
- - -
-
-
-
- System ready - INFO - Monitoring initialized -
-
-
"## - .to_string(), - ) -} +Html( +r##"
+
+

System Logs

+
+ + +
+
+
+
+System ready +INFO +Monitoring initialized +
+
+
"## .to_string(), ) } async fn llm_metrics(State(_state): State>) -> Html { - Html( - r##"
-
-

LLM Metrics

-
+Html( +r##"
+
+

LLM Metrics

+
-
-
-
Total Requests
-
- -- -
-
- -
-
Cache Hit Rate
-
- -- -
-
- -
-
Avg Latency
-
- -- -
-
- -
-
Total Tokens
-
- -- -
+
+
+
Total Requests
+
+ --
-
"## - .to_string(), - ) -} +
+
Cache Hit Rate
+
+ -- +
+
+ +
+
Avg Latency
+
+ -- +
+
+ +
+
Total Tokens
+
+ -- +
+
+
+
"## .to_string(), ) } async fn health(State(state): State>) -> Html { - let db_ok = state.conn.get().is_ok(); - let status = if db_ok { "healthy" } else { "degraded" }; +let db_ok = state.conn.get().is_ok(); +let status = if db_ok { "healthy" } else { "degraded" }; - Html(format!( - r##"
- - {status} -
"## - )) -} +Html(format!( + r##"
+ +{status} +"## )) } fn format_uptime(seconds: u64) -> String { - let days = seconds / 86400; - let hours = (seconds % 86400) / 3600; - let minutes = (seconds % 3600) / 60; +let days = seconds / 86400; +let hours = (seconds % 86400) / 3600; +let minutes = (seconds % 3600) / 60; - if days > 0 { - format!("{}d {}h {}m", days, hours, minutes) - } else if hours > 0 { - format!("{}h {}m", hours, minutes) - } else { - format!("{}m", minutes) - } +if days > 0 { + format!("{}d {}h {}m", days, hours, minutes) +} else if hours > 0 { + format!("{}h {}m", hours, minutes) +} else { + format!("{}m", minutes) } +} fn check_postgres() -> bool { - true +true } - fn check_redis() -> bool { - true +true } - fn check_minio() -> bool { - true +true } - fn check_llm() -> bool { - true +true } - async fn timestamp(State(_state): State>) -> Html { - let now = Local::now(); - Html(format!("Last updated: {}", now.format("%H:%M:%S"))) +let now = Local::now(); +Html(format!("Last updated: {}", now.format("%H:%M:%S"))) } - async fn bots(State(state): State>) -> Html { - let active_sessions = state - .session_manager - .try_lock() - .map(|sm| sm.active_count()) - .unwrap_or(0); - - Html(format!( - r##"
-
- Active Sessions - {active_sessions} -
-
"## - )) -} +let active_sessions = state +.session_manager +.try_lock() +.map(|sm| sm.active_count()) +.unwrap_or(0); +Html(format!( + r##"
+
+ Active Sessions + {active_sessions} +
+
"## )) } async fn services_status(State(_state): State>) -> Html { - let services = vec![ - ("postgresql", check_postgres()), - ("redis", check_redis()), - ("minio", check_minio()), - ("llm", check_llm()), - ]; +let services = vec![ +("postgresql", check_postgres()), +("redis", check_redis()), +("minio", check_minio()), +("llm", check_llm()), +]; - let mut status_updates = String::new(); - for (name, running) in services { - let status = if running { "running" } else { "stopped" }; - status_updates.push_str(&format!( - r##""## - )); - } - - Html(status_updates) +let mut status_updates = String::new(); +for (name, running) in services { + let status = if running { "running" } else { "stopped" }; + status_updates.push_str(&format!( + r##""## + )); } +Html(status_updates) + +} async fn resources_bars(State(_state): State>) -> Html { - let mut sys = System::new_all(); - sys.refresh_all(); +#[cfg(feature = "monitoring")] +let (cpu_usage, memory_percent) = { +let mut sys = System::new_all(); +sys.refresh_all(); let cpu_usage = sys.global_cpu_usage(); let total_memory = sys.total_memory(); @@ -477,97 +430,82 @@ async fn resources_bars(State(_state): State>) -> Html { } else { 0.0 }; + + (cpu_usage, memory_percent) +}; - Html(format!( - r##" - CPU - - - {cpu_usage:.0}% - - - MEM - - - {memory_percent:.0}% -"##, - cpu_width = cpu_usage.min(100.0), - mem_width = memory_percent.min(100.0), - )) -} +#[cfg(not(feature = "monitoring"))] +let (cpu_usage, memory_percent) = (0.0, 0.0); +Html(format!( + r##" +CPU + + +{cpu_usage:.0}% + MEM {memory_percent:.0}% "##, cpu_width = cpu_usage.min(100.0), mem_width = memory_percent.min(100.0), )) } async fn activity_latest(State(_state): State>) -> Html { - Html("System monitoring active...".to_string()) +Html("System monitoring active...".to_string()) } - async fn metric_sessions(State(state): State>) -> Html { - let active_sessions = state - .session_manager - .try_lock() - .map(|sm| sm.active_count()) - .unwrap_or(0); +let active_sessions = state +.session_manager +.try_lock() +.map(|sm| sm.active_count()) +.unwrap_or(0); + +Html(format!("{}", active_sessions)) - Html(format!("{}", active_sessions)) } - async fn metric_messages(State(_state): State>) -> Html { - Html("--".to_string()) +Html("--".to_string()) } - async fn metric_response_time(State(_state): State>) -> Html { - Html("--".to_string()) +Html("--".to_string()) } - async fn trend_sessions(State(_state): State>) -> Html { - Html("↑ 0%".to_string()) +Html("↑ 0%".to_string()) } - async fn rate_messages(State(_state): State>) -> Html { - Html("0/hr".to_string()) +Html("0/hr".to_string()) } - async fn sessions_panel(State(state): State>) -> Html { - let active_sessions = state - .session_manager - .try_lock() - .map(|sm| sm.active_count()) - .unwrap_or(0); +let active_sessions = state +.session_manager +.try_lock() +.map(|sm| sm.active_count()) +.unwrap_or(0); - Html(format!( - r##"
-
-

Active Sessions

- {active_sessions} +Html(format!( + r##"
+
+

Active Sessions

+ {active_sessions} +
+
+
+

No active sessions

-
-
-

No active sessions

-
-
-
"## - )) -} - +
+
"## )) } async fn messages_panel(State(_state): State>) -> Html { - Html( - r##"
-
-

Recent Messages

-
-
-
-

No recent messages

-
-
-
"## - .to_string(), - ) -} +Html( +r##"
+
+

Recent Messages

+
+
+
+

No recent messages

+
+
+ +
"## .to_string(), ) } diff --git a/src/security/auth.rs b/src/security/auth.rs index dd7c5b7ef..be39447db 100644 --- a/src/security/auth.rs +++ b/src/security/auth.rs @@ -832,9 +832,10 @@ fn validate_session_sync(session_id: &str) -> Result Router> { - Router::new() - .route("/api/user/storage", get(get_storage_info)) - .route("/api/user/storage/connections", get(get_storage_connections)) - .route("/api/user/security/2fa/status", get(get_2fa_status)) - .route("/api/user/security/2fa/enable", post(enable_2fa)) - .route("/api/user/security/2fa/disable", post(disable_2fa)) - .route("/api/user/security/sessions", get(get_active_sessions)) - .route( - "/api/user/security/sessions/revoke-all", - post(revoke_all_sessions), - ) - .route("/api/user/security/devices", get(get_trusted_devices)) - .route("/api/settings/search", post(save_search_settings)) - .route("/api/settings/smtp/test", post(test_smtp_connection)) - .route("/api/settings/accounts/social", get(get_accounts_social)) - .route("/api/settings/accounts/messaging", get(get_accounts_messaging)) - .route("/api/settings/accounts/email", get(get_accounts_email)) - .route("/api/settings/accounts/smtp", post(save_smtp_account)) - .route("/api/ops/health", get(get_ops_health)) - .route("/api/rbac/permissions", get(get_rbac_permissions)) - .merge(rbac::configure_rbac_routes()) - .merge(security_admin::configure_security_admin_routes()) +Router::new() +.route("/api/user/storage", get(get_storage_info)) +.route("/api/user/storage/connections", get(get_storage_connections)) +.route("/api/user/security/2fa/status", get(get_2fa_status)) +.route("/api/user/security/2fa/enable", post(enable_2fa)) +.route("/api/user/security/2fa/disable", post(disable_2fa)) +.route("/api/user/security/sessions", get(get_active_sessions)) +.route( +"/api/user/security/sessions/revoke-all", +post(revoke_all_sessions), +) +.route("/api/user/security/devices", get(get_trusted_devices)) +.route("/api/settings/search", post(save_search_settings)) +.route("/api/settings/smtp/test", post(test_smtp_connection)) +.route("/api/settings/accounts/social", get(get_accounts_social)) +.route("/api/settings/accounts/messaging", get(get_accounts_messaging)) +.route("/api/settings/accounts/email", get(get_accounts_email)) +.route("/api/settings/accounts/smtp", post(save_smtp_account)) +.route("/api/ops/health", get(get_ops_health)) +.route("/api/rbac/permissions", get(get_rbac_permissions)) +.merge(rbac::configure_rbac_routes()) +.merge(security_admin::configure_security_admin_routes()) } async fn get_accounts_social(State(_state): State>) -> Html { - Html(r##"
- - - - -
"##.to_string()) -} +Html(r##"
+ + + + + +
"##.to_string()) } async fn get_accounts_messaging(State(_state): State>) -> Html { - Html(r##"
- - - - -
"##.to_string()) -} +Html(r##"
+ + + + + +
"##.to_string()) } async fn get_accounts_email(State(_state): State>) -> Html { - Html(r##"
- - - -
"##.to_string()) -} +Html(r##"
+ + + + +
"##.to_string()) } async fn save_smtp_account( - State(_state): State>, - Json(config): Json, +State(_state): State>, +Json(config): Json, ) -> Json { - Json(serde_json::json!({ - "success": true, - "message": "SMTP configuration saved", - "config": config - })) +Json(serde_json::json!({ +"success": true, +"message": "SMTP configuration saved", +"config": config +})) } async fn get_ops_health(State(_state): State>) -> Json { - Json(serde_json::json!({ - "status": "healthy", - "services": { - "api": {"status": "up", "latency_ms": 12}, - "database": {"status": "up", "latency_ms": 5}, - "cache": {"status": "up", "latency_ms": 1}, - "storage": {"status": "up", "latency_ms": 8} - }, - "timestamp": chrono::Utc::now().to_rfc3339() - })) +Json(serde_json::json!({ +"status": "healthy", +"services": { +"api": {"status": "up", "latency_ms": 12}, +"database": {"status": "up", "latency_ms": 5}, +"cache": {"status": "up", "latency_ms": 1}, +"storage": {"status": "up", "latency_ms": 8} +}, +"timestamp": chrono::Utc::now().to_rfc3339() +})) } async fn get_rbac_permissions(State(_state): State>) -> Json { - Json(serde_json::json!({ - "permissions": [ - {"id": "read:users", "name": "Read Users", "category": "Users"}, - {"id": "write:users", "name": "Write Users", "category": "Users"}, - {"id": "delete:users", "name": "Delete Users", "category": "Users"}, - {"id": "read:bots", "name": "Read Bots", "category": "Bots"}, - {"id": "write:bots", "name": "Write Bots", "category": "Bots"}, - {"id": "admin:billing", "name": "Manage Billing", "category": "Admin"}, - {"id": "admin:settings", "name": "Manage Settings", "category": "Admin"} - ] - })) +Json(serde_json::json!({ +"permissions": [ +{"id": "read:users", "name": "Read Users", "category": "Users"}, +{"id": "write:users", "name": "Write Users", "category": "Users"}, +{"id": "delete:users", "name": "Delete Users", "category": "Users"}, +{"id": "read:bots", "name": "Read Bots", "category": "Bots"}, +{"id": "write:bots", "name": "Write Bots", "category": "Bots"}, +{"id": "admin:billing", "name": "Manage Billing", "category": "Admin"}, +{"id": "admin:settings", "name": "Manage Settings", "category": "Admin"} +] +})) } async fn get_storage_info(State(_state): State>) -> Html { - Html( - r##"
-
-
-
-
- 2.5 GB used - of 10 GB -
-
-
- 📄 - Documents - 1.2 GB -
-
- 🖼️ - Images - 800 MB -
-
- 📧 - Emails - 500 MB -
-
-
"## - .to_string(), - ) -} +Html( +r##"
+
+
+
+
+2.5 GB used +of 10 GB +
+
+
+📄 +Documents +1.2 GB +
+
+🖼️ +Images +800 MB +
+
+📧 +Emails +500 MB +
+
+s +
"## .to_string(), ) } async fn get_storage_connections(State(_state): State>) -> Html { - Html( - r##"
-

No external storage connections configured

- -
"## - .to_string(), - ) -} +Html( +r##"
+

No external storage connections configured

+ + +
"## .to_string(), ) } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SearchSettingsRequest { - enable_fuzzy_search: Option, - search_result_limit: Option, - enable_ai_suggestions: Option, - index_attachments: Option, - search_sources: Option>, +enable_fuzzy_search: Option, +search_result_limit: Option, +enable_ai_suggestions: Option, +index_attachments: Option, +search_sources: Option>, } #[derive(Debug, Serialize)] struct SearchSettingsResponse { - success: bool, - message: Option, - error: Option, +success: bool, +message: Option, +error: Option, } async fn save_search_settings( - State(_state): State>, - Json(settings): Json, +State(_state): State>, +Json(settings): Json, ) -> Json { - // In a real implementation, save to database - log::info!("Saving search settings: fuzzy={:?}, limit={:?}, ai={:?}", - settings.enable_fuzzy_search, - settings.search_result_limit, - settings.enable_ai_suggestions - ); +// In a real implementation, save to database +log::info!("Saving search settings: fuzzy={:?}, limit={:?}, ai={:?}", +settings.enable_fuzzy_search, +settings.search_result_limit, +settings.enable_ai_suggestions +); + + +Json(SearchSettingsResponse { + success: true, + message: Some("Search settings saved successfully".to_string()), + error: None, +}) - Json(SearchSettingsResponse { - success: true, - message: Some("Search settings saved successfully".to_string()), - error: None, - }) } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SmtpTestRequest { - host: String, - port: i32, - username: Option, - password: Option, - use_tls: Option, +host: String, +port: i32, +username: Option, +password: Option, +use_tls: Option, } #[derive(Debug, Serialize)] struct SmtpTestResponse { - success: bool, - message: Option, - error: Option, +success: bool, +message: Option, +error: Option, } +#[cfg(feature = "mail")] async fn test_smtp_connection( - State(_state): State>, - Json(config): Json, +State(_state): State>, +Json(config): Json, ) -> Json { - use lettre::SmtpTransport; - use lettre::transport::smtp::authentication::Credentials; +use lettre::SmtpTransport; +use lettre::transport::smtp::authentication::Credentials; - log::info!("Testing SMTP connection to {}:{}", config.host, config.port); - let mailer_result = if let (Some(user), Some(pass)) = (config.username, config.password) { - let creds = Credentials::new(user, pass); - SmtpTransport::relay(&config.host) - .map(|b| b.port(config.port as u16).credentials(creds).build()) - } else { - Ok(SmtpTransport::builder_dangerous(&config.host) - .port(config.port as u16) - .build()) - }; - match mailer_result { - Ok(mailer) => { - match mailer.test_connection() { - Ok(true) => Json(SmtpTestResponse { - success: true, - message: Some("SMTP connection successful".to_string()), - error: None, - }), - Ok(false) => Json(SmtpTestResponse { - success: false, - message: None, - error: Some("SMTP connection test failed".to_string()), - }), - Err(e) => Json(SmtpTestResponse { - success: false, - message: None, - error: Some(format!("SMTP error: {}", e)), - }), - } +log::info!("Testing SMTP connection to {}:{}", config.host, config.port); + +let mailer_result = if let (Some(user), Some(pass)) = (config.username, config.password) { + let creds = Credentials::new(user, pass); + SmtpTransport::relay(&config.host) + .map(|b| b.port(config.port as u16).credentials(creds).build()) +} else { + Ok(SmtpTransport::builder_dangerous(&config.host) + .port(config.port as u16) + .build()) +}; + +match mailer_result { + Ok(mailer) => { + match mailer.test_connection() { + Ok(true) => Json(SmtpTestResponse { + success: true, + message: Some("SMTP connection successful".to_string()), + error: None, + }), + Ok(false) => Json(SmtpTestResponse { + success: false, + message: None, + error: Some("SMTP connection test failed".to_string()), + }), + Err(e) => Json(SmtpTestResponse { + success: false, + message: None, + error: Some(format!("SMTP error: {}", e)), + }), } - Err(e) => Json(SmtpTestResponse { - success: false, - message: None, - error: Some(format!("Failed to create SMTP transport: {}", e)), - }), } + Err(e) => Json(SmtpTestResponse { + success: false, + message: None, + error: Some(format!("Failed to create SMTP transport: {}", e)), + }), +} + +} + +#[cfg(not(feature = "mail"))] +async fn test_smtp_connection( +State(_state): State>, +Json(_config): Json, +) -> Json { +Json(SmtpTestResponse { +success: false, +message: None, +error: Some("SMTP email feature is not enabled in this build".to_string()), +}) } async fn get_2fa_status(State(_state): State>) -> Html { - Html( - r##"
- - Two-factor authentication is not enabled -
"## - .to_string(), - ) -} +Html( +r##"
+ +Two-factor authentication is not enabled + +
"## .to_string(), ) } async fn enable_2fa(State(_state): State>) -> Html { - Html( - r##"
- - Two-factor authentication enabled -
"## - .to_string(), - ) -} +Html( +r##"
+ +Two-factor authentication enabled + +
"## .to_string(), ) } async fn disable_2fa(State(_state): State>) -> Html { - Html( - r##"
- - Two-factor authentication disabled -
"## - .to_string(), - ) -} +Html( +r##"
+ +Two-factor authentication disabled + +
"## .to_string(), ) } async fn get_active_sessions(State(_state): State>) -> Html { - Html( - r##"
-
-
- 💻 - Current Session - This device -
-
- Current browser session - Active now -
-
+Html( +r##"
+
+
+💻 +Current Session +This device
-
-

No other active sessions

-
"## - .to_string(), - ) -} +
+Current browser session +Active now +
+
+ +

No other active sessions

"## .to_string(), ) } async fn revoke_all_sessions(State(_state): State>) -> Html { - Html( - r##"
- - All other sessions have been revoked -
"## - .to_string(), - ) -} +Html( +r##"
+ +All other sessions have been revoked + +
"## .to_string(), ) } async fn get_trusted_devices(State(_state): State>) -> Html { - Html( - r##"
-
- 💻 -
- Current Device - Last active: Just now -
-
- Trusted +Html( +r##"
+
+💻 +
+Current Device +Last active: Just now
-
-

No other trusted devices

-
"## - .to_string(), - ) -} +
+Trusted + +

No other trusted devices

"## .to_string(), ) }