File created successfully at: C:\Users\MikeSwanson\claude-projects\gururmm\server\src\db\alerts.rs

//! Alert database operations
//!
//! Provides CRUD operations for alerts triggered by threshold violations.
//! Includes threshold state tracking for duration-based alerts.

use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

/// Alert severity levels
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "varchar", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum AlertSeverity {
    Info,
    Warning,
    Critical,
}

impl std::fmt::Display for AlertSeverity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AlertSeverity::Info => write!(f, "info"),
            AlertSeverity::Warning => write!(f, "warning"),
            AlertSeverity::Critical => write!(f, "critical"),
        }
    }
}

/// Alert status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "varchar", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum AlertStatus {
    Active,
    Acknowledged,
    Resolved,
}

impl std::fmt::Display for AlertStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AlertStatus::Active => write!(f, "active"),
            AlertStatus::Acknowledged => write!(f, "acknowledged"),
            AlertStatus::Resolved => write!(f, "resolved"),
        }
    }
}

/// Alert record from database
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Alert {
    pub id: Uuid,
    pub agent_id: Uuid,
    pub alert_type: String,
    pub severity: String,
    pub title: String,
    pub message: Option<String>,
    pub metric_value: Option<f32>,
    pub threshold_value: Option<f32>,
    pub status: String,
    pub acknowledged_at: Option<chrono::DateTime<chrono::Utc>>,
    pub acknowledged_by: Option<Uuid>,
    pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
    pub dedup_key: Option<String>,
    pub triggered_at: chrono::DateTime<chrono::Utc>,
    pub last_seen_at: chrono::DateTime<chrono::Utc>,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

/// Alert response for API with agent info
#[derive(Debug, Clone, Serialize)]
pub struct AlertResponse {
    pub id: Uuid,
    pub agent_id: Uuid,
    pub agent_hostname: Option<String>,
    pub site_name: Option<String>,
    pub client_name: Option<String>,
    pub alert_type: String,
    pub severity: String,
    pub title: String,
    pub message: Option<String>,
    pub metric_value: Option<f32>,
    pub threshold_value: Option<f32>,
    pub status: String,
    pub acknowledged_at: Option<chrono::DateTime<chrono::Utc>>,
    pub acknowledged_by: Option<Uuid>,
    pub acknowledged_by_name: Option<String>,
    pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
    pub triggered_at: chrono::DateTime<chrono::Utc>,
    pub last_seen_at: chrono::DateTime<chrono::Utc>,
}

impl From<Alert> for AlertResponse {
    fn from(a: Alert) -> Self {
        AlertResponse {
            id: a.id,
            agent_id: a.agent_id,
            agent_hostname: None,
            site_name: None,
            client_name: None,
            alert_type: a.alert_type,
            severity: a.severity,
            title: a.title,
            message: a.message,
            metric_value: a.metric_value,
            threshold_value: a.threshold_value,
            status: a.status,
            acknowledged_at: a.acknowledged_at,
            acknowledged_by: a.acknowledged_by,
            acknowledged_by_name: None,
            resolved_at: a.resolved_at,
            triggered_at: a.triggered_at,
            last_seen_at: a.last_seen_at,
        }
    }
}

/// Alert with agent details
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct AlertWithDetails {
    pub id: Uuid,
    pub agent_id: Uuid,
    pub alert_type: String,
    pub severity: String,
    pub title: String,
    pub message: Option<String>,
    pub metric_value: Option<f32>,
    pub threshold_value: Option<f32>,
    pub status: String,
    pub acknowledged_at: Option<chrono::DateTime<chrono::Utc>>,
    pub acknowledged_by: Option<Uuid>,
    pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
    pub dedup_key: Option<String>,
    pub triggered_at: chrono::DateTime<chrono::Utc>,
    pub last_seen_at: chrono::DateTime<chrono::Utc>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub agent_hostname: String,
    pub site_name: Option<String>,
    pub client_name: Option<String>,
    pub acknowledged_by_name: Option<String>,
}

/// Alert summary for dashboard
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct AlertSummary {
    pub total: i64,
    pub active: i64,
    pub acknowledged: i64,
    pub critical: i64,
    pub warning: i64,
    pub info: i64,
}

/// Threshold state tracking record
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct AlertThresholdState {
    pub agent_id: Uuid,
    pub threshold_key: String,
    pub violation_started_at: Option<chrono::DateTime<chrono::Utc>>,
    pub consecutive_violations: i32,
    pub last_value: Option<f32>,
    pub last_checked_at: chrono::DateTime<chrono::Utc>,
}

/// Data for creating an alert
#[derive(Debug)]
pub struct CreateAlert {
    pub agent_id: Uuid,
    pub alert_type: String,
    pub severity: AlertSeverity,
    pub title: String,
    pub message: Option<String>,
    pub metric_value: Option<f32>,
    pub threshold_value: Option<f32>,
    pub dedup_key: Option<String>,
}

/// Filter options for listing alerts
#[derive(Debug, Default, Deserialize)]
pub struct AlertFilter {
    pub status: Option<String>,
    pub severity: Option<String>,
    pub alert_type: Option<String>,
    pub agent_id: Option<Uuid>,
    pub client_id: Option<Uuid>,
    pub site_id: Option<Uuid>,
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

// ============================================================================
// Alert CRUD operations
// ============================================================================

/// Create a new alert or update existing active alert (by dedup_key)
pub async fn create_or_update_alert(
    pool: &PgPool,
    alert: CreateAlert,
) -> Result<Alert, sqlx::Error> {
    // If dedup_key provided, try to find existing active alert
    if let Some(ref dedup_key) = alert.dedup_key {
        let existing = sqlx::query_as::<_, Alert>(
            r#"
            SELECT * FROM alerts
            WHERE dedup_key = $1 AND status = 'active'
            "#,
        )
        .bind(dedup_key)
        .fetch_optional(pool)
        .await?;

        if let Some(existing_alert) = existing {
            // Update last_seen_at and metric_value
            return sqlx::query_as::<_, Alert>(
                r#"
                UPDATE alerts
                SET last_seen_at = NOW(),
                    metric_value = COALESCE($1, metric_value)
                WHERE id = $2
                RETURNING *
                "#,
            )
            .bind(alert.metric_value)
            .bind(existing_alert.id)
            .fetch_one(pool)
            .await;
        }
    }

    // Create new alert
    sqlx::query_as::<_, Alert>(
        r#"
        INSERT INTO alerts (agent_id, alert_type, severity, title, message, metric_value, threshold_value, dedup_key)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
        RETURNING *
        "#,
    )
    .bind(alert.agent_id)
    .bind(&alert.alert_type)
    .bind(alert.severity.to_string())
    .bind(&alert.title)
    .bind(&alert.message)
    .bind(alert.metric_value)
    .bind(alert.threshold_value)
    .bind(&alert.dedup_key)
    .fetch_one(pool)
    .await
}

/// Get an alert by ID
pub async fn get_alert_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Alert>, sqlx::Error> {
    sqlx::query_as::<_, Alert>("SELECT * FROM alerts WHERE id = $1")
        .bind(id)
        .fetch_optional(pool)
        .await
}

/// Get an alert by ID with details
pub async fn get_alert_with_details(pool: &PgPool, id: Uuid) -> Result<Option<AlertWithDetails>, sqlx::Error> {
    sqlx::query_as::<_, AlertWithDetails>(
        r#"
        SELECT
            al.*,
            a.hostname as agent_hostname,
            s.name as site_name,
            c.name as client_name,
            u.name as acknowledged_by_name
        FROM alerts al
        JOIN agents a ON al.agent_id = a.id
        LEFT JOIN sites s ON a.site_id = s.id
        LEFT JOIN clients c ON s.client_id = c.id
        LEFT JOIN users u ON al.acknowledged_by = u.id
        WHERE al.id = $1
        "#,
    )
    .bind(id)
    .fetch_optional(pool)
    .await
}

/// Get alerts with filtering
pub async fn get_alerts(pool: &PgPool, filter: AlertFilter) -> Result<Vec<AlertWithDetails>, sqlx::Error> {
    let limit = filter.limit.unwrap_or(100).min(500);
    let offset = filter.offset.unwrap_or(0);

    // Build dynamic query
    let mut query = String::from(
        r#"
        SELECT
            al.*,
            a.hostname as agent_hostname,
            s.name as site_name,
            c.name as client_name,
            u.name as acknowledged_by_name
        FROM alerts al
        JOIN agents a ON al.agent_id = a.id
        LEFT JOIN sites s ON a.site_id = s.id
        LEFT JOIN clients c ON s.client_id = c.id
        LEFT JOIN users u ON al.acknowledged_by = u.id
        WHERE 1=1
        "#,
    );

    if filter.status.is_some() {
        query.push_str(" AND al.status = $1");
    }
    if filter.severity.is_some() {
        query.push_str(&format!(" AND al.severity = ${}", if filter.status.is_some() { 2 } else { 1 }));
    }
    if filter.alert_type.is_some() {
        let idx = 1 + filter.status.is_some() as usize + filter.severity.is_some() as usize;
        query.push_str(&format!(" AND al.alert_type = ${}", idx));
    }
    if filter.agent_id.is_some() {
        let idx = 1 + filter.status.is_some() as usize + filter.severity.is_some() as usize + filter.alert_type.is_some() as usize;
        query.push_str(&format!(" AND al.agent_id = ${}", idx));
    }
    if filter.client_id.is_some() {
        let idx = 1 + filter.status.is_some() as usize + filter.severity.is_some() as usize + filter.alert_type.is_some() as usize + filter.agent_id.is_some() as usize;
        query.push_str(&format!(" AND c.id = ${}", idx));
    }
    if filter.site_id.is_some() {
        let idx = 1 + filter.status.is_some() as usize + filter.severity.is_some() as usize + filter.alert_type.is_some() as usize + filter.agent_id.is_some() as usize + filter.client_id.is_some() as usize;
        query.push_str(&format!(" AND s.id = ${}", idx));
    }

    query.push_str(" ORDER BY al.triggered_at DESC LIMIT $99 OFFSET $100");

    // Replace placeholder indices with actual
    let mut param_idx = 0;
    if filter.status.is_some() { param_idx += 1; }
    if filter.severity.is_some() { param_idx += 1; }
    if filter.alert_type.is_some() { param_idx += 1; }
    if filter.agent_id.is_some() { param_idx += 1; }
    if filter.client_id.is_some() { param_idx += 1; }
    if filter.site_id.is_some() { param_idx += 1; }

    let limit_idx = param_idx + 1;
    let offset_idx = param_idx + 2;
    query = query.replace("$99", &format!("${}", limit_idx));
    query = query.replace("$100", &format!("${}", offset_idx));

    // Build the query with proper bindings
    let mut q = sqlx::query_as::<_, AlertWithDetails>(&query);

    if let Some(ref s) = filter.status { q = q.bind(s); }
    if let Some(ref s) = filter.severity { q = q.bind(s); }
    if let Some(ref s) = filter.alert_type { q = q.bind(s); }
    if let Some(id) = filter.agent_id { q = q.bind(id); }
    if let Some(id) = filter.client_id { q = q.bind(id); }
    if let Some(id) = filter.site_id { q = q.bind(id); }
    q = q.bind(limit);
    q = q.bind(offset);

    q.fetch_all(pool).await
}

/// Get alert summary counts
pub async fn get_alert_summary(pool: &PgPool) -> Result<AlertSummary, sqlx::Error> {
    sqlx::query_as::<_, AlertSummary>(
        r#"
        SELECT
            COUNT(*) as total,
            COUNT(*) FILTER (WHERE status = 'active') as active,
            COUNT(*) FILTER (WHERE status = 'acknowledged') as acknowledged,
            COUNT(*) FILTER (WHERE severity = 'critical' AND status != 'resolved') as critical,
            COUNT(*) FILTER (WHERE severity = 'warning' AND status != 'resolved') as warning,
            COUNT(*) FILTER (WHERE severity = 'info' AND status != 'resolved') as info
        FROM alerts
        WHERE triggered_at > NOW() - INTERVAL '7 days'
        "#,
    )
    .fetch_one(pool)
    .await
}

/// Get active alerts for an agent
pub async fn get_active_alerts_for_agent(pool: &PgPool, agent_id: Uuid) -> Result<Vec<Alert>, sqlx::Error> {
    sqlx::query_as::<_, Alert>(
        r#"
        SELECT * FROM alerts
        WHERE agent_id = $1 AND status IN ('active', 'acknowledged')
        ORDER BY triggered_at DESC
        "#,
    )
    .bind(agent_id)
    .fetch_all(pool)
    .await
}

/// Acknowledge an alert
pub async fn acknowledge_alert(
    pool: &PgPool,
    id: Uuid,
    user_id: Uuid,
) -> Result<Option<Alert>, sqlx::Error> {
    sqlx::query_as::<_, Alert>(
        r#"
        UPDATE alerts
        SET status = 'acknowledged',
            acknowledged_at = NOW(),
            acknowledged_by = $1
        WHERE id = $2 AND status = 'active'
        RETURNING *
        "#,
    )
    .bind(user_id)
    .bind(id)
    .fetch_optional(pool)
    .await
}

/// Resolve an alert
pub async fn resolve_alert(pool: &PgPool, id: Uuid) -> Result<Option<Alert>, sqlx::Error> {
    sqlx::query_as::<_, Alert>(
        r#"
        UPDATE alerts
        SET status = 'resolved',
            resolved_at = NOW()
        WHERE id = $1 AND status != 'resolved'
        RETURNING *
        "#,
    )
    .bind(id)
    .fetch_optional(pool)
    .await
}

/// Auto-resolve alerts by dedup_key (when condition clears)
pub async fn auto_resolve_by_dedup_key(pool: &PgPool, dedup_key: &str) -> Result<bool, sqlx::Error> {
    let result = sqlx::query(
        r#"
        UPDATE alerts
        SET status = 'resolved',
            resolved_at = NOW()
        WHERE dedup_key = $1 AND status IN ('active', 'acknowledged')
        "#,
    )
    .bind(dedup_key)
    .execute(pool)
    .await?;
    Ok(result.rows_affected() > 0)
}

// ============================================================================
// Threshold state operations (for duration-based alerts)
// ============================================================================

/// Get or create threshold state for an agent/threshold combination
pub async fn get_threshold_state(
    pool: &PgPool,
    agent_id: Uuid,
    threshold_key: &str,
) -> Result<AlertThresholdState, sqlx::Error> {
    // Try to get existing state
    let existing = sqlx::query_as::<_, AlertThresholdState>(
        "SELECT * FROM alert_threshold_state WHERE agent_id = $1 AND threshold_key = $2",
    )
    .bind(agent_id)
    .bind(threshold_key)
    .fetch_optional(pool)
    .await?;

    if let Some(state) = existing {
        return Ok(state);
    }

    // Create new state
    sqlx::query_as::<_, AlertThresholdState>(
        r#"
        INSERT INTO alert_threshold_state (agent_id, threshold_key)
        VALUES ($1, $2)
        RETURNING *
        "#,
    )
    .bind(agent_id)
    .bind(threshold_key)
    .fetch_one(pool)
    .await
}

/// Update threshold state when violation is detected
pub async fn update_threshold_violation(
    pool: &PgPool,
    agent_id: Uuid,
    threshold_key: &str,
    value: f32,
) -> Result<AlertThresholdState, sqlx::Error> {
    sqlx::query_as::<_, AlertThresholdState>(
        r#"
        INSERT INTO alert_threshold_state (agent_id, threshold_key, violation_started_at, consecutive_violations, last_value, last_checked_at)
        VALUES ($1, $2, NOW(), 1, $3, NOW())
        ON CONFLICT (agent_id, threshold_key) DO UPDATE
        SET violation_started_at = COALESCE(alert_threshold_state.violation_started_at, NOW()),
            consecutive_violations = alert_threshold_state.consecutive_violations + 1,
            last_value = $3,
            last_checked_at = NOW()
        RETURNING *
        "#,
    )
    .bind(agent_id)
    .bind(threshold_key)
    .bind(value)
    .fetch_one(pool)
    .await
}

/// Clear threshold state when condition clears
pub async fn clear_threshold_state(
    pool: &PgPool,
    agent_id: Uuid,
    threshold_key: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        UPDATE alert_threshold_state
        SET violation_started_at = NULL,
            consecutive_violations = 0,
            last_checked_at = NOW()
        WHERE agent_id = $1 AND threshold_key = $2
        "#,
    )
    .bind(agent_id)
    .bind(threshold_key)
    .execute(pool)
    .await?;
    Ok(())
}

/// Check if threshold has been in violation long enough
pub async fn check_threshold_duration(
    pool: &PgPool,
    agent_id: Uuid,
    threshold_key: &str,
    required_duration_seconds: i64,
) -> Result<bool, sqlx::Error> {
    let state = get_threshold_state(pool, agent_id, threshold_key).await?;

    if let Some(started) = state.violation_started_at {
        let elapsed = chrono::Utc::now().signed_duration_since(started);
        Ok(elapsed.num_seconds() >= required_duration_seconds)
    } else {
        Ok(false)
    }
}


Now I've created the alerts.rs file. Let me update the todo list and move on to creating the policy module with merge logic. This module will handle merging multiple policies into an effective policy.


Now let me create the policy module with merge logic:


TodoWrite

Phase 1: Create database migration (006_policies.sql)

Phase 1: Create server/src/db/policies.rs

Phase 1: Create server/src/db/alerts.rs

Phase 1: Create server/src/policy/ module with merge logic

Phase 2: Create API endpoints for policies and alerts

Phase 3: Create alert evaluation engine

Phase 4: Update agent to apply policies

Phase 5: Create dashboard UI for policies and alerts


Bash

Create policy module directory


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Phase 1: Create database migration (006_policies.sql)

Phase 1: Create server/src/db/policies.rs

Phase 1: Create server/src/db/alerts.rs

Phase 1: Create server/src/policy/ module with merge logic

Phase 2: Create API endpoints for policies and alerts

Phase 3: Create alert evaluation engine

Phase 4: Update agent to apply policies

Phase 1: Create server/src/db/alerts.rs

Phase 3: Create alert evaluation engine

Phase 4: Update agent to apply policies

Phase 5: Create dashboard UI for policies and alerts


Good, now let me create the policy module with merge logic. I'll create:
1. mod.rs - Module root exporting merge functions
2. merge.rs - The actual merge logic
3. effective.rs - Calculate effective policy for an agent


Write

//! Policy module
//!
//! Handles policy merging and effective policy calculation.
//! Policies stack in hierarchical order: Client → Site → Agent
//! with "last wins" merge logic (later policies override earlier).

mod merge;
mod effective;

pub use merge::*;
pub use effective::*;


//! Policy merge logic
//!
//! Implements "last wins" merge strategy where later policies
//! completely override earlier policies for the same settings.

use crate::db::policies::{
    MetricsConfig, PolicyData, ThresholdLevel, ThresholdsConfig, TempThreshold,
    TrayConfig, UpdatesConfig, WatchdogConfig, MaintenanceWindow, ServiceWatch, ProcessWatch,
};

/// Merge two policies with "last wins" semantics
/// Values from `overlay` override values from `base` when present
pub fn merge_policy_data(base: &PolicyData, overlay: &PolicyData) -> PolicyData {
    PolicyData {
        metrics: merge_metrics(&base.metrics, &overlay.metrics),
        thresholds: merge_thresholds(&base.thresholds, &overlay.thresholds),
        tray: merge_tray(&base.tray, &overlay.tray),
        updates: merge_updates(&base.updates, &overlay.updates),
        watchdog: merge_watchdog(&base.watchdog, &overlay.watchdog),
    }
}

/// Merge metrics configuration
fn merge_metrics(base: &Option<MetricsConfig>, overlay: &Option<MetricsConfig>) -> Option<MetricsConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(MetricsConfig {
            interval_seconds: o.interval_seconds.or(b.interval_seconds),
            collect_cpu: o.collect_cpu.or(b.collect_cpu),
            collect_memory: o.collect_memory.or(b.collect_memory),
            collect_disk: o.collect_disk.or(b.collect_disk),
            collect_network: o.collect_network.or(b.collect_network),
            collect_temperatures: o.collect_temperatures.or(b.collect_temperatures),
            collect_user_info: o.collect_user_info.or(b.collect_user_info),
            collect_public_ip: o.collect_public_ip.or(b.collect_public_ip),
        }),
    }
}

/// Merge thresholds configuration
fn merge_thresholds(base: &Option<ThresholdsConfig>, overlay: &Option<ThresholdsConfig>) -> Option<ThresholdsConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(ThresholdsConfig {
            cpu: merge_threshold_level(&b.cpu, &o.cpu),
            memory: merge_threshold_level(&b.memory, &o.memory),
            disk: merge_threshold_level(&b.disk, &o.disk),
            cpu_temp: merge_temp_threshold(&b.cpu_temp, &o.cpu_temp),
            gpu_temp: merge_temp_threshold(&b.gpu_temp, &o.gpu_temp),
        }),
    }
}

/// Merge individual threshold level
fn merge_threshold_level(base: &Option<ThresholdLevel>, overlay: &Option<ThresholdLevel>) -> Option<ThresholdLevel> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(ThresholdLevel {
            warning_percent: o.warning_percent.or(b.warning_percent),
            critical_percent: o.critical_percent.or(b.critical_percent),
            duration_seconds: o.duration_seconds.or(b.duration_seconds),
        }),
    }
}

/// Merge temperature threshold
fn merge_temp_threshold(base: &Option<TempThreshold>, overlay: &Option<TempThreshold>) -> Option<TempThreshold> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(TempThreshold {
            warning_celsius: o.warning_celsius.or(b.warning_celsius),
            critical_celsius: o.critical_celsius.or(b.critical_celsius),
        }),
    }
}

/// Merge tray configuration
fn merge_tray(base: &Option<TrayConfig>, overlay: &Option<TrayConfig>) -> Option<TrayConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(TrayConfig {
            enabled: o.enabled.or(b.enabled),
            show_status: o.show_status.or(b.show_status),
            show_info: o.show_info.or(b.show_info),
            allow_force_checkin: o.allow_force_checkin.or(b.allow_force_checkin),
            allow_view_logs: o.allow_view_logs.or(b.allow_view_logs),
            allow_open_dashboard: o.allow_open_dashboard.or(b.allow_open_dashboard),
            allow_stop_agent: o.allow_stop_agent.or(b.allow_stop_agent),
            dashboard_url: o.dashboard_url.clone().or_else(|| b.dashboard_url.clone()),
        }),
    }
}

/// Merge updates configuration
fn merge_updates(base: &Option<UpdatesConfig>, overlay: &Option<UpdatesConfig>) -> Option<UpdatesConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(UpdatesConfig {
            auto_update: o.auto_update.or(b.auto_update),
            channel: o.channel.clone().or_else(|| b.channel.clone()),
            defer_hours: o.defer_hours.or(b.defer_hours),
            maintenance_window: merge_maintenance_window(&b.maintenance_window, &o.maintenance_window),
        }),
    }
}

/// Merge maintenance window (complete replacement if specified)
fn merge_maintenance_window(base: &Option<MaintenanceWindow>, overlay: &Option<MaintenanceWindow>) -> Option<MaintenanceWindow> {
    overlay.clone().or_else(|| base.clone())
}

/// Merge watchdog configuration
fn merge_watchdog(base: &Option<WatchdogConfig>, overlay: &Option<WatchdogConfig>) -> Option<WatchdogConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(WatchdogConfig {
            enabled: o.enabled.or(b.enabled),
            check_interval_seconds: o.check_interval_seconds.or(b.check_interval_seconds),
            // For services/processes, overlay completely replaces if specified
            services: merge_service_list(&b.services, &o.services),
            processes: merge_process_list(&b.processes, &o.processes),
        }),
    }
}

/// Merge service watch lists (overlay replaces if specified)
fn merge_service_list(base: &Option<Vec<ServiceWatch>>, overlay: &Option<Vec<ServiceWatch>>) -> Option<Vec<ServiceWatch>> {
    overlay.clone().or_else(|| base.clone())
}

/// Merge process watch lists (overlay replaces if specified)
fn merge_process_list(base: &Option<Vec<ProcessWatch>>, overlay: &Option<Vec<ProcessWatch>>) -> Option<Vec<ProcessWatch>> {
    overlay.clone().or_else(|| base.clone())
}

/// System default policy values (used when no policy is assigned)
pub fn system_defaults() -> PolicyData {
    PolicyData {
        metrics: Some(MetricsConfig {
            interval_seconds: Some(60),
            collect_cpu: Some(true),
            collect_memory: Some(true),
            collect_disk: Some(true),
            collect_network: Some(true),
            collect_temperatures: Some(true),
            collect_user_info: Some(true),
            collect_public_ip: Some(true),
        }),
        thresholds: Some(ThresholdsConfig {
            cpu: Some(ThresholdLevel {
                warning_percent: Some(80.0),
                critical_percent: Some(95.0),
                duration_seconds: Some(300), // 5 minutes
            }),
            memory: Some(ThresholdLevel {
                warning_percent: Some(85.0),
                critical_percent: Some(95.0),
                duration_seconds: Some(300),
            }),
            disk: Some(ThresholdLevel {
                warning_percent: Some(85.0),
                critical_percent: Some(95.0),
                duration_seconds: Some(0), // Immediate
            }),
            cpu_temp: Some(TempThreshold {
                warning_celsius: Some(80.0),
                critical_celsius: Some(95.0),
            }),
            gpu_temp: Some(TempThreshold {
                warning_celsius: Some(85.0),
                critical_celsius: Some(100.0),
            }),
        }),
        tray: Some(TrayConfig {
            enabled: Some(true),
            show_status: Some(true),
            show_info: Some(true),
            allow_force_checkin: Some(true),
            allow_view_logs: Some(false),
            allow_open_dashboard: Some(true),
            allow_stop_agent: Some(false),
            dashboard_url: None,
        }),
        updates: Some(UpdatesConfig {
            auto_update: Some(true),
            channel: Some("stable".to_string()),
            defer_hours: Some(24),
            maintenance_window: None,
        }),
        watchdog: Some(WatchdogConfig {
            enabled: Some(false),
            check_interval_seconds: Some(30),
            services: Some(vec![]),
            processes: Some(vec![]),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_merge_empty_policies() {
        let base = PolicyData::default();
        let overlay = PolicyData::default();
        let merged = merge_policy_data(&base, &overlay);
        assert!(merged.metrics.is_none());
    }

    #[test]
    fn test_merge_base_only() {
        let base = PolicyData {
            metrics: Some(MetricsConfig {
                interval_seconds: Some(30),
                ..Default::default()
            }),
            ..Default::default()
        };
        let overlay = PolicyData::default();
        let merged = merge_policy_data(&base, &overlay);
        assert_eq!(merged.metrics.unwrap().interval_seconds, Some(30));
    }

    #[test]
    fn test_merge_overlay_wins() {
        let base = PolicyData {
            metrics: Some(MetricsConfig {
                interval_seconds: Some(30),
                collect_cpu: Some(true),
                ..Default::default()
            }),
            ..Default::default()
        };
        let overlay = PolicyData {
            metrics: Some(MetricsConfig {
                interval_seconds: Some(60), // Should override
                collect_cpu: None,          // Should not override
                collect_memory: Some(false), // New value
                ..Default::default()
            }),
            ..Default::default()
        };
        let merged = merge_policy_data(&base, &overlay);
        let metrics = merged.metrics.unwrap();
        assert_eq!(metrics.interval_seconds, Some(60)); // Overlay wins
        assert_eq!(metrics.collect_cpu, Some(true));    // Base preserved
        assert_eq!(metrics.collect_memory, Some(false)); // Overlay adds
    }
}

impl Default for MetricsConfig {
    fn default() -> Self {
        MetricsConfig {
            interval_seconds: None,
            collect_cpu: None,
            collect_memory: None,
            collect_disk: None,
            collect_network: None,
            collect_temperatures: None,
            collect_user_info: None,
            collect_public_ip: None,
        }
    }
}


File created successfully at: C:\Users\MikeSwanson\claude-projects\gururmm\server\src\policy\mod.rs

//! Policy module
//!
//! Handles policy merging and effective policy calculation.
//! Policies stack in hierarchical order: Client → Site → Agent
//! with "last wins" merge logic (later policies override earlier).

mod merge;
mod effective;

pub use merge::*;
pub use effective::*;


File created successfully at: C:\Users\MikeSwanson\claude-projects\gururmm\server\src\policy\merge.rs

//! Policy merge logic
//!
//! Implements "last wins" merge strategy where later policies
//! completely override earlier policies for the same settings.

use crate::db::policies::{
    MetricsConfig, PolicyData, ThresholdLevel, ThresholdsConfig, TempThreshold,
    TrayConfig, UpdatesConfig, WatchdogConfig, MaintenanceWindow, ServiceWatch, ProcessWatch,
};

/// Merge two policies with "last wins" semantics
/// Values from `overlay` override values from `base` when present
pub fn merge_policy_data(base: &PolicyData, overlay: &PolicyData) -> PolicyData {
    PolicyData {
        metrics: merge_metrics(&base.metrics, &overlay.metrics),
        thresholds: merge_thresholds(&base.thresholds, &overlay.thresholds),
        tray: merge_tray(&base.tray, &overlay.tray),
        updates: merge_updates(&base.updates, &overlay.updates),
        watchdog: merge_watchdog(&base.watchdog, &overlay.watchdog),
    }
}

/// Merge metrics configuration
fn merge_metrics(base: &Option<MetricsConfig>, overlay: &Option<MetricsConfig>) -> Option<MetricsConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(MetricsConfig {
            interval_seconds: o.interval_seconds.or(b.interval_seconds),
            collect_cpu: o.collect_cpu.or(b.collect_cpu),
            collect_memory: o.collect_memory.or(b.collect_memory),
            collect_disk: o.collect_disk.or(b.collect_disk),
            collect_network: o.collect_network.or(b.collect_network),
            collect_temperatures: o.collect_temperatures.or(b.collect_temperatures),
            collect_user_info: o.collect_user_info.or(b.collect_user_info),
            collect_public_ip: o.collect_public_ip.or(b.collect_public_ip),
        }),
    }
}

/// Merge thresholds configuration
fn merge_thresholds(base: &Option<ThresholdsConfig>, overlay: &Option<ThresholdsConfig>) -> Option<ThresholdsConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(ThresholdsConfig {
            cpu: merge_threshold_level(&b.cpu, &o.cpu),
            memory: merge_threshold_level(&b.memory, &o.memory),
            disk: merge_threshold_level(&b.disk, &o.disk),
            cpu_temp: merge_temp_threshold(&b.cpu_temp, &o.cpu_temp),
            gpu_temp: merge_temp_threshold(&b.gpu_temp, &o.gpu_temp),
        }),
    }
}

/// Merge individual threshold level
fn merge_threshold_level(base: &Option<ThresholdLevel>, overlay: &Option<ThresholdLevel>) -> Option<ThresholdLevel> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(ThresholdLevel {
            warning_percent: o.warning_percent.or(b.warning_percent),
            critical_percent: o.critical_percent.or(b.critical_percent),
            duration_seconds: o.duration_seconds.or(b.duration_seconds),
        }),
    }
}

/// Merge temperature threshold
fn merge_temp_threshold(base: &Option<TempThreshold>, overlay: &Option<TempThreshold>) -> Option<TempThreshold> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(TempThreshold {
            warning_celsius: o.warning_celsius.or(b.warning_celsius),
            critical_celsius: o.critical_celsius.or(b.critical_celsius),
        }),
    }
}

/// Merge tray configuration
fn merge_tray(base: &Option<TrayConfig>, overlay: &Option<TrayConfig>) -> Option<TrayConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(TrayConfig {
            enabled: o.enabled.or(b.enabled),
            show_status: o.show_status.or(b.show_status),
            show_info: o.show_info.or(b.show_info),
            allow_force_checkin: o.allow_force_checkin.or(b.allow_force_checkin),
            allow_view_logs: o.allow_view_logs.or(b.allow_view_logs),
            allow_open_dashboard: o.allow_open_dashboard.or(b.allow_open_dashboard),
            allow_stop_agent: o.allow_stop_agent.or(b.allow_stop_agent),
            dashboard_url: o.dashboard_url.clone().or_else(|| b.dashboard_url.clone()),
        }),
    }
}

/// Merge updates configuration
fn merge_updates(base: &Option<UpdatesConfig>, overlay: &Option<UpdatesConfig>) -> Option<UpdatesConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(UpdatesConfig {
            auto_update: o.auto_update.or(b.auto_update),
            channel: o.channel.clone().or_else(|| b.channel.clone()),
            defer_hours: o.defer_hours.or(b.defer_hours),
            maintenance_window: merge_maintenance_window(&b.maintenance_window, &o.maintenance_window),
        }),
    }
}

/// Merge maintenance window (complete replacement if specified)
fn merge_maintenance_window(base: &Option<MaintenanceWindow>, overlay: &Option<MaintenanceWindow>) -> Option<MaintenanceWindow> {
    overlay.clone().or_else(|| base.clone())
}

/// Merge watchdog configuration
fn merge_watchdog(base: &Option<WatchdogConfig>, overlay: &Option<WatchdogConfig>) -> Option<WatchdogConfig> {
    match (base, overlay) {
        (None, None) => None,
        (Some(b), None) => Some(b.clone()),
        (None, Some(o)) => Some(o.clone()),
        (Some(b), Some(o)) => Some(WatchdogConfig {
            enabled: o.enabled.or(b.enabled),
            check_interval_seconds: o.check_interval_seconds.or(b.check_interval_seconds),
            // For services/processes, overlay completely replaces if specified
            services: merge_service_list(&b.services, &o.services),
            processes: merge_process_list(&b.processes, &o.processes),
        }),
    }
}

/// Merge service watch lists (overlay replaces if specified)
fn merge_service_list(base: &Option<Vec<ServiceWatch>>, overlay: &Option<Vec<ServiceWatch>>) -> Option<Vec<ServiceWatch>> {
    overlay.clone().or_else(|| base.clone())
}

/// Merge process watch lists (overlay replaces if specified)
fn merge_process_list(base: &Option<Vec<ProcessWatch>>, overlay: &Option<Vec<ProcessWatch>>) -> Option<Vec<ProcessWatch>> {
    overlay.clone().or_else(|| base.clone())
}

/// System default policy values (used when no policy is assigned)
pub fn system_defaults() -> PolicyData {
    PolicyData {
        metrics: Some(MetricsConfig {
            interval_seconds: Some(60),
            collect_cpu: Some(true),
            collect_memory: Some(true),
            collect_disk: Some(true),
            collect_network: Some(true),
            collect_temperatures: Some(true),
            collect_user_info: Some(true),
            collect_public_ip: Some(true),
        }),
        thresholds: Some(ThresholdsConfig {
            cpu: Some(ThresholdLevel {
                warning_percent: Some(80.0),
                critical_percent: Some(95.0),
                duration_seconds: Some(300), // 5 minutes
            }),
            memory: Some(ThresholdLevel {
                warning_percent: Some(85.0),
                critical_percent: Some(95.0),
                duration_seconds: Some(300),
            }),
            disk: Some(ThresholdLevel {
                warning_percent: Some(85.0),
                critical_percent: Some(95.0),
                duration_seconds: Some(0), // Immediate
            }),
            cpu_temp: Some(TempThreshold {
                warning_celsius: Some(80.0),
                critical_celsius: Some(95.0),
            }),
            gpu_temp: Some(TempThreshold {
                warning_celsius: Some(85.0),
                critical_celsius: Some(100.0),
            }),
        }),
        tray: Some(TrayConfig {
            enabled: Some(true),
            show_status: Some(true),
            show_info: Some(true),
            allow_force_checkin: Some(true),
            allow_view_logs: Some(false),
            allow_open_dashboard: Some(true),
            allow_stop_agent: Some(false),
            dashboard_url: None,
        }),
        updates: Some(UpdatesConfig {
            auto_update: Some(true),
            channel: Some("stable".to_string()),
            defer_hours: Some(24),
            maintenance_window: None,
        }),
        watchdog: Some(WatchdogConfig {
            enabled: Some(false),
            check_interval_seconds: Some(30),
            services: Some(vec![]),
            processes: Some(vec![]),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_merge_empty_policies() {
        let base = PolicyData::default();
        let overlay = PolicyData::default();
        let merged = merge_policy_data(&base, &overlay);
        assert!(merged.metrics.is_none());
    }

    #[test]
    fn test_merge_base_only() {
        let base = PolicyData {
            metrics: Some(MetricsConfig {
                interval_seconds: Some(30),
                ..Default::default()
            }),
            ..Default::default()
        };
        let overlay = PolicyData::default();
        let merged = merge_policy_data(&base, &overlay);
        assert_eq!(merged.metrics.unwrap().interval_seconds, Some(30));
    }

    #[test]
    fn test_merge_overlay_wins() {
        let base = PolicyData {
            metrics: Some(MetricsConfig {
                interval_seconds: Some(30),
                collect_cpu: Some(true),
                ..Default::default()
            }),
            ..Default::default()
        };
        let overlay = PolicyData {
            metrics: Some(MetricsConfig {
                interval_seconds: Some(60), // Should override
                collect_cpu: None,          // Should not override
                collect_memory: Some(false), // New value
                ..Default::default()
            }),
            ..Default::default()
        };
        let merged = merge_policy_data(&base, &overlay);
        let metrics = merged.metrics.unwrap();
        assert_eq!(metrics.interval_seconds, Some(60)); // Overlay wins
        assert_eq!(metrics.collect_cpu, Some(true));    // Base preserved
        assert_eq!(metrics.collect_memory, Some(false)); // Overlay adds
    }
}

impl Default for MetricsConfig {
    fn default() -> Self {
        MetricsConfig {
            interval_seconds: None,
            collect_cpu: None,
            collect_memory: None,
            collect_disk: None,
            collect_network: None,
            collect_temperatures: None,
            collect_user_info: None,
            collect_public_ip: None,
        }
    }
}


Now let me create the effective.rs file that calculates the effective policy for an agent by fetching all policies and merging them in order.


//! Effective policy calculation
//!
//! Computes the merged effective policy for an agent by stacking
//! policies from Client → Site → Agent levels.

use sqlx::PgPool;
use uuid::Uuid;
use serde::Serialize;

use crate::db::policies::{get_all_policies_for_agent, PolicyData};
use super::merge::{merge_policy_data, system_defaults};

/// The effective policy for an agent after all merging
#[derive(Debug, Clone, Serialize)]
pub struct EffectivePolicy {
    /// The final merged policy data
    pub policy: PolicyData,
    /// List of policies that contributed, in application order
    pub applied_policies: Vec<AppliedPolicy>,
}

/// A policy that was applied during effective policy calculation
#[derive(Debug, Clone, Serialize)]
pub struct AppliedPolicy {
    pub policy_id: Uuid,
    pub policy_name: String,
    pub level: String,    // "client", "site", "agent"
    pub priority: i32,
}

/// Calculate the effective policy for an agent
///
/// Merges policies in this order:
/// 1. System defaults
/// 2. Client-level policies (sorted by priority)
/// 3. Site-level policies (sorted by priority)
/// 4. Agent-level policies (sorted by priority)
///
/// Later policies override earlier ones ("last wins").
pub async fn get_effective_policy(
    pool: &PgPool,
    agent_id: Uuid,
) -> Result<EffectivePolicy, sqlx::Error> {
    // Start with system defaults
    let mut effective = system_defaults();
    let mut applied = Vec::new();

    // Get all policies that apply to this agent
    let all_policies = get_all_policies_for_agent(pool, agent_id).await?;

    // Apply each policy in order
    for (policy, level, priority) in all_policies {
        effective = merge_policy_data(&effective, &policy.policy_data.0);
        applied.push(AppliedPolicy {
            policy_id: policy.id,
            policy_name: policy.name,
            level,
            priority,
        });
    }

    Ok(EffectivePolicy {
        policy: effective,
        applied_policies: applied,
    })
}

/// Get effective policy data only (without applied policy list)
/// Used for sending to agents
pub async fn get_effective_policy_data(
    pool: &PgPool,
    agent_id: Uuid,
) -> Result<PolicyData, sqlx::Error> {
    let effective = get_effective_policy(pool, agent_id).await?;
    Ok(effective.policy)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_system_defaults_are_complete() {
        let defaults = system_defaults();

        // Metrics should have defaults
        let metrics = defaults.metrics.expect("metrics should be set");
        assert!(metrics.interval_seconds.is_some());
        assert!(metrics.collect_cpu.is_some());

        // Thresholds should have defaults
        let thresholds = defaults.thresholds.expect("thresholds should be set");
        assert!(thresholds.cpu.is_some());
        assert!(thresholds.memory.is_some());
        assert!(thresholds.disk.is_some());

        // Tray should have defaults
        let tray = defaults.tray.expect("tray should be set");
        assert!(tray.enabled.is_some());

        // Updates should have defaults
        let updates = defaults.updates.expect("updates should be set");
        assert!(updates.auto_update.is_some());

        // Watchdog should have defaults
        let watchdog = defaults.watchdog.expect("watchdog should be set");
        assert!(watchdog.enabled.is_some());
    }
}


File created successfully at: C:\Users\MikeSwanson\claude-projects\gururmm\server\src\policy\effective.rs

//! Effective policy calculation
//!
//! Computes the merged effective policy for an agent by stacking
//! policies from Client → Site → Agent levels.

use sqlx::PgPool;
use uuid::Uuid;
use serde::Serialize;

use crate::db::policies::{get_all_policies_for_agent, PolicyData};
use super::merge::{merge_policy_data, system_defaults};

/// The effective policy for an agent after all merging
#[derive(Debug, Clone, Serialize)]
pub struct EffectivePolicy {
    /// The final merged policy data
    pub policy: PolicyData,
    /// List of policies that contributed, in application order
    pub applied_policies: Vec<AppliedPolicy>,
}

/// A policy that was applied during effective policy calculation
#[derive(Debug, Clone, Serialize)]
pub struct AppliedPolicy {
    pub policy_id: Uuid,
    pub policy_name: String,
    pub level: String,    // "client", "site", "agent"
    pub priority: i32,
}

/// Calculate the effective policy for an agent
///
/// Merges policies in this order:
/// 1. System defaults
/// 2. Client-level policies (sorted by priority)
/// 3. Site-level policies (sorted by priority)
/// 4. Agent-level policies (sorted by priority)
///
/// Later policies override earlier ones ("last wins").
pub async fn get_effective_policy(
    pool: &PgPool,
    agent_id: Uuid,
) -> Result<EffectivePolicy, sqlx::Error> {
    // Start with system defaults
    let mut effective = system_defaults();
    let mut applied = Vec::new();

    // Get all policies that apply to this agent
    let all_policies = get_all_policies_for_agent(pool, agent_id).await?;

    // Apply each policy in order
    for (policy, level, priority) in all_policies {
        effective = merge_policy_data(&effective, &policy.policy_data.0);
        applied.push(AppliedPolicy {
            policy_id: policy.id,
            policy_name: policy.name,
            level,
            priority,
        });
    }

    Ok(EffectivePolicy {
        policy: effective,
        applied_policies: applied,
    })
}

/// Get effective policy data only (without applied policy list)
/// Used for sending to agents
pub async fn get_effective_policy_data(
    pool: &PgPool,
    agent_id: Uuid,
) -> Result<PolicyData, sqlx::Error> {
    let effective = get_effective_policy(pool, agent_id).await?;
    Ok(effective.policy)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_system_defaults_are_complete() {
        let defaults = system_defaults();

        // Metrics should have defaults
        let metrics = defaults.metrics.expect("metrics should be set");
        assert!(metrics.interval_seconds.is_some());
        assert!(metrics.collect_cpu.is_some());

        // Thresholds should have defaults
        let thresholds = defaults.thresholds.expect("thresholds should be set");
        assert!(thresholds.cpu.is_some());
        assert!(thresholds.memory.is_some());
        assert!(thresholds.disk.is_some());

        // Tray should have defaults
        let tray = defaults.tray.expect("tray should be set");
        assert!(tray.enabled.is_some());

        // Updates should have defaults
        let updates = defaults.updates.expect("updates should be set");
        assert!(updates.auto_update.is_some());

        // Watchdog should have defaults
        let watchdog = defaults.watchdog.expect("watchdog should be set");
        assert!(watchdog.enabled.is_some());
    }
}


Now I need to register this module in the server's main lib.rs or main.rs. Let me check the server structure.


Now let me check and update the server's main module to include the policy module:


     1→//! GuruRMM Server - RMM Management Server
     2→//!
     3→//! Provides the backend API and WebSocket endpoint for GuruRMM agents.
     4→//! Features:
     5→//! - Agent registration and management
     6→//! - Real-time WebSocket communication with agents
     7→//! - Metrics storage and retrieval
     8→//! - Command execution via agents
     9→//! - Dashboard authentication
    10→
    11→mod api;
    12→mod auth;
    13→mod config;
    14→mod db;
    15→mod updates;
    16→mod ws;
    17→
    18→use std::sync::Arc;
    19→
    20→use anyhow::Result;
    21→use axum::{
    22→    routing::{get, post},
    23→    Router,
    24→};
    25→use sqlx::postgres::PgPoolOptions;
    26→use tokio::sync::RwLock;
    27→use tower_http::cors::{Any, CorsLayer};
    28→use tower_http::trace::TraceLayer;
    29→use tracing::info;
    30→
    31→use crate::config::ServerConfig;
    32→use crate::updates::UpdateManager;
    33→use crate::ws::AgentConnections;
    34→
    35→/// Shared application state
    36→#[derive(Clone)]
    37→pub struct AppState {
    38→    /// Database connection pool
    39→    pub db: sqlx::PgPool,
    40→
    41→    /// Server configuration
    42→    pub config: Arc<ServerConfig>,
    43→
    44→    /// Connected agents (WebSocket connections)
    45→    pub agents: Arc<RwLock<AgentConnections>>,
    46→
    47→    /// Agent update manager
    48→    pub updates: Arc<UpdateManager>,
    49→}
    50→
    51→#[tokio::main]
    52→async fn main() -> Result<()> {
    53→    // Load environment variables from .env file
    54→    dotenvy::dotenv().ok();
    55→
    56→    // Initialize logging
    57→    tracing_subscriber::fmt()
    58→        .with_env_filter(
    59→            tracing_subscriber::EnvFilter::from_default_env()
    60→                .add_directive("gururmm_server=info".parse()?)
    61→                .add_directive("tower_http=debug".parse()?)
    62→                .add_directive("info".parse()?),
    63→        )
    64→        .init();
    65→
    66→    info!("GuruRMM Server starting...");
    67→
    68→    // Load configuration
    69→    let config = ServerConfig::from_env()?;
    70→    info!("Server configuration loaded");
    71→
    72→    // Connect to database
    73→    info!("Connecting to database...");
    74→    let db = PgPoolOptions::new()
    75→        .max_connections(config.database.max_connections)
    76→        .connect(&config.database.url)
    77→        .await?;
    78→    info!("Database connected");
    79→
    80→    // Run migrations
    81→    info!("Running database migrations...");
    82→    sqlx::migrate!("./migrations").run(&db).await?;
    83→    info!("Migrations complete");
    84→
    85→    // Initialize update manager
    86→    let update_manager = UpdateManager::new(
    87→        config.updates.downloads_dir.clone(),
    88→        config.updates.downloads_base_url.clone(),
    89→        config.updates.auto_update_enabled,
    90→        config.updates.update_timeout_secs,
    91→    );
    92→
    93→    // Initial scan for available versions
    94→    info!("Scanning for available agent versions...");
    95→    if let Err(e) = update_manager.scan_versions().await {
    96→        tracing::warn!("Failed to scan agent versions: {} (continuing without auto-update)", e);
    97→    }
    98→
    99→    let update_manager = Arc::new(update_manager);
   100→
   101→    // Spawn background scanner (handle is intentionally not awaited - runs until server shutdown)
   102→    let _scanner_handle = update_manager.spawn_scanner(config.updates.scan_interval_secs);
   103→    info!(
   104→        "Auto-update: {} (scan interval: {}s)",
   105→        if config.updates.auto_update_enabled { "enabled" } else { "disabled" },
   106→        config.updates.scan_interval_secs
   107→    );
   108→
   109→    // Create shared state
   110→    let state = AppState {
   111→        db,
   112→        config: Arc::new(config.clone()),
   113→        agents: Arc::new(RwLock::new(AgentConnections::new())),
   114→        updates: update_manager,
   115→    };
   116→
   117→    // Build router
   118→    let app = build_router(state);
   119→
   120→    // Start server
   121→    let addr = format!("{}:{}", config.server.host, config.server.port);
   122→    info!("Starting server on {}", addr);
   123→
   124→    let listener = tokio::net::TcpListener::bind(&addr).await?;
   125→    axum::serve(listener, app).await?;
   126→
   127→    Ok(())
   128→}
   129→
   130→/// Build the application router
   131→fn build_router(state: AppState) -> Router {
   132→    // CORS configuration (allow dashboard access)
   133→    let cors = CorsLayer::new()
   134→        .allow_origin(Any)
   135→        .allow_methods(Any)
   136→        .allow_headers(Any);
   137→
   138→    Router::new()
   139→        // Health check
   140→        .route("/health", get(health_check))
   141→        // WebSocket endpoint for agents
   142→        .route("/ws", get(ws::ws_handler))
   143→        // API routes
   144→        .nest("/api", api::routes())
   145→        // Middleware
   146→        .layer(TraceLayer::new_for_http())
   147→        .layer(cors)
   148→        // State
   149→        .with_state(state)
   150→}
   151→
   152→/// Health check endpoint
   153→async fn health_check() -> &'static str {
   154→    "OK"
   155→}
   156→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! GuruRMM Server - RMM Management Server
//!
//! Provides the backend API and WebSocket endpoint for GuruRMM agents.
//! Features:
//! - Agent registration and management
//! - Real-time WebSocket communication with agents
//! - Metrics storage and retrieval
//! - Command execution via agents
//! - Dashboard authentication

mod api;
mod auth;
mod config;
mod db;
mod updates;
mod ws;

use std::sync::Arc;

use anyhow::Result;
use axum::{
    routing::{get, post},
    Router,
};
use sqlx::postgres::PgPoolOptions;
use tokio::sync::RwLock;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::info;

use crate::config::ServerConfig;
use crate::updates::UpdateManager;
use crate::ws::AgentConnections;

/// Shared application state
#[derive(Clone)]
pub struct AppState {
    /// Database connection pool
    pub db: sqlx::PgPool,

    /// Server configuration
    pub config: Arc<ServerConfig>,

    /// Connected agents (WebSocket connections)
    pub agents: Arc<RwLock<AgentConnections>>,

    /// Agent update manager
    pub updates: Arc<UpdateManager>,
}

#[tokio::main]
async fn main() -> Result<()> {
    // Load environment variables from .env file
    dotenvy::dotenv().ok();

    // Initialize logging
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("gururmm_server=info".parse()?)
                .add_directive("tower_http=debug".parse()?)
                .add_directive("info".parse()?),
        )
        .init();

    info!("GuruRMM Server starting...");

    // Load configuration
    let config = ServerConfig::from_env()?;
    info!("Server configuration loaded");

    // Connect to database
    info!("Connecting to database...");
    let db = PgPoolOptions::new()
        .max_connections(config.database.max_connections)
        .connect(&config.database.url)
        .await?;
    info!("Database connected");

    // Run migrations
    info!("Running database migrations...");
    sqlx::migrate!("./migrations").run(&db).await?;
    info!("Migrations complete");

    // Initialize update manager
    let update_manager = UpdateManager::new(
        config.updates.downloads_dir.clone(),
        config.updates.downloads_base_url.clone(),
        config.updates.auto_update_enabled,
        config.updates.update_timeout_secs,
    );

    // Initial scan for available versions
    info!("Scanning for available agent versions...");
    if let Err(e) = update_manager.scan_versions().await {
        tracing::warn!("Failed to scan agent versions: {} (continuing without auto-update)", e);
    }

    let update_manager = Arc::new(update_manager);

    // Spawn background scanner (handle is intentionally not awaited - runs until server shutdown)
    let _scanner_handle = update_manager.spawn_scanner(config.updates.scan_interval_secs);
    info!(
        "Auto-update: {} (scan interval: {}s)",
        if config.updates.auto_update_enabled { "enabled" } else { "disabled" },
        config.updates.scan_interval_secs
    );

    // Create shared state
    let state = AppState {
        db,
        config: Arc::new(config.clone()),
        agents: Arc::new(RwLock::new(AgentConnections::new())),
        updates: update_manager,
    };

    // Build router
    let app = build_router(state);

    // Start server
    let addr = format!("{}:{}", config.server.host, config.server.port);
    info!("Starting server on {}", addr);

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

/// Build the application router
fn build_router(state: AppState) -> Router {
    // CORS configuration (allow dashboard access)
    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any);

    Router::new()
        // Health check
        .route("/health", get(health_check))
        // WebSocket endpoint for agents
        .route("/ws", get(ws::ws_handler))
        // API routes
        .nest("/api", api::routes())
        // Middleware
        .layer(TraceLayer::new_for_http())
        .layer(cors)
        // State
        .with_state(state)
}

/// Health check endpoint
async fn health_check() -> &'static str {
    "OK"
}


C:\Users\MikeSwanson\claude-projects\gururmm\server\src\main.rs
C:\Users\MikeSwanson\claude-projects\gururmm\server\src\config.rs


I need to add the policy module to the main.rs. Let me update it and then move on to Phase 2 - creating API endpoints for policies and alerts.


Edit

mod api;
mod auth;
mod config;
mod db;
mod updates;
mod ws;

mod api;
mod auth;
mod config;
mod db;
mod policy;
mod updates;
mod ws;


The file C:\Users\MikeSwanson\claude-projects\gururmm\server\src\main.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
     7→//! - Metrics storage and retrieval
     8→//! - Command execution via agents
     9→//! - Dashboard authentication
    10→
    11→mod api;
    12→mod auth;
    13→mod config;
    14→mod db;
    15→mod policy;
    16→mod updates;
    17→mod ws;
    18→
    19→use std::sync::Arc;
    20→
    21→use anyhow::Result;

mod api;
mod auth;
mod config;
mod db;
mod policy;
mod updates;
mod ws;

//! GuruRMM Server - RMM Management Server
//!
//! Provides the backend API and WebSocket endpoint for GuruRMM agents.
//! Features:
//! - Agent registration and management
//! - Real-time WebSocket communication with agents
//! - Metrics storage and retrieval
//! - Command execution via agents
//! - Dashboard authentication

mod api;
mod auth;
mod config;
mod db;
mod updates;
mod ws;

use std::sync::Arc;

use anyhow::Result;
use axum::{
    routing::{get, post},
    Router,
};
use sqlx::postgres::PgPoolOptions;
use tokio::sync::RwLock;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::info;

use crate::config::ServerConfig;
use crate::updates::UpdateManager;
use crate::ws::AgentConnections;

/// Shared application state
#[derive(Clone)]
pub struct AppState {
    /// Database connection pool
    pub db: sqlx::PgPool,

    /// Server configuration
    pub config: Arc<ServerConfig>,

    /// Connected agents (WebSocket connections)
    pub agents: Arc<RwLock<AgentConnections>>,

    /// Agent update manager
    pub updates: Arc<UpdateManager>,
}

#[tokio::main]
async fn main() -> Result<()> {
    // Load environment variables from .env file
    dotenvy::dotenv().ok();

    // Initialize logging
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("gururmm_server=info".parse()?)
                .add_directive("tower_http=debug".parse()?)
                .add_directive("info".parse()?),
        )
        .init();

    info!("GuruRMM Server starting...");

    // Load configuration
    let config = ServerConfig::from_env()?;
    info!("Server configuration loaded");

    // Connect to database
    info!("Connecting to database...");
    let db = PgPoolOptions::new()
        .max_connections(config.database.max_connections)
        .connect(&config.database.url)
        .await?;
    info!("Database connected");

    // Run migrations
    info!("Running database migrations...");
    sqlx::migrate!("./migrations").run(&db).await?;
    info!("Migrations complete");

    // Initialize update manager
    let update_manager = UpdateManager::new(
        config.updates.downloads_dir.clone(),
        config.updates.downloads_base_url.clone(),
        config.updates.auto_update_enabled,
        config.updates.update_timeout_secs,
    );

    // Initial scan for available versions
    info!("Scanning for available agent versions...");
    if let Err(e) = update_manager.scan_versions().await {
        tracing::warn!("Failed to scan agent versions: {} (continuing without auto-update)", e);
    }

    let update_manager = Arc::new(update_manager);

    // Spawn background scanner (handle is intentionally not awaited - runs until server shutdown)
    let _scanner_handle = update_manager.spawn_scanner(config.updates.scan_interval_secs);
    info!(
        "Auto-update: {} (scan interval: {}s)",
        if config.updates.auto_update_enabled { "enabled" } else { "disabled" },
        config.updates.scan_interval_secs
    );

    // Create shared state
    let state = AppState {
        db,
        config: Arc::new(config.clone()),
        agents: Arc::new(RwLock::new(AgentConnections::new())),
        updates: update_manager,
    };

    // Build router
    let app = build_router(state);

    // Start server
    let addr = format!("{}:{}", config.server.host, config.server.port);
    info!("Starting server on {}", addr);

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

/// Build the application router
fn build_router(state: AppState) -> Router {
    // CORS configuration (allow dashboard access)
    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any);

    Router::new()
        // Health check
        .route("/health", get(health_check))
        // WebSocket endpoint for agents
        .route("/ws", get(ws::ws_handler))
        // API routes
        .nest("/api", api::routes())
        // Middleware
        .layer(TraceLayer::new_for_http())
        .layer(cors)
        // State
        .with_state(state)
}

/// Health check endpoint
async fn health_check() -> &'static str {
    "OK"
}

mod auth;
mod config;
mod db;
+mod policy;
mod updates;
mod ws;


Now I need to update the todo list and move on to Phase 2 - creating API endpoints. Let me first check the existing API structure.


Now let me update the todo and move on to Phase 2 - creating the API endpoints:


TodoWrite

Phase 1: Create database migration (006_policies.sql)

Phase 1: Create server/src/db/policies.rs

Phase 1: Create server/src/db/alerts.rs

Phase 1: Create server/src/policy/ module with merge logic

Phase 2: Create API endpoints for policies and alerts

Phase 3: Create alert evaluation engine

Phase 4: Update agent to apply policies

Phase 5: Create dashboard UI for policies and alerts


Phase 1: Create server/src/db/alerts.rs

Phase 3: Create alert evaluation engine

Phase 4: Update agent to apply policies

Phase 1: Create server/src/db/alerts.rs

Phase 3: Create alert evaluation engine

Phase 4: Update agent to apply policies


     1→//! REST API routes
     2→//!
     3→//! Provides endpoints for:
     4→//! - Agent management (registration, listing, deletion)
     5→//! - Client and site management
     6→//! - Metrics retrieval
     7→//! - Command execution
     8→//! - User authentication
     9→
    10→pub mod agents;
    11→pub mod auth;
    12→pub mod clients;
    13→pub mod commands;
    14→pub mod metrics;
    15→pub mod sites;
    16→pub mod sso;
    17→pub mod users;
    18→
    19→use axum::{
    20→    routing::{delete, get, post, put},
    21→    Router,
    22→};
    23→
    24→use crate::AppState;
    25→
    26→/// Build all API routes
    27→pub fn routes() -> Router<AppState> {
    28→    Router::new()
    29→        // Authentication
    30→        .route("/auth/login", post(auth::login))
    31→        .route("/auth/register", post(auth::register))
    32→        .route("/auth/me", get(auth::me))
    33→        // SSO
    34→        .route("/auth/sso/entra/config", get(sso::entra_config))
    35→        .route("/auth/sso/entra/callback", post(sso::entra_callback))
    36→        // Users (admin)
    37→        .route("/users", get(users::list_users))
    38→        .route("/users/:id", get(users::get_user))
    39→        .route("/users/:id", delete(users::delete_user))
    40→        .route("/users/:id/role", put(users::update_user_role))
    41→        // Clients
    42→        .route("/clients", get(clients::list_clients))
    43→        .route("/clients", post(clients::create_client))
    44→        .route("/clients/:id", get(clients::get_client))
    45→        .route("/clients/:id", put(clients::update_client))
    46→        .route("/clients/:id", delete(clients::delete_client))
    47→        .route("/clients/:id/sites", get(sites::list_sites_by_client))
    48→        // Sites
    49→        .route("/sites", get(sites::list_sites))
    50→        .route("/sites", post(sites::create_site))
    51→        .route("/sites/:id", get(sites::get_site))
    52→        .route("/sites/:id", put(sites::update_site))
    53→        .route("/sites/:id", delete(sites::delete_site))
    54→        .route("/sites/:id/regenerate-key", post(sites::regenerate_api_key))
    55→        // Agents
    56→        .route("/agents", get(agents::list_agents_with_details))
    57→        .route("/agents", post(agents::register_agent))
    58→        .route("/agents/stats", get(agents::get_stats))
    59→        .route("/agents/unassigned", get(agents::list_unassigned_agents))
    60→        .route("/agents/:id", get(agents::get_agent))
    61→        .route("/agents/:id", delete(agents::delete_agent))
    62→        .route("/agents/:id/move", post(agents::move_agent))
    63→        .route("/agents/:id/state", get(agents::get_agent_state))
    64→        .route("/agents/:id/update", post(agents::trigger_update))
    65→        // Metrics
    66→        .route("/agents/:id/metrics", get(metrics::get_agent_metrics))
    67→        .route("/metrics/summary", get(metrics::get_summary))
    68→        // Commands
    69→        .route("/agents/:id/command", post(commands::send_command))
    70→        .route("/commands", get(commands::list_commands))
    71→        .route("/commands/:id", get(commands::get_command))
    72→        // Legacy Agent (PowerShell for 2008 R2)
    73→        .route("/agent/register-legacy", post(agents::register_legacy))
    74→        .route("/agent/heartbeat", post(agents::heartbeat))
    75→        .route("/agent/command-result", post(agents::command_result))
    76→}
    77→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! REST API routes
//!
//! Provides endpoints for:
//! - Agent management (registration, listing, deletion)
//! - Client and site management
//! - Metrics retrieval
//! - Command execution
//! - User authentication

pub mod agents;
pub mod auth;
pub mod clients;
pub mod commands;
pub mod metrics;
pub mod sites;
pub mod sso;
pub mod users;

use axum::{
    routing::{delete, get, post, put},
    Router,
};

use crate::AppState;

/// Build all API routes
pub fn routes() -> Router<AppState> {
    Router::new()
        // Authentication
        .route("/auth/login", post(auth::login))
        .route("/auth/register", post(auth::register))
        .route("/auth/me", get(auth::me))
        // SSO
        .route("/auth/sso/entra/config", get(sso::entra_config))
        .route("/auth/sso/entra/callback", post(sso::entra_callback))
        // Users (admin)
        .route("/users", get(users::list_users))
        .route("/users/:id", get(users::get_user))
        .route("/users/:id", delete(users::delete_user))
        .route("/users/:id/role", put(users::update_user_role))
        // Clients
        .route("/clients", get(clients::list_clients))
        .route("/clients", post(clients::create_client))
        .route("/clients/:id", get(clients::get_client))
        .route("/clients/:id", put(clients::update_client))
        .route("/clients/:id", delete(clients::delete_client))
        .route("/clients/:id/sites", get(sites::list_sites_by_client))
        // Sites
        .route("/sites", get(sites::list_sites))
        .route("/sites", post(sites::create_site))
        .route("/sites/:id", get(sites::get_site))
        .route("/sites/:id", put(sites::update_site))
        .route("/sites/:id", delete(sites::delete_site))
        .route("/sites/:id/regenerate-key", post(sites::regenerate_api_key))
        // Agents
        .route("/agents", get(agents::list_agents_with_details))
        .route("/agents", post(agents::register_agent))
        .route("/agents/stats", get(agents::get_stats))
        .route("/agents/unassigned", get(agents::list_unassigned_agents))
        .route("/agents/:id", get(agents::get_agent))
        .route("/agents/:id", delete(agents::delete_agent))
        .route("/agents/:id/move", post(agents::move_agent))
        .route("/agents/:id/state", get(agents::get_agent_state))
        .route("/agents/:id/update", post(agents::trigger_update))
        // Metrics
        .route("/agents/:id/metrics", get(metrics::get_agent_metrics))
        .route("/metrics/summary", get(metrics::get_summary))
        // Commands
        .route("/agents/:id/command", post(commands::send_command))
        .route("/commands", get(commands::list_commands))
        .route("/commands/:id", get(commands::get_command))
        // Legacy Agent (PowerShell for 2008 R2)
        .route("/agent/register-legacy", post(agents::register_legacy))
        .route("/agent/heartbeat", post(agents::heartbeat))
        .route("/agent/command-result", post(agents::command_result))
}


I need to add the policies and alerts API modules. Let me read an existing API module to see the patterns used, then create the new ones.


Let me check an existing API module for patterns:


     1→//! Client management API endpoints
     2→
     3→use axum::{
     4→    extract::{Path, State},
     5→    http::StatusCode,
     6→    Json,
     7→};
     8→use serde::{Deserialize, Serialize};
     9→use uuid::Uuid;
    10→
    11→use crate::auth::AuthUser;
    12→use crate::db;
    13→use crate::AppState;
    14→
    15→/// Response for client operations
    16→#[derive(Debug, Serialize)]
    17→pub struct ClientResponse {
    18→    pub id: Uuid,
    19→    pub name: String,
    20→    pub code: Option<String>,
    21→    pub notes: Option<String>,
    22→    pub is_active: bool,
    23→    pub created_at: chrono::DateTime<chrono::Utc>,
    24→    pub site_count: i64,
    25→}
    26→
    27→/// Request to create a new client
    28→#[derive(Debug, Deserialize)]
    29→pub struct CreateClientRequest {
    30→    pub name: String,
    31→    pub code: Option<String>,
    32→    pub notes: Option<String>,
    33→}
    34→
    35→/// Request to update a client
    36→#[derive(Debug, Deserialize)]
    37→pub struct UpdateClientRequest {
    38→    pub name: Option<String>,
    39→    pub code: Option<String>,
    40→    pub notes: Option<String>,
    41→    pub is_active: Option<bool>,
    42→}
    43→
    44→/// List all clients
    45→pub async fn list_clients(
    46→    _user: AuthUser,
    47→    State(state): State<AppState>,
    48→) -> Result<Json<Vec<ClientResponse>>, (StatusCode, String)> {
    49→    let clients = db::get_all_clients_with_counts(&state.db)
    50→        .await
    51→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    52→
    53→    let responses: Vec<ClientResponse> = clients
    54→        .into_iter()
    55→        .map(|c| ClientResponse {
    56→            id: c.id,
    57→            name: c.name,
    58→            code: c.code,
    59→            notes: c.notes,
    60→            is_active: c.is_active,
    61→            created_at: c.created_at,
    62→            site_count: c.site_count,
    63→        })
    64→        .collect();
    65→
    66→    Ok(Json(responses))
    67→}
    68→
    69→/// Create a new client
    70→pub async fn create_client(
    71→    _user: AuthUser,
    72→    State(state): State<AppState>,
    73→    Json(req): Json<CreateClientRequest>,
    74→) -> Result<Json<ClientResponse>, (StatusCode, String)> {
    75→    let create = db::CreateClient {
    76→        name: req.name,
    77→        code: req.code,
    78→        notes: req.notes,
    79→    };
    80→
    81→    let client = db::create_client(&state.db, create)
    82→        .await
    83→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    84→
    85→    Ok(Json(ClientResponse {
    86→        id: client.id,
    87→        name: client.name,
    88→        code: client.code,
    89→        notes: client.notes,
    90→        is_active: client.is_active,
    91→        created_at: client.created_at,
    92→        site_count: 0,
    93→    }))
    94→}
    95→
    96→/// Get a specific client
    97→pub async fn get_client(
    98→    _user: AuthUser,
    99→    State(state): State<AppState>,
   100→    Path(id): Path<Uuid>,
   101→) -> Result<Json<ClientResponse>, (StatusCode, String)> {
   102→    let client = db::get_client_by_id(&state.db, id)
   103→        .await
   104→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   105→        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;
   106→
   107→    // Get site count
   108→    let sites = db::get_sites_by_client(&state.db, id)
   109→        .await
   110→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   111→
   112→    Ok(Json(ClientResponse {
   113→        id: client.id,
   114→        name: client.name,
   115→        code: client.code,
   116→        notes: client.notes,
   117→        is_active: client.is_active,
   118→        created_at: client.created_at,
   119→        site_count: sites.len() as i64,
   120→    }))
   121→}
   122→
   123→/// Update a client
   124→pub async fn update_client(
   125→    _user: AuthUser,
   126→    State(state): State<AppState>,
   127→    Path(id): Path<Uuid>,
   128→    Json(req): Json<UpdateClientRequest>,
   129→) -> Result<Json<ClientResponse>, (StatusCode, String)> {
   130→    let update = db::UpdateClient {
   131→        name: req.name,
   132→        code: req.code,
   133→        notes: req.notes,
   134→        is_active: req.is_active,
   135→    };
   136→
   137→    let client = db::update_client(&state.db, id, update)
   138→        .await
   139→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   140→        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;
   141→
   142→    Ok(Json(ClientResponse {
   143→        id: client.id,
   144→        name: client.name,
   145→        code: client.code,
   146→        notes: client.notes,
   147→        is_active: client.is_active,
   148→        created_at: client.created_at,
   149→        site_count: 0, // Would need to query again
   150→    }))
   151→}
   152→
   153→/// Delete a client
   154→pub async fn delete_client(
   155→    _user: AuthUser,
   156→    State(state): State<AppState>,
   157→    Path(id): Path<Uuid>,
   158→) -> Result<StatusCode, (StatusCode, String)> {
   159→    let deleted = db::delete_client(&state.db, id)
   160→        .await
   161→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   162→
   163→    if deleted {
   164→        Ok(StatusCode::NO_CONTENT)
   165→    } else {
   166→        Err((StatusCode::NOT_FOUND, "Client not found".to_string()))
   167→    }
   168→}
   169→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! Client management API endpoints

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::auth::AuthUser;
use crate::db;
use crate::AppState;

/// Response for client operations
#[derive(Debug, Serialize)]
pub struct ClientResponse {
    pub id: Uuid,
    pub name: String,
    pub code: Option<String>,
    pub notes: Option<String>,
    pub is_active: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub site_count: i64,
}

/// Request to create a new client
#[derive(Debug, Deserialize)]
pub struct CreateClientRequest {
    pub name: String,
    pub code: Option<String>,
    pub notes: Option<String>,
}

/// Request to update a client
#[derive(Debug, Deserialize)]
pub struct UpdateClientRequest {
    pub name: Option<String>,
    pub code: Option<String>,
    pub notes: Option<String>,
    pub is_active: Option<bool>,
}

/// List all clients
pub async fn list_clients(
    _user: AuthUser,
    State(state): State<AppState>,
) -> Result<Json<Vec<ClientResponse>>, (StatusCode, String)> {
    let clients = db::get_all_clients_with_counts(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<ClientResponse> = clients
        .into_iter()
        .map(|c| ClientResponse {
            id: c.id,
            name: c.name,
            code: c.code,
            notes: c.notes,
            is_active: c.is_active,
            created_at: c.created_at,
            site_count: c.site_count,
        })
        .collect();

    Ok(Json(responses))
}

/// Create a new client
pub async fn create_client(
    _user: AuthUser,
    State(state): State<AppState>,
    Json(req): Json<CreateClientRequest>,
) -> Result<Json<ClientResponse>, (StatusCode, String)> {
    let create = db::CreateClient {
        name: req.name,
        code: req.code,
        notes: req.notes,
    };

    let client = db::create_client(&state.db, create)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(ClientResponse {
        id: client.id,
        name: client.name,
        code: client.code,
        notes: client.notes,
        is_active: client.is_active,
        created_at: client.created_at,
        site_count: 0,
    }))
}

/// Get a specific client
pub async fn get_client(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<ClientResponse>, (StatusCode, String)> {
    let client = db::get_client_by_id(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;

    // Get site count
    let sites = db::get_sites_by_client(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(ClientResponse {
        id: client.id,
        name: client.name,
        code: client.code,
        notes: client.notes,
        is_active: client.is_active,
        created_at: client.created_at,
        site_count: sites.len() as i64,
    }))
}

/// Update a client
pub async fn update_client(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(req): Json<UpdateClientRequest>,
) -> Result<Json<ClientResponse>, (StatusCode, String)> {
    let update = db::UpdateClient {
        name: req.name,
        code: req.code,
        notes: req.notes,
        is_active: req.is_active,
    };

    let client = db::update_client(&state.db, id, update)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;

    Ok(Json(ClientResponse {
        id: client.id,
        name: client.name,
        code: client.code,
        notes: client.notes,
        is_active: client.is_active,
        created_at: client.created_at,
        site_count: 0, // Would need to query again
    }))
}

/// Delete a client
pub async fn delete_client(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    let deleted = db::delete_client(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if deleted {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err((StatusCode::NOT_FOUND, "Client not found".to_string()))
    }
}


Now I understand the patterns. Let me create the policies.rs and alerts.rs API modules, then update the mod.rs to include them.


Now let me create the policies and alerts API modules:


Write

//! Policy management API endpoints

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::auth::AuthUser;
use crate::db;
use crate::db::policies::{
    AssignmentTarget, CreatePolicy, CreatePolicyAssignment, PolicyData, UpdatePolicy,
};
use crate::policy::get_effective_policy;
use crate::AppState;

/// Response for policy operations
#[derive(Debug, Serialize)]
pub struct PolicyResponse {
    pub id: Uuid,
    pub name: String,
    pub description: Option<String>,
    pub policy_data: PolicyData,
    pub is_default: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
    pub assignment_count: i64,
}

/// Response for policy assignment
#[derive(Debug, Serialize)]
pub struct AssignmentResponse {
    pub id: Uuid,
    pub policy_id: Uuid,
    pub policy_name: String,
    pub target_type: String,
    pub target_id: Uuid,
    pub target_name: String,
    pub priority: i32,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

/// Response for effective policy
#[derive(Debug, Serialize)]
pub struct EffectivePolicyResponse {
    pub policy: PolicyData,
    pub applied_policies: Vec<AppliedPolicyInfo>,
}

#[derive(Debug, Serialize)]
pub struct AppliedPolicyInfo {
    pub policy_id: Uuid,
    pub policy_name: String,
    pub level: String,
    pub priority: i32,
}

/// Request to create a policy
#[derive(Debug, Deserialize)]
pub struct CreatePolicyRequest {
    pub name: String,
    pub description: Option<String>,
    pub policy_data: PolicyData,
    pub is_default: Option<bool>,
}

/// Request to update a policy
#[derive(Debug, Deserialize)]
pub struct UpdatePolicyRequest {
    pub name: Option<String>,
    pub description: Option<String>,
    pub policy_data: Option<PolicyData>,
    pub is_default: Option<bool>,
}

/// Request to assign a policy
#[derive(Debug, Deserialize)]
pub struct AssignPolicyRequest {
    pub target_type: AssignmentTarget,
    pub target_id: Uuid,
    pub priority: Option<i32>,
}

/// List all policies
pub async fn list_policies(
    _user: AuthUser,
    State(state): State<AppState>,
) -> Result<Json<Vec<PolicyResponse>>, (StatusCode, String)> {
    let policies = db::get_all_policies_with_counts(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<PolicyResponse> = policies
        .into_iter()
        .map(|p| PolicyResponse {
            id: p.id,
            name: p.name,
            description: p.description,
            policy_data: p.policy_data.0,
            is_default: p.is_default,
            created_at: p.created_at,
            updated_at: p.updated_at,
            assignment_count: p.assignment_count,
        })
        .collect();

    Ok(Json(responses))
}

/// Create a new policy
pub async fn create_policy(
    user: AuthUser,
    State(state): State<AppState>,
    Json(req): Json<CreatePolicyRequest>,
) -> Result<Json<PolicyResponse>, (StatusCode, String)> {
    let create = CreatePolicy {
        name: req.name,
        description: req.description,
        policy_data: req.policy_data,
        is_default: req.is_default,
    };

    let policy = db::create_policy(&state.db, create, Some(user.id))
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(PolicyResponse {
        id: policy.id,
        name: policy.name,
        description: policy.description,
        policy_data: policy.policy_data.0,
        is_default: policy.is_default,
        created_at: policy.created_at,
        updated_at: policy.updated_at,
        assignment_count: 0,
    }))
}

/// Get a specific policy
pub async fn get_policy(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<PolicyResponse>, (StatusCode, String)> {
    let policy = db::get_policy_by_id(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Policy not found".to_string()))?;

    // Get assignment count
    let assignments = db::get_assignments_for_policy(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(PolicyResponse {
        id: policy.id,
        name: policy.name,
        description: policy.description,
        policy_data: policy.policy_data.0,
        is_default: policy.is_default,
        created_at: policy.created_at,
        updated_at: policy.updated_at,
        assignment_count: assignments.len() as i64,
    }))
}

/// Update a policy
pub async fn update_policy(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(req): Json<UpdatePolicyRequest>,
) -> Result<Json<PolicyResponse>, (StatusCode, String)> {
    let update = UpdatePolicy {
        name: req.name,
        description: req.description,
        policy_data: req.policy_data,
        is_default: req.is_default,
    };

    let policy = db::update_policy(&state.db, id, update)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Policy not found".to_string()))?;

    Ok(Json(PolicyResponse {
        id: policy.id,
        name: policy.name,
        description: policy.description,
        policy_data: policy.policy_data.0,
        is_default: policy.is_default,
        created_at: policy.created_at,
        updated_at: policy.updated_at,
        assignment_count: 0,
    }))
}

/// Delete a policy
pub async fn delete_policy(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    let deleted = db::delete_policy(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if deleted {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err((StatusCode::NOT_FOUND, "Policy not found".to_string()))
    }
}

/// Assign a policy to a target
pub async fn assign_policy(
    user: AuthUser,
    State(state): State<AppState>,
    Path(policy_id): Path<Uuid>,
    Json(req): Json<AssignPolicyRequest>,
) -> Result<Json<AssignmentResponse>, (StatusCode, String)> {
    // Verify policy exists
    db::get_policy_by_id(&state.db, policy_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Policy not found".to_string()))?;

    let assignment_data = CreatePolicyAssignment {
        target_type: req.target_type,
        target_id: req.target_id,
        priority: req.priority,
    };

    let assignment = db::assign_policy(&state.db, policy_id, assignment_data, Some(user.id))
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    // Get details for response
    let assignments = db::get_assignments_for_policy(&state.db, policy_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let created = assignments
        .into_iter()
        .find(|a| a.id == assignment.id)
        .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Assignment created but not found".to_string()))?;

    let target_type = if created.client_id.is_some() {
        "client"
    } else if created.site_id.is_some() {
        "site"
    } else {
        "agent"
    };

    Ok(Json(AssignmentResponse {
        id: created.id,
        policy_id: created.policy_id,
        policy_name: created.policy_name,
        target_type: target_type.to_string(),
        target_id: req.target_id,
        target_name: created.target_name,
        priority: created.priority,
        created_at: created.created_at,
    }))
}

/// List assignments for a policy
pub async fn list_policy_assignments(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(policy_id): Path<Uuid>,
) -> Result<Json<Vec<AssignmentResponse>>, (StatusCode, String)> {
    let assignments = db::get_assignments_for_policy(&state.db, policy_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<AssignmentResponse> = assignments
        .into_iter()
        .map(|a| {
            let (target_type, target_id) = if let Some(id) = a.client_id {
                ("client", id)
            } else if let Some(id) = a.site_id {
                ("site", id)
            } else if let Some(id) = a.agent_id {
                ("agent", id)
            } else {
                ("unknown", Uuid::nil())
            };

            AssignmentResponse {
                id: a.id,
                policy_id: a.policy_id,
                policy_name: a.policy_name,
                target_type: target_type.to_string(),
                target_id,
                target_name: a.target_name,
                priority: a.priority,
                created_at: a.created_at,
            }
        })
        .collect();

    Ok(Json(responses))
}

/// Remove a policy assignment
pub async fn remove_assignment(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(assignment_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    let deleted = db::remove_assignment(&state.db, assignment_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if deleted {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err((StatusCode::NOT_FOUND, "Assignment not found".to_string()))
    }
}

/// Get effective (merged) policy for an agent
pub async fn get_agent_effective_policy(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(agent_id): Path<Uuid>,
) -> Result<Json<EffectivePolicyResponse>, (StatusCode, String)> {
    let effective = get_effective_policy(&state.db, agent_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(EffectivePolicyResponse {
        policy: effective.policy,
        applied_policies: effective
            .applied_policies
            .into_iter()
            .map(|p| AppliedPolicyInfo {
                policy_id: p.policy_id,
                policy_name: p.policy_name,
                level: p.level,
                priority: p.priority,
            })
            .collect(),
    }))
}