     1→//! Metrics database operations
     2→
     3→use chrono::{DateTime, Utc};
     4→use serde::{Deserialize, Serialize};
     5→use sqlx::PgPool;
     6→use uuid::Uuid;
     7→
     8→/// Metrics record from database
     9→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
    10→pub struct Metrics {
    11→    pub id: i64,
    12→    pub agent_id: Uuid,
    13→    pub timestamp: DateTime<Utc>,
    14→    pub cpu_percent: Option<f32>,
    15→    pub memory_percent: Option<f32>,
    16→    pub memory_used_bytes: Option<i64>,
    17→    pub disk_percent: Option<f32>,
    18→    pub disk_used_bytes: Option<i64>,
    19→    pub network_rx_bytes: Option<i64>,
    20→    pub network_tx_bytes: Option<i64>,
    21→    // Extended metrics
    22→    pub uptime_seconds: Option<i64>,
    23→    pub boot_time: Option<i64>,
    24→    pub logged_in_user: Option<String>,
    25→    pub user_idle_seconds: Option<i64>,
    26→    pub public_ip: Option<String>,
    27→    pub memory_total_bytes: Option<i64>,
    28→    pub disk_total_bytes: Option<i64>,
    29→    // Temperature metrics
    30→    pub cpu_temp_celsius: Option<f32>,
    31→    pub gpu_temp_celsius: Option<f32>,
    32→    pub temperatures: Option<serde_json::Value>,
    33→}
    34→
    35→/// Create metrics data from agent
    36→#[derive(Debug, Clone, Deserialize)]
    37→pub struct CreateMetrics {
    38→    pub agent_id: Uuid,
    39→    pub cpu_percent: Option<f32>,
    40→    pub memory_percent: Option<f32>,
    41→    pub memory_used_bytes: Option<i64>,
    42→    pub disk_percent: Option<f32>,
    43→    pub disk_used_bytes: Option<i64>,
    44→    pub network_rx_bytes: Option<i64>,
    45→    pub network_tx_bytes: Option<i64>,
    46→    // Extended metrics
    47→    pub uptime_seconds: Option<i64>,
    48→    pub boot_time: Option<i64>,
    49→    pub logged_in_user: Option<String>,
    50→    pub user_idle_seconds: Option<i64>,
    51→    pub public_ip: Option<String>,
    52→    pub memory_total_bytes: Option<i64>,
    53→    pub disk_total_bytes: Option<i64>,
    54→    // Temperature metrics
    55→    pub cpu_temp_celsius: Option<f32>,
    56→    pub gpu_temp_celsius: Option<f32>,
    57→    pub temperatures: Option<serde_json::Value>,
    58→}
    59→
    60→/// Insert metrics into the database
    61→pub async fn insert_metrics(pool: &PgPool, metrics: CreateMetrics) -> Result<i64, sqlx::Error> {
    62→    let result: (i64,) = sqlx::query_as(
    63→        r#"
    64→        INSERT INTO metrics (
    65→            agent_id, cpu_percent, memory_percent, memory_used_bytes,
    66→            disk_percent, disk_used_bytes, network_rx_bytes, network_tx_bytes,
    67→            uptime_seconds, boot_time, logged_in_user, user_idle_seconds,
    68→            public_ip, memory_total_bytes, disk_total_bytes,
    69→            cpu_temp_celsius, gpu_temp_celsius, temperatures
    70→        )
    71→        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
    72→        RETURNING id
    73→        "#,
    74→    )
    75→    .bind(metrics.agent_id)
    76→    .bind(metrics.cpu_percent)
    77→    .bind(metrics.memory_percent)
    78→    .bind(metrics.memory_used_bytes)
    79→    .bind(metrics.disk_percent)
    80→    .bind(metrics.disk_used_bytes)
    81→    .bind(metrics.network_rx_bytes)
    82→    .bind(metrics.network_tx_bytes)
    83→    .bind(metrics.uptime_seconds)
    84→    .bind(metrics.boot_time)
    85→    .bind(&metrics.logged_in_user)
    86→    .bind(metrics.user_idle_seconds)
    87→    .bind(&metrics.public_ip)
    88→    .bind(metrics.memory_total_bytes)
    89→    .bind(metrics.disk_total_bytes)
    90→    .bind(metrics.cpu_temp_celsius)
    91→    .bind(metrics.gpu_temp_celsius)
    92→    .bind(&metrics.temperatures)
    93→    .fetch_one(pool)
    94→    .await?;
    95→
    96→    Ok(result.0)
    97→}
    98→
    99→/// Get recent metrics for an agent
   100→pub async fn get_agent_metrics(
   101→    pool: &PgPool,
   102→    agent_id: Uuid,
   103→    limit: i64,
   104→) -> Result<Vec<Metrics>, sqlx::Error> {
   105→    sqlx::query_as::<_, Metrics>(
   106→        r#"
   107→        SELECT * FROM metrics
   108→        WHERE agent_id = $1
   109→        ORDER BY timestamp DESC
   110→        LIMIT $2
   111→        "#,
   112→    )
   113→    .bind(agent_id)
   114→    .bind(limit)
   115→    .fetch_all(pool)
   116→    .await
   117→}
   118→
   119→/// Get metrics for an agent within a time range
   120→pub async fn get_agent_metrics_range(
   121→    pool: &PgPool,
   122→    agent_id: Uuid,
   123→    start: DateTime<Utc>,
   124→    end: DateTime<Utc>,
   125→) -> Result<Vec<Metrics>, sqlx::Error> {
   126→    sqlx::query_as::<_, Metrics>(
   127→        r#"
   128→        SELECT * FROM metrics
   129→        WHERE agent_id = $1 AND timestamp >= $2 AND timestamp <= $3
   130→        ORDER BY timestamp ASC
   131→        "#,
   132→    )
   133→    .bind(agent_id)
   134→    .bind(start)
   135→    .bind(end)
   136→    .fetch_all(pool)
   137→    .await
   138→}
   139→
   140→/// Get latest metrics for an agent
   141→pub async fn get_latest_metrics(
   142→    pool: &PgPool,
   143→    agent_id: Uuid,
   144→) -> Result<Option<Metrics>, sqlx::Error> {
   145→    sqlx::query_as::<_, Metrics>(
   146→        r#"
   147→        SELECT * FROM metrics
   148→        WHERE agent_id = $1
   149→        ORDER BY timestamp DESC
   150→        LIMIT 1
   151→        "#,
   152→    )
   153→    .bind(agent_id)
   154→    .fetch_optional(pool)
   155→    .await
   156→}
   157→
   158→/// Delete old metrics (for cleanup jobs)
   159→pub async fn delete_old_metrics(
   160→    pool: &PgPool,
   161→    older_than: DateTime<Utc>,
   162→) -> Result<u64, sqlx::Error> {
   163→    let result = sqlx::query("DELETE FROM metrics WHERE timestamp < $1")
   164→        .bind(older_than)
   165→        .execute(pool)
   166→        .await?;
   167→
   168→    Ok(result.rows_affected())
   169→}
   170→
   171→/// Summary statistics for dashboard
   172→#[derive(Debug, Clone, Serialize, Deserialize)]
   173→pub struct MetricsSummary {
   174→    pub avg_cpu: f32,
   175→    pub avg_memory: f32,
   176→    pub avg_disk: f32,
   177→    pub total_network_rx: i64,
   178→    pub total_network_tx: i64,
   179→}
   180→
   181→/// Get summary metrics across all agents (last hour)
   182→pub async fn get_metrics_summary(pool: &PgPool) -> Result<MetricsSummary, sqlx::Error> {
   183→    let result: (Option<f64>, Option<f64>, Option<f64>, Option<i64>, Option<i64>) = sqlx::query_as(
   184→        r#"
   185→        SELECT
   186→            AVG(cpu_percent)::float8,
   187→            AVG(memory_percent)::float8,
   188→            AVG(disk_percent)::float8,
   189→            SUM(network_rx_bytes),
   190→            SUM(network_tx_bytes)
   191→        FROM metrics
   192→        WHERE timestamp > NOW() - INTERVAL '1 hour'
   193→        "#,
   194→    )
   195→    .fetch_one(pool)
   196→    .await?;
   197→
   198→    Ok(MetricsSummary {
   199→        avg_cpu: result.0.unwrap_or(0.0) as f32,
   200→        avg_memory: result.1.unwrap_or(0.0) as f32,
   201→        avg_disk: result.2.unwrap_or(0.0) as f32,
   202→        total_network_rx: result.3.unwrap_or(0),
   203→        total_network_tx: result.4.unwrap_or(0),
   204→    })
   205→}
   206→
   207→// ============================================================================
   208→// Agent State (network interfaces, extended info snapshot)
   209→// ============================================================================
   210→
   211→/// Agent state record from database
   212→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
   213→pub struct AgentState {
   214→    pub agent_id: Uuid,
   215→    pub network_interfaces: Option<serde_json::Value>,
   216→    pub network_state_hash: Option<String>,
   217→    pub uptime_seconds: Option<i64>,
   218→    pub boot_time: Option<i64>,
   219→    pub logged_in_user: Option<String>,
   220→    pub user_idle_seconds: Option<i64>,
   221→    pub public_ip: Option<String>,
   222→    pub network_updated_at: Option<DateTime<Utc>>,
   223→    pub metrics_updated_at: Option<DateTime<Utc>>,
   224→}
   225→
   226→/// Update or insert agent state (upsert)
   227→pub async fn upsert_agent_state(
   228→    pool: &PgPool,
   229→    agent_id: Uuid,
   230→    uptime_seconds: Option<i64>,
   231→    boot_time: Option<i64>,
   232→    logged_in_user: Option<&str>,
   233→    user_idle_seconds: Option<i64>,
   234→    public_ip: Option<&str>,
   235→) -> Result<(), sqlx::Error> {
   236→    sqlx::query(
   237→        r#"
   238→        INSERT INTO agent_state (
   239→            agent_id, uptime_seconds, boot_time, logged_in_user,
   240→            user_idle_seconds, public_ip, metrics_updated_at
   241→        )
   242→        VALUES ($1, $2, $3, $4, $5, $6, NOW())
   243→        ON CONFLICT (agent_id) DO UPDATE SET
   244→            uptime_seconds = EXCLUDED.uptime_seconds,
   245→            boot_time = EXCLUDED.boot_time,
   246→            logged_in_user = EXCLUDED.logged_in_user,
   247→            user_idle_seconds = EXCLUDED.user_idle_seconds,
   248→            public_ip = EXCLUDED.public_ip,
   249→            metrics_updated_at = NOW()
   250→        "#,
   251→    )
   252→    .bind(agent_id)
   253→    .bind(uptime_seconds)
   254→    .bind(boot_time)
   255→    .bind(logged_in_user)
   256→    .bind(user_idle_seconds)
   257→    .bind(public_ip)
   258→    .execute(pool)
   259→    .await?;
   260→
   261→    Ok(())
   262→}
   263→
   264→/// Update network state for an agent
   265→pub async fn update_agent_network_state(
   266→    pool: &PgPool,
   267→    agent_id: Uuid,
   268→    interfaces: &serde_json::Value,
   269→    state_hash: &str,
   270→) -> Result<(), sqlx::Error> {
   271→    sqlx::query(
   272→        r#"
   273→        INSERT INTO agent_state (agent_id, network_interfaces, network_state_hash, network_updated_at)
   274→        VALUES ($1, $2, $3, NOW())
   275→        ON CONFLICT (agent_id) DO UPDATE SET
   276→            network_interfaces = EXCLUDED.network_interfaces,
   277→            network_state_hash = EXCLUDED.network_state_hash,
   278→            network_updated_at = NOW()
   279→        "#,
   280→    )
   281→    .bind(agent_id)
   282→    .bind(interfaces)
   283→    .bind(state_hash)
   284→    .execute(pool)
   285→    .await?;
   286→
   287→    Ok(())
   288→}
   289→
   290→/// Get agent state by agent ID
   291→pub async fn get_agent_state(pool: &PgPool, agent_id: Uuid) -> Result<Option<AgentState>, sqlx::Error> {
   292→    sqlx::query_as::<_, AgentState>("SELECT * FROM agent_state WHERE agent_id = $1")
   293→        .bind(agent_id)
   294→        .fetch_optional(pool)
   295→        .await
   296→}
   297→

<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>

//! Metrics database operations

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

/// Metrics record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Metrics {
    pub id: i64,
    pub agent_id: Uuid,
    pub timestamp: DateTime<Utc>,
    pub cpu_percent: Option<f32>,
    pub memory_percent: Option<f32>,
    pub memory_used_bytes: Option<i64>,
    pub disk_percent: Option<f32>,
    pub disk_used_bytes: Option<i64>,
    pub network_rx_bytes: Option<i64>,
    pub network_tx_bytes: Option<i64>,
    // Extended metrics
    pub uptime_seconds: Option<i64>,
    pub boot_time: Option<i64>,
    pub logged_in_user: Option<String>,
    pub user_idle_seconds: Option<i64>,
    pub public_ip: Option<String>,
    pub memory_total_bytes: Option<i64>,
    pub disk_total_bytes: Option<i64>,
    // Temperature metrics
    pub cpu_temp_celsius: Option<f32>,
    pub gpu_temp_celsius: Option<f32>,
    pub temperatures: Option<serde_json::Value>,
}

/// Create metrics data from agent
#[derive(Debug, Clone, Deserialize)]
pub struct CreateMetrics {
    pub agent_id: Uuid,
    pub cpu_percent: Option<f32>,
    pub memory_percent: Option<f32>,
    pub memory_used_bytes: Option<i64>,
    pub disk_percent: Option<f32>,
    pub disk_used_bytes: Option<i64>,
    pub network_rx_bytes: Option<i64>,
    pub network_tx_bytes: Option<i64>,
    // Extended metrics
    pub uptime_seconds: Option<i64>,
    pub boot_time: Option<i64>,
    pub logged_in_user: Option<String>,
    pub user_idle_seconds: Option<i64>,
    pub public_ip: Option<String>,
    pub memory_total_bytes: Option<i64>,
    pub disk_total_bytes: Option<i64>,
    // Temperature metrics
    pub cpu_temp_celsius: Option<f32>,
    pub gpu_temp_celsius: Option<f32>,
    pub temperatures: Option<serde_json::Value>,
}

/// Insert metrics into the database
pub async fn insert_metrics(pool: &PgPool, metrics: CreateMetrics) -> Result<i64, sqlx::Error> {
    let result: (i64,) = sqlx::query_as(
        r#"
        INSERT INTO metrics (
            agent_id, cpu_percent, memory_percent, memory_used_bytes,
            disk_percent, disk_used_bytes, network_rx_bytes, network_tx_bytes,
            uptime_seconds, boot_time, logged_in_user, user_idle_seconds,
            public_ip, memory_total_bytes, disk_total_bytes,
            cpu_temp_celsius, gpu_temp_celsius, temperatures
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
        RETURNING id
        "#,
    )
    .bind(metrics.agent_id)
    .bind(metrics.cpu_percent)
    .bind(metrics.memory_percent)
    .bind(metrics.memory_used_bytes)
    .bind(metrics.disk_percent)
    .bind(metrics.disk_used_bytes)
    .bind(metrics.network_rx_bytes)
    .bind(metrics.network_tx_bytes)
    .bind(metrics.uptime_seconds)
    .bind(metrics.boot_time)
    .bind(&metrics.logged_in_user)
    .bind(metrics.user_idle_seconds)
    .bind(&metrics.public_ip)
    .bind(metrics.memory_total_bytes)
    .bind(metrics.disk_total_bytes)
    .bind(metrics.cpu_temp_celsius)
    .bind(metrics.gpu_temp_celsius)
    .bind(&metrics.temperatures)
    .fetch_one(pool)
    .await?;

    Ok(result.0)
}

/// Get recent metrics for an agent
pub async fn get_agent_metrics(
    pool: &PgPool,
    agent_id: Uuid,
    limit: i64,
) -> Result<Vec<Metrics>, sqlx::Error> {
    sqlx::query_as::<_, Metrics>(
        r#"
        SELECT * FROM metrics
        WHERE agent_id = $1
        ORDER BY timestamp DESC
        LIMIT $2
        "#,
    )
    .bind(agent_id)
    .bind(limit)
    .fetch_all(pool)
    .await
}

/// Get metrics for an agent within a time range
pub async fn get_agent_metrics_range(
    pool: &PgPool,
    agent_id: Uuid,
    start: DateTime<Utc>,
    end: DateTime<Utc>,
) -> Result<Vec<Metrics>, sqlx::Error> {
    sqlx::query_as::<_, Metrics>(
        r#"
        SELECT * FROM metrics
        WHERE agent_id = $1 AND timestamp >= $2 AND timestamp <= $3
        ORDER BY timestamp ASC
        "#,
    )
    .bind(agent_id)
    .bind(start)
    .bind(end)
    .fetch_all(pool)
    .await
}

/// Get latest metrics for an agent
pub async fn get_latest_metrics(
    pool: &PgPool,
    agent_id: Uuid,
) -> Result<Option<Metrics>, sqlx::Error> {
    sqlx::query_as::<_, Metrics>(
        r#"
        SELECT * FROM metrics
        WHERE agent_id = $1
        ORDER BY timestamp DESC
        LIMIT 1
        "#,
    )
    .bind(agent_id)
    .fetch_optional(pool)
    .await
}

/// Delete old metrics (for cleanup jobs)
pub async fn delete_old_metrics(
    pool: &PgPool,
    older_than: DateTime<Utc>,
) -> Result<u64, sqlx::Error> {
    let result = sqlx::query("DELETE FROM metrics WHERE timestamp < $1")
        .bind(older_than)
        .execute(pool)
        .await?;

    Ok(result.rows_affected())
}

/// Summary statistics for dashboard
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsSummary {
    pub avg_cpu: f32,
    pub avg_memory: f32,
    pub avg_disk: f32,
    pub total_network_rx: i64,
    pub total_network_tx: i64,
}

/// Get summary metrics across all agents (last hour)
pub async fn get_metrics_summary(pool: &PgPool) -> Result<MetricsSummary, sqlx::Error> {
    let result: (Option<f64>, Option<f64>, Option<f64>, Option<i64>, Option<i64>) = sqlx::query_as(
        r#"
        SELECT
            AVG(cpu_percent)::float8,
            AVG(memory_percent)::float8,
            AVG(disk_percent)::float8,
            SUM(network_rx_bytes),
            SUM(network_tx_bytes)
        FROM metrics
        WHERE timestamp > NOW() - INTERVAL '1 hour'
        "#,
    )
    .fetch_one(pool)
    .await?;

    Ok(MetricsSummary {
        avg_cpu: result.0.unwrap_or(0.0) as f32,
        avg_memory: result.1.unwrap_or(0.0) as f32,
        avg_disk: result.2.unwrap_or(0.0) as f32,
        total_network_rx: result.3.unwrap_or(0),
        total_network_tx: result.4.unwrap_or(0),
    })
}

// ============================================================================
// Agent State (network interfaces, extended info snapshot)
// ============================================================================

/// Agent state record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AgentState {
    pub agent_id: Uuid,
    pub network_interfaces: Option<serde_json::Value>,
    pub network_state_hash: Option<String>,
    pub uptime_seconds: Option<i64>,
    pub boot_time: Option<i64>,
    pub logged_in_user: Option<String>,
    pub user_idle_seconds: Option<i64>,
    pub public_ip: Option<String>,
    pub network_updated_at: Option<DateTime<Utc>>,
    pub metrics_updated_at: Option<DateTime<Utc>>,
}

/// Update or insert agent state (upsert)
pub async fn upsert_agent_state(
    pool: &PgPool,
    agent_id: Uuid,
    uptime_seconds: Option<i64>,
    boot_time: Option<i64>,
    logged_in_user: Option<&str>,
    user_idle_seconds: Option<i64>,
    public_ip: Option<&str>,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        INSERT INTO agent_state (
            agent_id, uptime_seconds, boot_time, logged_in_user,
            user_idle_seconds, public_ip, metrics_updated_at
        )
        VALUES ($1, $2, $3, $4, $5, $6, NOW())
        ON CONFLICT (agent_id) DO UPDATE SET
            uptime_seconds = EXCLUDED.uptime_seconds,
            boot_time = EXCLUDED.boot_time,
            logged_in_user = EXCLUDED.logged_in_user,
            user_idle_seconds = EXCLUDED.user_idle_seconds,
            public_ip = EXCLUDED.public_ip,
            metrics_updated_at = NOW()
        "#,
    )
    .bind(agent_id)
    .bind(uptime_seconds)
    .bind(boot_time)
    .bind(logged_in_user)
    .bind(user_idle_seconds)
    .bind(public_ip)
    .execute(pool)
    .await?;

    Ok(())
}

/// Update network state for an agent
pub async fn update_agent_network_state(
    pool: &PgPool,
    agent_id: Uuid,
    interfaces: &serde_json::Value,
    state_hash: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        INSERT INTO agent_state (agent_id, network_interfaces, network_state_hash, network_updated_at)
        VALUES ($1, $2, $3, NOW())
        ON CONFLICT (agent_id) DO UPDATE SET
            network_interfaces = EXCLUDED.network_interfaces,
            network_state_hash = EXCLUDED.network_state_hash,
            network_updated_at = NOW()
        "#,
    )
    .bind(agent_id)
    .bind(interfaces)
    .bind(state_hash)
    .execute(pool)
    .await?;

    Ok(())
}

/// Get agent state by agent ID
pub async fn get_agent_state(pool: &PgPool, agent_id: Uuid) -> Result<Option<AgentState>, sqlx::Error> {
    sqlx::query_as::<_, AgentState>("SELECT * FROM agent_state WHERE agent_id = $1")
        .bind(agent_id)
        .fetch_optional(pool)
        .await
}


Now let me check the database schema and look for any existing alert/threshold patterns:


Bash

find /c/Users/MikeSwanson/claude-projects/gururmm -name "*.sql" -o -name "migrations" -type d

Find SQL migrations directory


     1→//! Database models and queries
     2→//!
     3→//! Provides database access for clients, sites, agents, metrics, commands, users, and updates.
     4→
     5→pub mod agents;
     6→pub mod clients;
     7→pub mod commands;
     8→pub mod metrics;
     9→pub mod sites;
    10→pub mod updates;
    11→pub mod users;
    12→
    13→pub use agents::*;
    14→pub use clients::*;
    15→pub use commands::*;
    16→pub use metrics::*;
    17→pub use sites::*;
    18→pub use updates::*;
    19→pub use users::*;
    20→

<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>

//! Database models and queries
//!
//! Provides database access for clients, sites, agents, metrics, commands, users, and updates.

pub mod agents;
pub mod clients;
pub mod commands;
pub mod metrics;
pub mod sites;
pub mod updates;
pub mod users;

pub use agents::*;
pub use clients::*;
pub use commands::*;
pub use metrics::*;
pub use sites::*;
pub use updates::*;
pub use users::*;


Let me check the migrations to understand the database schema:


     1→-- GuruRMM Initial Schema
     2→-- Creates tables for agents, metrics, commands, watchdog events, and users
     3→
     4→-- Enable UUID extension
     5→CREATE EXTENSION IF NOT EXISTS "pgcrypto";
     6→
     7→-- Agents table
     8→-- Stores registered agents and their current status
     9→CREATE TABLE agents (
    10→    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    11→    hostname VARCHAR(255) NOT NULL,
    12→    api_key_hash VARCHAR(255) NOT NULL,
    13→    os_type VARCHAR(50) NOT NULL,
    14→    os_version VARCHAR(100),
    15→    agent_version VARCHAR(50),
    16→    last_seen TIMESTAMPTZ,
    17→    status VARCHAR(20) DEFAULT 'offline',
    18→    created_at TIMESTAMPTZ DEFAULT NOW(),
    19→    updated_at TIMESTAMPTZ DEFAULT NOW()
    20→);
    21→
    22→-- Index for looking up agents by hostname
    23→CREATE INDEX idx_agents_hostname ON agents(hostname);
    24→
    25→-- Index for finding online agents
    26→CREATE INDEX idx_agents_status ON agents(status);
    27→
    28→-- Metrics table
    29→-- Time-series data for system metrics from agents
    30→CREATE TABLE metrics (
    31→    id BIGSERIAL PRIMARY KEY,
    32→    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    33→    timestamp TIMESTAMPTZ DEFAULT NOW(),
    34→    cpu_percent REAL,
    35→    memory_percent REAL,
    36→    memory_used_bytes BIGINT,
    37→    disk_percent REAL,
    38→    disk_used_bytes BIGINT,
    39→    network_rx_bytes BIGINT,
    40→    network_tx_bytes BIGINT
    41→);
    42→
    43→-- Index for querying metrics by agent and time
    44→CREATE INDEX idx_metrics_agent_time ON metrics(agent_id, timestamp DESC);
    45→
    46→-- Index for finding recent metrics
    47→CREATE INDEX idx_metrics_timestamp ON metrics(timestamp DESC);
    48→
    49→-- Users table
    50→-- Dashboard users for authentication
    51→CREATE TABLE users (
    52→    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    53→    email VARCHAR(255) UNIQUE NOT NULL,
    54→    password_hash VARCHAR(255),
    55→    name VARCHAR(255),
    56→    role VARCHAR(50) DEFAULT 'user',
    57→    sso_provider VARCHAR(50),
    58→    sso_id VARCHAR(255),
    59→    created_at TIMESTAMPTZ DEFAULT NOW(),
    60→    last_login TIMESTAMPTZ
    61→);
    62→
    63→-- Index for email lookups during login
    64→CREATE INDEX idx_users_email ON users(email);
    65→
    66→-- Index for SSO lookups
    67→CREATE INDEX idx_users_sso ON users(sso_provider, sso_id);
    68→
    69→-- Commands table
    70→-- Commands sent to agents and their results
    71→CREATE TABLE commands (
    72→    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    73→    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    74→    command_type VARCHAR(50) NOT NULL,
    75→    command_text TEXT NOT NULL,
    76→    status VARCHAR(20) DEFAULT 'pending',
    77→    exit_code INTEGER,
    78→    stdout TEXT,
    79→    stderr TEXT,
    80→    created_at TIMESTAMPTZ DEFAULT NOW(),
    81→    started_at TIMESTAMPTZ,
    82→    completed_at TIMESTAMPTZ,
    83→    created_by UUID REFERENCES users(id) ON DELETE SET NULL
    84→);
    85→
    86→-- Index for finding pending commands for an agent
    87→CREATE INDEX idx_commands_agent_status ON commands(agent_id, status);
    88→
    89→-- Index for command history queries
    90→CREATE INDEX idx_commands_created ON commands(created_at DESC);
    91→
    92→-- Watchdog events table
    93→-- Events from agent watchdog monitoring
    94→CREATE TABLE watchdog_events (
    95→    id BIGSERIAL PRIMARY KEY,
    96→    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    97→    timestamp TIMESTAMPTZ DEFAULT NOW(),
    98→    service_name VARCHAR(255) NOT NULL,
    99→    event_type VARCHAR(50) NOT NULL,
   100→    details TEXT
   101→);
   102→
   103→-- Index for querying events by agent and time
   104→CREATE INDEX idx_watchdog_agent_time ON watchdog_events(agent_id, timestamp DESC);
   105→
   106→-- Index for finding recent events
   107→CREATE INDEX idx_watchdog_timestamp ON watchdog_events(timestamp DESC);
   108→
   109→-- Function to update updated_at timestamp
   110→CREATE OR REPLACE FUNCTION update_updated_at_column()
   111→RETURNS TRIGGER AS $$
   112→BEGIN
   113→    NEW.updated_at = NOW();
   114→    RETURN NEW;
   115→END;
   116→$$ LANGUAGE plpgsql;
   117→
   118→-- Trigger for agents table
   119→CREATE TRIGGER update_agents_updated_at
   120→    BEFORE UPDATE ON agents
   121→    FOR EACH ROW
   122→    EXECUTE FUNCTION update_updated_at_column();
   123→

<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 Initial Schema
-- Creates tables for agents, metrics, commands, watchdog events, and users

-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Agents table
-- Stores registered agents and their current status
CREATE TABLE agents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    hostname VARCHAR(255) NOT NULL,
    api_key_hash VARCHAR(255) NOT NULL,
    os_type VARCHAR(50) NOT NULL,
    os_version VARCHAR(100),
    agent_version VARCHAR(50),
    last_seen TIMESTAMPTZ,
    status VARCHAR(20) DEFAULT 'offline',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for looking up agents by hostname
CREATE INDEX idx_agents_hostname ON agents(hostname);

-- Index for finding online agents
CREATE INDEX idx_agents_status ON agents(status);

-- Metrics table
-- Time-series data for system metrics from agents
CREATE TABLE metrics (
    id BIGSERIAL PRIMARY KEY,
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    timestamp TIMESTAMPTZ DEFAULT NOW(),
    cpu_percent REAL,
    memory_percent REAL,
    memory_used_bytes BIGINT,
    disk_percent REAL,
    disk_used_bytes BIGINT,
    network_rx_bytes BIGINT,
    network_tx_bytes BIGINT
);

-- Index for querying metrics by agent and time
CREATE INDEX idx_metrics_agent_time ON metrics(agent_id, timestamp DESC);

-- Index for finding recent metrics
CREATE INDEX idx_metrics_timestamp ON metrics(timestamp DESC);

-- Users table
-- Dashboard users for authentication
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255),
    name VARCHAR(255),
    role VARCHAR(50) DEFAULT 'user',
    sso_provider VARCHAR(50),
    sso_id VARCHAR(255),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_login TIMESTAMPTZ
);

-- Index for email lookups during login
CREATE INDEX idx_users_email ON users(email);

-- Index for SSO lookups
CREATE INDEX idx_users_sso ON users(sso_provider, sso_id);

-- Commands table
-- Commands sent to agents and their results
CREATE TABLE commands (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    command_type VARCHAR(50) NOT NULL,
    command_text TEXT NOT NULL,
    status VARCHAR(20) DEFAULT 'pending',
    exit_code INTEGER,
    stdout TEXT,
    stderr TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    created_by UUID REFERENCES users(id) ON DELETE SET NULL
);

-- Index for finding pending commands for an agent
CREATE INDEX idx_commands_agent_status ON commands(agent_id, status);

-- Index for command history queries
CREATE INDEX idx_commands_created ON commands(created_at DESC);

-- Watchdog events table
-- Events from agent watchdog monitoring
CREATE TABLE watchdog_events (
    id BIGSERIAL PRIMARY KEY,
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    timestamp TIMESTAMPTZ DEFAULT NOW(),
    service_name VARCHAR(255) NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    details TEXT
);

-- Index for querying events by agent and time
CREATE INDEX idx_watchdog_agent_time ON watchdog_events(agent_id, timestamp DESC);

-- Index for finding recent events
CREATE INDEX idx_watchdog_timestamp ON watchdog_events(timestamp DESC);

-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Trigger for agents table
CREATE TRIGGER update_agents_updated_at
    BEFORE UPDATE ON agents
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();


     1→-- Extended metrics and agent state
     2→-- Adds columns for uptime, user info, IPs, and network state storage
     3→
     4→-- Add extended columns to metrics table
     5→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS uptime_seconds BIGINT;
     6→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS boot_time BIGINT;
     7→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS logged_in_user VARCHAR(255);
     8→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS user_idle_seconds BIGINT;
     9→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS public_ip VARCHAR(45);  -- Supports IPv6
    10→
    11→-- Agent state table for current/latest agent information
    12→-- This stores the latest snapshot of extended agent info (not time-series)
    13→CREATE TABLE IF NOT EXISTS agent_state (
    14→    agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
    15→    -- Network state
    16→    network_interfaces JSONB,
    17→    network_state_hash VARCHAR(32),
    18→    -- Latest extended metrics (cached for quick access)
    19→    uptime_seconds BIGINT,
    20→    boot_time BIGINT,
    21→    logged_in_user VARCHAR(255),
    22→    user_idle_seconds BIGINT,
    23→    public_ip VARCHAR(45),
    24→    -- Timestamps
    25→    network_updated_at TIMESTAMPTZ,
    26→    metrics_updated_at TIMESTAMPTZ DEFAULT NOW()
    27→);
    28→
    29→-- Index for finding agents by public IP (useful for diagnostics)
    30→CREATE INDEX IF NOT EXISTS idx_agent_state_public_ip ON agent_state(public_ip);
    31→
    32→-- Add memory_total_bytes and disk_total_bytes to metrics for completeness
    33→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS memory_total_bytes BIGINT;
    34→ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disk_total_bytes BIGINT;
    35→

<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>

-- Extended metrics and agent state
-- Adds columns for uptime, user info, IPs, and network state storage

-- Add extended columns to metrics table
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS uptime_seconds BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS boot_time BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS logged_in_user VARCHAR(255);
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS user_idle_seconds BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS public_ip VARCHAR(45);  -- Supports IPv6

-- Agent state table for current/latest agent information
-- This stores the latest snapshot of extended agent info (not time-series)
CREATE TABLE IF NOT EXISTS agent_state (
    agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
    -- Network state
    network_interfaces JSONB,
    network_state_hash VARCHAR(32),
    -- Latest extended metrics (cached for quick access)
    uptime_seconds BIGINT,
    boot_time BIGINT,
    logged_in_user VARCHAR(255),
    user_idle_seconds BIGINT,
    public_ip VARCHAR(45),
    -- Timestamps
    network_updated_at TIMESTAMPTZ,
    metrics_updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for finding agents by public IP (useful for diagnostics)
CREATE INDEX IF NOT EXISTS idx_agent_state_public_ip ON agent_state(public_ip);

-- Add memory_total_bytes and disk_total_bytes to metrics for completeness
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS memory_total_bytes BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disk_total_bytes BIGINT;


Now let me check the agents API to understand how configuration is currently delivered:


grep -r "ConfigUpdate" /c/Users/MikeSwanson/claude-projects/gururmm/server/src --include="*.rs" -n

Search for ConfigUpdate in server code


     1→//! Agent 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 tracing::info;
    12→
    13→use crate::auth::AuthUser;
    14→use crate::db::{self, AgentResponse, AgentStats};
    15→use crate::ws::{generate_api_key, hash_api_key, ServerMessage, UpdatePayload};
    16→use crate::AppState;
    17→
    18→/// Response for agent registration
    19→#[derive(Debug, Serialize)]
    20→pub struct RegisterAgentResponse {
    21→    pub agent_id: Uuid,
    22→    pub api_key: String,
    23→    pub message: String,
    24→}
    25→
    26→/// Request to register a new agent
    27→#[derive(Debug, Deserialize)]
    28→pub struct RegisterAgentRequest {
    29→    pub hostname: String,
    30→    pub os_type: String,
    31→    pub os_version: Option<String>,
    32→}
    33→
    34→/// Register a new agent (generates API key)
    35→pub async fn register_agent(
    36→    State(state): State<AppState>,
    37→    Json(req): Json<RegisterAgentRequest>,
    38→) -> Result<Json<RegisterAgentResponse>, (StatusCode, String)> {
    39→    // Generate a new API key
    40→    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    41→    let api_key_hash = hash_api_key(&api_key);
    42→
    43→    // Create the agent
    44→    let create = db::CreateAgent {
    45→        hostname: req.hostname,
    46→        api_key_hash,
    47→        os_type: req.os_type,
    48→        os_version: req.os_version,
    49→        agent_version: None,
    50→    };
    51→
    52→    let agent = db::create_agent(&state.db, create)
    53→        .await
    54→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    55→
    56→    Ok(Json(RegisterAgentResponse {
    57→        agent_id: agent.id,
    58→        api_key, // Return the plain API key (only shown once!)
    59→        message: "Agent registered successfully. Save the API key - it will not be shown again."
    60→            .to_string(),
    61→    }))
    62→}
    63→
    64→/// List all agents
    65→pub async fn list_agents(
    66→    State(state): State<AppState>,
    67→) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
    68→    let agents = db::get_all_agents(&state.db)
    69→        .await
    70→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    71→
    72→    let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
    73→    Ok(Json(responses))
    74→}
    75→
    76→/// Get a specific agent
    77→pub async fn get_agent(
    78→    State(state): State<AppState>,
    79→    Path(id): Path<Uuid>,
    80→) -> Result<Json<AgentResponse>, (StatusCode, String)> {
    81→    let agent = db::get_agent_by_id(&state.db, id)
    82→        .await
    83→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
    84→        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
    85→
    86→    Ok(Json(agent.into()))
    87→}
    88→
    89→/// Delete an agent
    90→pub async fn delete_agent(
    91→    State(state): State<AppState>,
    92→    Path(id): Path<Uuid>,
    93→) -> Result<StatusCode, (StatusCode, String)> {
    94→    // Check if agent is connected and disconnect it
    95→    if state.agents.read().await.is_connected(&id) {
    96→        // In a real implementation, we'd send a disconnect message
    97→        state.agents.write().await.remove(&id);
    98→    }
    99→
   100→    let deleted = db::delete_agent(&state.db, id)
   101→        .await
   102→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   103→
   104→    if deleted {
   105→        Ok(StatusCode::NO_CONTENT)
   106→    } else {
   107→        Err((StatusCode::NOT_FOUND, "Agent not found".to_string()))
   108→    }
   109→}
   110→
   111→/// Get agent statistics
   112→pub async fn get_stats(
   113→    State(state): State<AppState>,
   114→) -> Result<Json<AgentStats>, (StatusCode, String)> {
   115→    let stats = db::get_agent_stats(&state.db)
   116→        .await
   117→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   118→
   119→    Ok(Json(stats))
   120→}
   121→
   122→/// Request to move an agent to a different site
   123→#[derive(Debug, Deserialize)]
   124→pub struct MoveAgentRequest {
   125→    pub site_id: Option<Uuid>, // None to unassign from site
   126→}
   127→
   128→/// Move an agent to a different site
   129→pub async fn move_agent(
   130→    State(state): State<AppState>,
   131→    Path(id): Path<Uuid>,
   132→    Json(req): Json<MoveAgentRequest>,
   133→) -> Result<Json<AgentResponse>, (StatusCode, String)> {
   134→    // Verify the site exists if provided
   135→    if let Some(site_id) = req.site_id {
   136→        let site = db::get_site_by_id(&state.db, site_id)
   137→            .await
   138→            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   139→
   140→        if site.is_none() {
   141→            return Err((StatusCode::NOT_FOUND, "Site not found".to_string()));
   142→        }
   143→    }
   144→
   145→    // Move the agent
   146→    let agent = db::move_agent_to_site(&state.db, id, req.site_id)
   147→        .await
   148→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   149→        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
   150→

<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>

//! Agent management API endpoints

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

use tracing::info;

use crate::auth::AuthUser;
use crate::db::{self, AgentResponse, AgentStats};
use crate::ws::{generate_api_key, hash_api_key, ServerMessage, UpdatePayload};
use crate::AppState;

/// Response for agent registration
#[derive(Debug, Serialize)]
pub struct RegisterAgentResponse {
    pub agent_id: Uuid,
    pub api_key: String,
    pub message: String,
}

/// Request to register a new agent
#[derive(Debug, Deserialize)]
pub struct RegisterAgentRequest {
    pub hostname: String,
    pub os_type: String,
    pub os_version: Option<String>,
}

/// Register a new agent (generates API key)
pub async fn register_agent(
    State(state): State<AppState>,
    Json(req): Json<RegisterAgentRequest>,
) -> Result<Json<RegisterAgentResponse>, (StatusCode, String)> {
    // Generate a new API key
    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    let api_key_hash = hash_api_key(&api_key);

    // Create the agent
    let create = db::CreateAgent {
        hostname: req.hostname,
        api_key_hash,
        os_type: req.os_type,
        os_version: req.os_version,
        agent_version: None,
    };

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

    Ok(Json(RegisterAgentResponse {
        agent_id: agent.id,
        api_key, // Return the plain API key (only shown once!)
        message: "Agent registered successfully. Save the API key - it will not be shown again."
            .to_string(),
    }))
}

/// List all agents
pub async fn list_agents(
    State(state): State<AppState>,
) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
    let agents = db::get_all_agents(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
    Ok(Json(responses))
}

/// Get a specific agent
pub async fn get_agent(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<AgentResponse>, (StatusCode, String)> {
    let agent = db::get_agent_by_id(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;

    Ok(Json(agent.into()))
}

/// Delete an agent
pub async fn delete_agent(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    // Check if agent is connected and disconnect it
    if state.agents.read().await.is_connected(&id) {
        // In a real implementation, we'd send a disconnect message
        state.agents.write().await.remove(&id);
    }

    let deleted = db::delete_agent(&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, "Agent not found".to_string()))
    }
}

/// Get agent statistics
pub async fn get_stats(
    State(state): State<AppState>,
) -> Result<Json<AgentStats>, (StatusCode, String)> {
    let stats = db::get_agent_stats(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(stats))
}

/// Request to move an agent to a different site
#[derive(Debug, Deserialize)]
pub struct MoveAgentRequest {
    pub site_id: Option<Uuid>, // None to unassign from site
}

/// Move an agent to a different site
pub async fn move_agent(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(req): Json<MoveAgentRequest>,
) -> Result<Json<AgentResponse>, (StatusCode, String)> {
    // Verify the site exists if provided
    if let Some(site_id) = req.site_id {
        let site = db::get_site_by_id(&state.db, site_id)
            .await
            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

        if site.is_none() {
            return Err((StatusCode::NOT_FOUND, "Site not found".to_string()));
        }
    }

    // Move the agent
    let agent = db::move_agent_to_site(&state.db, id, req.site_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;


/c/Users/MikeSwanson/claude-projects/gururmm/server/src/ws/mod.rs:98:    ConfigUpdate(serde_json::Value),


Let me check if there are any existing policy or threshold patterns in the codebase:


find /c/Users/MikeSwanson/claude-projects/gururmm/server/migrations -name "*.sql" -exec cat {} \;

Display all migration files


No matches found


-- GuruRMM Initial Schema
-- Creates tables for agents, metrics, commands, watchdog events, and users

-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Agents table
-- Stores registered agents and their current status
CREATE TABLE agents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    hostname VARCHAR(255) NOT NULL,
    api_key_hash VARCHAR(255) NOT NULL,
    os_type VARCHAR(50) NOT NULL,
    os_version VARCHAR(100),
    agent_version VARCHAR(50),
    last_seen TIMESTAMPTZ,
    status VARCHAR(20) DEFAULT 'offline',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for looking up agents by hostname
CREATE INDEX idx_agents_hostname ON agents(hostname);

-- Index for finding online agents
CREATE INDEX idx_agents_status ON agents(status);

-- Metrics table
-- Time-series data for system metrics from agents
CREATE TABLE metrics (
    id BIGSERIAL PRIMARY KEY,
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    timestamp TIMESTAMPTZ DEFAULT NOW(),
    cpu_percent REAL,
    memory_percent REAL,
    memory_used_bytes BIGINT,
    disk_percent REAL,
    disk_used_bytes BIGINT,
    network_rx_bytes BIGINT,
    network_tx_bytes BIGINT
);

-- Index for querying metrics by agent and time
CREATE INDEX idx_metrics_agent_time ON metrics(agent_id, timestamp DESC);

-- Index for finding recent metrics
CREATE INDEX idx_metrics_timestamp ON metrics(timestamp DESC);

-- Users table
-- Dashboard users for authentication
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255),
    name VARCHAR(255),
    role VARCHAR(50) DEFAULT 'user',
    sso_provider VARCHAR(50),
    sso_id VARCHAR(255),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_login TIMESTAMPTZ
);

-- Index for email lookups during login
CREATE INDEX idx_users_email ON users(email);

-- Index for SSO lookups
CREATE INDEX idx_users_sso ON users(sso_provider, sso_id);

-- Commands table
-- Commands sent to agents and their results
CREATE TABLE commands (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    command_type VARCHAR(50) NOT NULL,
    command_text TEXT NOT NULL,
    status VARCHAR(20) DEFAULT 'pending',
    exit_code INTEGER,
    stdout TEXT,
    stderr TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    created_by UUID REFERENCES users(id) ON DELETE SET NULL
);

-- Index for finding pending commands for an agent
CREATE INDEX idx_commands_agent_status ON commands(agent_id, status);

-- Index for command history queries
CREATE INDEX idx_commands_created ON commands(created_at DESC);

-- Watchdog events table
-- Events from agent watchdog monitoring
CREATE TABLE watchdog_events (
    id BIGSERIAL PRIMARY KEY,
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    timestamp TIMESTAMPTZ DEFAULT NOW(),
    service_name VARCHAR(255) NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    details TEXT
);

-- Index for querying events by agent and time
CREATE INDEX idx_watchdog_agent_time ON watchdog_events(agent_id, timestamp DESC);

-- Index for finding recent events
CREATE INDEX idx_watchdog_timestamp ON watchdog_events(timestamp DESC);

-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Trigger for agents table
CREATE TRIGGER update_agents_updated_at
    BEFORE UPDATE ON agents
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
-- GuruRMM Clients and Sites Schema
-- Adds multi-tenant support with clients, sites, and site-based agent registration

-- Clients table (organizations/companies)
CREATE TABLE clients (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    code VARCHAR(50) UNIQUE, -- Optional short code like "ACME"
    notes TEXT,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_clients_name ON clients(name);
CREATE INDEX idx_clients_code ON clients(code);

-- Trigger for clients updated_at
CREATE TRIGGER update_clients_updated_at
    BEFORE UPDATE ON clients
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- Sites table (locations under a client)
CREATE TABLE sites (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    -- Site code: human-friendly, used for agent registration (e.g., "BLUE-TIGER-4829")
    site_code VARCHAR(50) UNIQUE NOT NULL,
    -- API key hash for this site (all agents at site share this key)
    api_key_hash VARCHAR(255) NOT NULL,
    address TEXT,
    notes TEXT,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_sites_client ON sites(client_id);
CREATE INDEX idx_sites_code ON sites(site_code);
CREATE INDEX idx_sites_api_key ON sites(api_key_hash);

-- Trigger for sites updated_at
CREATE TRIGGER update_sites_updated_at
    BEFORE UPDATE ON sites
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- Add new columns to agents table
-- device_id: unique hardware-derived identifier for the machine
ALTER TABLE agents ADD COLUMN device_id VARCHAR(255);

-- site_id: which site this agent belongs to (nullable for legacy agents)
ALTER TABLE agents ADD COLUMN site_id UUID REFERENCES sites(id) ON DELETE SET NULL;

-- Make api_key_hash nullable (new agents will use site's api_key)
ALTER TABLE agents ALTER COLUMN api_key_hash DROP NOT NULL;

-- Index for looking up agents by device_id within a site
CREATE UNIQUE INDEX idx_agents_site_device ON agents(site_id, device_id) WHERE site_id IS NOT NULL AND device_id IS NOT NULL;

-- Index for site lookups
CREATE INDEX idx_agents_site ON agents(site_id);

-- Registration tokens table (optional: for secure site code distribution)
CREATE TABLE registration_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
    token_hash VARCHAR(255) NOT NULL,
    description VARCHAR(255),
    uses_remaining INTEGER, -- NULL = unlimited
    expires_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    created_by UUID REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX idx_reg_tokens_site ON registration_tokens(site_id);
CREATE INDEX idx_reg_tokens_hash ON registration_tokens(token_hash);

-- Function to generate a random site code (WORD-WORD-####)
-- This is just a helper; actual generation should be in application code
-- for better word lists
CREATE OR REPLACE FUNCTION generate_site_code() RETURNS VARCHAR(50) AS $$
DECLARE
    words TEXT[] := ARRAY['ALPHA', 'BETA', 'GAMMA', 'DELTA', 'ECHO', 'FOXTROT',
                          'BLUE', 'GREEN', 'RED', 'GOLD', 'SILVER', 'IRON',
                          'HAWK', 'EAGLE', 'TIGER', 'LION', 'WOLF', 'BEAR',
                          'NORTH', 'SOUTH', 'EAST', 'WEST', 'PEAK', 'VALLEY',
                          'RIVER', 'OCEAN', 'STORM', 'CLOUD', 'STAR', 'MOON'];
    word1 TEXT;
    word2 TEXT;
    num INTEGER;
BEGIN
    word1 := words[1 + floor(random() * array_length(words, 1))::int];
    word2 := words[1 + floor(random() * array_length(words, 1))::int];
    num := 1000 + floor(random() * 9000)::int;
    RETURN word1 || '-' || word2 || '-' || num::text;
END;
$$ LANGUAGE plpgsql;
-- Extended metrics and agent state
-- Adds columns for uptime, user info, IPs, and network state storage

-- Add extended columns to metrics table
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS uptime_seconds BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS boot_time BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS logged_in_user VARCHAR(255);
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS user_idle_seconds BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS public_ip VARCHAR(45);  -- Supports IPv6

-- Agent state table for current/latest agent information
-- This stores the latest snapshot of extended agent info (not time-series)
CREATE TABLE IF NOT EXISTS agent_state (
    agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
    -- Network state
    network_interfaces JSONB,
    network_state_hash VARCHAR(32),
    -- Latest extended metrics (cached for quick access)
    uptime_seconds BIGINT,
    boot_time BIGINT,
    logged_in_user VARCHAR(255),
    user_idle_seconds BIGINT,
    public_ip VARCHAR(45),
    -- Timestamps
    network_updated_at TIMESTAMPTZ,
    metrics_updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for finding agents by public IP (useful for diagnostics)
CREATE INDEX IF NOT EXISTS idx_agent_state_public_ip ON agent_state(public_ip);

-- Add memory_total_bytes and disk_total_bytes to metrics for completeness
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS memory_total_bytes BIGINT;
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disk_total_bytes BIGINT;
-- Agent update tracking
-- Tracks update commands sent to agents and their results

CREATE TABLE agent_updates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
    update_id UUID NOT NULL UNIQUE,
    old_version VARCHAR(50) NOT NULL,
    target_version VARCHAR(50) NOT NULL,
    status VARCHAR(20) DEFAULT 'pending',  -- pending, downloading, installing, completed, failed, rolled_back
    download_url TEXT,
    checksum_sha256 VARCHAR(64),
    started_at TIMESTAMPTZ DEFAULT NOW(),
    completed_at TIMESTAMPTZ,
    error_message TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for finding updates by agent
CREATE INDEX idx_agent_updates_agent ON agent_updates(agent_id);

-- Index for finding updates by status (for monitoring)
CREATE INDEX idx_agent_updates_status ON agent_updates(status);

-- Index for finding pending/in-progress updates (for timeout detection)
CREATE INDEX idx_agent_updates_pending ON agent_updates(agent_id, status)
    WHERE status IN ('pending', 'downloading', 'installing');

-- Add architecture column to agents table for proper binary matching
ALTER TABLE agents ADD COLUMN IF NOT EXISTS architecture VARCHAR(20) DEFAULT 'amd64';
-- Add temperature metrics columns to metrics table
-- CPU and GPU temperatures as primary metrics, plus JSON array for all sensors

ALTER TABLE metrics ADD COLUMN cpu_temp_celsius REAL;
ALTER TABLE metrics ADD COLUMN gpu_temp_celsius REAL;
ALTER TABLE metrics ADD COLUMN temperatures JSONB;

-- Add index for querying high temperature events
CREATE INDEX idx_metrics_cpu_temp ON metrics(cpu_temp_celsius) WHERE cpu_temp_celsius IS NOT NULL;

COMMENT ON COLUMN metrics.cpu_temp_celsius IS 'Average CPU temperature in Celsius';
COMMENT ON COLUMN metrics.gpu_temp_celsius IS 'Primary GPU temperature in Celsius';
COMMENT ON COLUMN metrics.temperatures IS 'All temperature sensor readings as JSON array';


Perfect! Now let me check the commands database module to understand how commands are structured:


     1→//! Commands database operations
     2→
     3→use chrono::{DateTime, Utc};
     4→use serde::{Deserialize, Serialize};
     5→use sqlx::PgPool;
     6→use uuid::Uuid;
     7→
     8→/// Command record from database
     9→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
    10→pub struct Command {
    11→    pub id: Uuid,
    12→    pub agent_id: Uuid,
    13→    pub command_type: String,
    14→    pub command_text: String,
    15→    pub status: String,
    16→    pub exit_code: Option<i32>,
    17→    pub stdout: Option<String>,
    18→    pub stderr: Option<String>,
    19→    pub created_at: DateTime<Utc>,
    20→    pub started_at: Option<DateTime<Utc>>,
    21→    pub completed_at: Option<DateTime<Utc>>,
    22→    pub created_by: Option<Uuid>,
    23→}
    24→
    25→/// Create a new command
    26→#[derive(Debug, Clone, Deserialize)]
    27→pub struct CreateCommand {
    28→    pub agent_id: Uuid,
    29→    pub command_type: String,
    30→    pub command_text: String,
    31→    pub created_by: Option<Uuid>,
    32→}
    33→
    34→/// Insert a new command
    35→pub async fn create_command(pool: &PgPool, cmd: CreateCommand) -> Result<Command, sqlx::Error> {
    36→    sqlx::query_as::<_, Command>(
    37→        r#"
    38→        INSERT INTO commands (agent_id, command_type, command_text, status, created_by)
    39→        VALUES ($1, $2, $3, 'pending', $4)
    40→        RETURNING *
    41→        "#,
    42→    )
    43→    .bind(cmd.agent_id)
    44→    .bind(&cmd.command_type)
    45→    .bind(&cmd.command_text)
    46→    .bind(cmd.created_by)
    47→    .fetch_one(pool)
    48→    .await
    49→}
    50→
    51→/// Get a command by ID
    52→pub async fn get_command_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Command>, sqlx::Error> {
    53→    sqlx::query_as::<_, Command>("SELECT * FROM commands WHERE id = $1")
    54→        .bind(id)
    55→        .fetch_optional(pool)
    56→        .await
    57→}
    58→
    59→/// Get pending commands for an agent
    60→pub async fn get_pending_commands(
    61→    pool: &PgPool,
    62→    agent_id: Uuid,
    63→) -> Result<Vec<Command>, sqlx::Error> {
    64→    sqlx::query_as::<_, Command>(
    65→        r#"
    66→        SELECT * FROM commands
    67→        WHERE agent_id = $1 AND status = 'pending'
    68→        ORDER BY created_at ASC
    69→        "#,
    70→    )
    71→    .bind(agent_id)
    72→    .fetch_all(pool)
    73→    .await
    74→}
    75→
    76→/// Get command history for an agent
    77→pub async fn get_agent_commands(
    78→    pool: &PgPool,
    79→    agent_id: Uuid,
    80→    limit: i64,
    81→) -> Result<Vec<Command>, sqlx::Error> {
    82→    sqlx::query_as::<_, Command>(
    83→        r#"
    84→        SELECT * FROM commands
    85→        WHERE agent_id = $1
    86→        ORDER BY created_at DESC
    87→        LIMIT $2
    88→        "#,
    89→    )
    90→    .bind(agent_id)
    91→    .bind(limit)
    92→    .fetch_all(pool)
    93→    .await
    94→}
    95→
    96→/// Get all recent commands
    97→pub async fn get_recent_commands(pool: &PgPool, limit: i64) -> Result<Vec<Command>, sqlx::Error> {
    98→    sqlx::query_as::<_, Command>(
    99→        r#"
   100→        SELECT * FROM commands
   101→        ORDER BY created_at DESC
   102→        LIMIT $1
   103→        "#,
   104→    )
   105→    .bind(limit)
   106→    .fetch_all(pool)
   107→    .await
   108→}
   109→
   110→/// Update command status to running
   111→pub async fn mark_command_running(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
   112→    sqlx::query("UPDATE commands SET status = 'running', started_at = NOW() WHERE id = $1")
   113→        .bind(id)
   114→        .execute(pool)
   115→        .await?;
   116→    Ok(())
   117→}
   118→
   119→/// Update command result
   120→#[derive(Debug, Clone, Deserialize)]
   121→pub struct CommandResult {
   122→    pub exit_code: i32,
   123→    pub stdout: String,
   124→    pub stderr: String,
   125→}
   126→
   127→pub async fn update_command_result(
   128→    pool: &PgPool,
   129→    id: Uuid,
   130→    result: CommandResult,
   131→) -> Result<(), sqlx::Error> {
   132→    let status = if result.exit_code == 0 {
   133→        "completed"
   134→    } else {
   135→        "failed"
   136→    };
   137→
   138→    sqlx::query(
   139→        r#"
   140→        UPDATE commands
   141→        SET status = $1, exit_code = $2, stdout = $3, stderr = $4, completed_at = NOW()
   142→        WHERE id = $5
   143→        "#,
   144→    )
   145→    .bind(status)
   146→    .bind(result.exit_code)
   147→    .bind(&result.stdout)
   148→    .bind(&result.stderr)
   149→    .bind(id)
   150→    .execute(pool)
   151→    .await?;
   152→
   153→    Ok(())
   154→}
   155→
   156→/// Delete a command
   157→pub async fn delete_command(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
   158→    let result = sqlx::query("DELETE FROM commands WHERE id = $1")
   159→        .bind(id)
   160→        .execute(pool)
   161→        .await?;
   162→    Ok(result.rows_affected() > 0)
   163→}
   164→

<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>

//! Commands database operations

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

/// Command record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Command {
    pub id: Uuid,
    pub agent_id: Uuid,
    pub command_type: String,
    pub command_text: String,
    pub status: String,
    pub exit_code: Option<i32>,
    pub stdout: Option<String>,
    pub stderr: Option<String>,
    pub created_at: DateTime<Utc>,
    pub started_at: Option<DateTime<Utc>>,
    pub completed_at: Option<DateTime<Utc>>,
    pub created_by: Option<Uuid>,
}

/// Create a new command
#[derive(Debug, Clone, Deserialize)]
pub struct CreateCommand {
    pub agent_id: Uuid,
    pub command_type: String,
    pub command_text: String,
    pub created_by: Option<Uuid>,
}

/// Insert a new command
pub async fn create_command(pool: &PgPool, cmd: CreateCommand) -> Result<Command, sqlx::Error> {
    sqlx::query_as::<_, Command>(
        r#"
        INSERT INTO commands (agent_id, command_type, command_text, status, created_by)
        VALUES ($1, $2, $3, 'pending', $4)
        RETURNING *
        "#,
    )
    .bind(cmd.agent_id)
    .bind(&cmd.command_type)
    .bind(&cmd.command_text)
    .bind(cmd.created_by)
    .fetch_one(pool)
    .await
}

/// Get a command by ID
pub async fn get_command_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Command>, sqlx::Error> {
    sqlx::query_as::<_, Command>("SELECT * FROM commands WHERE id = $1")
        .bind(id)
        .fetch_optional(pool)
        .await
}

/// Get pending commands for an agent
pub async fn get_pending_commands(
    pool: &PgPool,
    agent_id: Uuid,
) -> Result<Vec<Command>, sqlx::Error> {
    sqlx::query_as::<_, Command>(
        r#"
        SELECT * FROM commands
        WHERE agent_id = $1 AND status = 'pending'
        ORDER BY created_at ASC
        "#,
    )
    .bind(agent_id)
    .fetch_all(pool)
    .await
}

/// Get command history for an agent
pub async fn get_agent_commands(
    pool: &PgPool,
    agent_id: Uuid,
    limit: i64,
) -> Result<Vec<Command>, sqlx::Error> {
    sqlx::query_as::<_, Command>(
        r#"
        SELECT * FROM commands
        WHERE agent_id = $1
        ORDER BY created_at DESC
        LIMIT $2
        "#,
    )
    .bind(agent_id)
    .bind(limit)
    .fetch_all(pool)
    .await
}

/// Get all recent commands
pub async fn get_recent_commands(pool: &PgPool, limit: i64) -> Result<Vec<Command>, sqlx::Error> {
    sqlx::query_as::<_, Command>(
        r#"
        SELECT * FROM commands
        ORDER BY created_at DESC
        LIMIT $1
        "#,
    )
    .bind(limit)
    .fetch_all(pool)
    .await
}

/// Update command status to running
pub async fn mark_command_running(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
    sqlx::query("UPDATE commands SET status = 'running', started_at = NOW() WHERE id = $1")
        .bind(id)
        .execute(pool)
        .await?;
    Ok(())
}

/// Update command result
#[derive(Debug, Clone, Deserialize)]
pub struct CommandResult {
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
}

pub async fn update_command_result(
    pool: &PgPool,
    id: Uuid,
    result: CommandResult,
) -> Result<(), sqlx::Error> {
    let status = if result.exit_code == 0 {
        "completed"
    } else {
        "failed"
    };

    sqlx::query(
        r#"
        UPDATE commands
        SET status = $1, exit_code = $2, stdout = $3, stderr = $4, completed_at = NOW()
        WHERE id = $5
        "#,
    )
    .bind(status)
    .bind(result.exit_code)
    .bind(&result.stdout)
    .bind(&result.stderr)
    .bind(id)
    .execute(pool)
    .await?;

    Ok(())
}

/// Delete a command
pub async fn delete_command(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("DELETE FROM commands WHERE id = $1")
        .bind(id)
        .execute(pool)
        .await?;
    Ok(result.rows_affected() > 0)
}


     1→//! Agent database operations
     2→
     3→use chrono::{DateTime, Utc};
     4→use serde::{Deserialize, Serialize};
     5→use sqlx::PgPool;
     6→use uuid::Uuid;
     7→
     8→/// Agent record from database
     9→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
    10→pub struct Agent {
    11→    pub id: Uuid,
    12→    pub hostname: String,
    13→    #[serde(skip_serializing)]
    14→    pub api_key_hash: Option<String>,  // Nullable: new agents use site's api_key
    15→    pub os_type: String,
    16→    pub os_version: Option<String>,
    17→    pub agent_version: Option<String>,
    18→    pub last_seen: Option<DateTime<Utc>>,
    19→    pub status: String,
    20→    pub created_at: DateTime<Utc>,
    21→    pub updated_at: DateTime<Utc>,
    22→    // New fields for site-based registration
    23→    pub device_id: Option<String>,     // Hardware-derived unique ID
    24→    pub site_id: Option<Uuid>,         // Which site this agent belongs to
    25→}
    26→
    27→/// Agent without sensitive fields (for API responses)
    28→#[derive(Debug, Clone, Serialize, Deserialize)]
    29→pub struct AgentResponse {
    30→    pub id: Uuid,
    31→    pub hostname: String,
    32→    pub os_type: String,
    33→    pub os_version: Option<String>,
    34→    pub agent_version: Option<String>,
    35→    pub last_seen: Option<DateTime<Utc>>,
    36→    pub status: String,
    37→    pub created_at: DateTime<Utc>,
    38→    pub device_id: Option<String>,
    39→    pub site_id: Option<Uuid>,
    40→    pub site_name: Option<String>,
    41→    pub client_name: Option<String>,
    42→}
    43→
    44→impl From<Agent> for AgentResponse {
    45→    fn from(agent: Agent) -> Self {
    46→        Self {
    47→            id: agent.id,
    48→            hostname: agent.hostname,
    49→            os_type: agent.os_type,
    50→            os_version: agent.os_version,
    51→            agent_version: agent.agent_version,
    52→            last_seen: agent.last_seen,
    53→            status: agent.status,
    54→            created_at: agent.created_at,
    55→            device_id: agent.device_id,
    56→            site_id: agent.site_id,
    57→            site_name: None,
    58→            client_name: None,
    59→        }
    60→    }
    61→}
    62→
    63→/// Create a new agent registration
    64→#[derive(Debug, Clone, Deserialize)]
    65→pub struct CreateAgent {
    66→    pub hostname: String,
    67→    pub api_key_hash: String,
    68→    pub os_type: String,
    69→    pub os_version: Option<String>,
    70→    pub agent_version: Option<String>,
    71→}
    72→
    73→/// Insert a new agent into the database
    74→pub async fn create_agent(pool: &PgPool, agent: CreateAgent) -> Result<Agent, sqlx::Error> {
    75→    sqlx::query_as::<_, Agent>(
    76→        r#"
    77→        INSERT INTO agents (hostname, api_key_hash, os_type, os_version, agent_version, status)
    78→        VALUES ($1, $2, $3, $4, $5, 'offline')
    79→        RETURNING *
    80→        "#,
    81→    )
    82→    .bind(&agent.hostname)
    83→    .bind(&agent.api_key_hash)
    84→    .bind(&agent.os_type)
    85→    .bind(&agent.os_version)
    86→    .bind(&agent.agent_version)
    87→    .fetch_one(pool)
    88→    .await
    89→}
    90→
    91→/// Get an agent by ID
    92→pub async fn get_agent_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Agent>, sqlx::Error> {
    93→    sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE id = $1")
    94→        .bind(id)
    95→        .fetch_optional(pool)
    96→        .await
    97→}
    98→
    99→/// Get an agent by API key hash
   100→pub async fn get_agent_by_api_key_hash(
   101→    pool: &PgPool,
   102→    api_key_hash: &str,
   103→) -> Result<Option<Agent>, sqlx::Error> {
   104→    sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE api_key_hash = $1")
   105→        .bind(api_key_hash)
   106→        .fetch_optional(pool)
   107→        .await
   108→}
   109→
   110→/// Get all agents
   111→pub async fn get_all_agents(pool: &PgPool) -> Result<Vec<Agent>, sqlx::Error> {
   112→    sqlx::query_as::<_, Agent>("SELECT * FROM agents ORDER BY hostname")
   113→        .fetch_all(pool)
   114→        .await
   115→}
   116→
   117→/// Get agents by status
   118→pub async fn get_agents_by_status(
   119→    pool: &PgPool,
   120→    status: &str,

<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>

//! Agent database operations

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

/// Agent record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Agent {
    pub id: Uuid,
    pub hostname: String,
    #[serde(skip_serializing)]
    pub api_key_hash: Option<String>,  // Nullable: new agents use site's api_key
    pub os_type: String,
    pub os_version: Option<String>,
    pub agent_version: Option<String>,
    pub last_seen: Option<DateTime<Utc>>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    // New fields for site-based registration
    pub device_id: Option<String>,     // Hardware-derived unique ID
    pub site_id: Option<Uuid>,         // Which site this agent belongs to
}

/// Agent without sensitive fields (for API responses)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentResponse {
    pub id: Uuid,
    pub hostname: String,
    pub os_type: String,
    pub os_version: Option<String>,
    pub agent_version: Option<String>,
    pub last_seen: Option<DateTime<Utc>>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub device_id: Option<String>,
    pub site_id: Option<Uuid>,
    pub site_name: Option<String>,
    pub client_name: Option<String>,
}

impl From<Agent> for AgentResponse {
    fn from(agent: Agent) -> Self {
        Self {
            id: agent.id,
            hostname: agent.hostname,
            os_type: agent.os_type,
            os_version: agent.os_version,
            agent_version: agent.agent_version,
            last_seen: agent.last_seen,
            status: agent.status,
            created_at: agent.created_at,
            device_id: agent.device_id,
            site_id: agent.site_id,
            site_name: None,
            client_name: None,
        }
    }
}

/// Create a new agent registration
#[derive(Debug, Clone, Deserialize)]
pub struct CreateAgent {
    pub hostname: String,
    pub api_key_hash: String,
    pub os_type: String,
    pub os_version: Option<String>,
    pub agent_version: Option<String>,
}

/// Insert a new agent into the database
pub async fn create_agent(pool: &PgPool, agent: CreateAgent) -> Result<Agent, sqlx::Error> {
    sqlx::query_as::<_, Agent>(
        r#"
        INSERT INTO agents (hostname, api_key_hash, os_type, os_version, agent_version, status)
        VALUES ($1, $2, $3, $4, $5, 'offline')
        RETURNING *
        "#,
    )
    .bind(&agent.hostname)
    .bind(&agent.api_key_hash)
    .bind(&agent.os_type)
    .bind(&agent.os_version)
    .bind(&agent.agent_version)
    .fetch_one(pool)
    .await
}

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

/// Get an agent by API key hash
pub async fn get_agent_by_api_key_hash(
    pool: &PgPool,
    api_key_hash: &str,
) -> Result<Option<Agent>, sqlx::Error> {
    sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE api_key_hash = $1")
        .bind(api_key_hash)
        .fetch_optional(pool)
        .await
}

/// Get all agents
pub async fn get_all_agents(pool: &PgPool) -> Result<Vec<Agent>, sqlx::Error> {
    sqlx::query_as::<_, Agent>("SELECT * FROM agents ORDER BY hostname")
        .fetch_all(pool)
        .await
}

/// Get agents by status
pub async fn get_agents_by_status(
    pool: &PgPool,
    status: &str,


Let me also check the Cargo.toml to understand the dependencies:


     1→[package]
     2→name = "gururmm-server"
     3→version = "0.2.0"
     4→edition = "2021"
     5→description = "GuruRMM Server - RMM management server"
     6→authors = ["GuruRMM"]
     7→
     8→[dependencies]
     9→# Web framework
    10→axum = { version = "0.7", features = ["ws", "macros"] }
    11→axum-extra = { version = "0.9", features = ["typed-header"] }
    12→tower = { version = "0.5", features = ["util", "timeout"] }
    13→tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
    14→
    15→# Async runtime
    16→tokio = { version = "1", features = ["full"] }
    17→
    18→# Database
    19→sqlx = { version = "0.8", features = [
    20→    "runtime-tokio",
    21→    "tls-native-tls",
    22→    "postgres",
    23→    "uuid",
    24→    "chrono",
    25→    "migrate"
    26→] }
    27→
    28→# Serialization
    29→serde = { version = "1", features = ["derive"] }
    30→serde_json = "1"
    31→
    32→# Configuration
    33→config = "0.14"
    34→
    35→# Authentication
    36→jsonwebtoken = "9"
    37→argon2 = "0.5"
    38→
    39→# OAuth2/SSO
    40→oauth2 = "4.4"
    41→reqwest = { version = "0.12", features = ["json"] }
    42→
    43→# UUID
    44→uuid = { version = "1", features = ["v4", "serde"] }
    45→
    46→# Time
    47→chrono = { version = "0.4", features = ["serde"] }
    48→
    49→# Logging
    50→tracing = "0.1"
    51→tracing-subscriber = { version = "0.3", features = ["env-filter"] }
    52→
    53→# Error handling
    54→anyhow = "1"
    55→thiserror = "1"
    56→
    57→# Random for API key generation
    58→rand = "0.8"
    59→base64 = "0.22"
    60→
    61→# Hashing for API keys
    62→sha2 = "0.10"
    63→
    64→# Semantic versioning for agent updates
    65→semver = "1"
    66→
    67→# Environment variables
    68→dotenvy = "0.15"
    69→
    70→# Futures for WebSocket
    71→futures-util = "0.3"
    72→
    73→# Pin transitive dependencies to stable versions
    74→home = "0.5.9"  # 0.5.12 requires Rust 1.88
    75→
    76→[profile.release]
    77→opt-level = 3
    78→lto = true
    79→codegen-units = 1
    80→

<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>

[package]
name = "gururmm-server"
version = "0.2.0"
edition = "2021"
description = "GuruRMM Server - RMM management server"
authors = ["GuruRMM"]

[dependencies]
# Web framework
axum = { version = "0.7", features = ["ws", "macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }

# Async runtime
tokio = { version = "1", features = ["full"] }

# Database
sqlx = { version = "0.8", features = [
    "runtime-tokio",
    "tls-native-tls",
    "postgres",
    "uuid",
    "chrono",
    "migrate"
] }

# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Configuration
config = "0.14"

# Authentication
jsonwebtoken = "9"
argon2 = "0.5"

# OAuth2/SSO
oauth2 = "4.4"
reqwest = { version = "0.12", features = ["json"] }

# UUID
uuid = { version = "1", features = ["v4", "serde"] }

# Time
chrono = { version = "0.4", features = ["serde"] }

# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Error handling
anyhow = "1"
thiserror = "1"

# Random for API key generation
rand = "0.8"
base64 = "0.22"

# Hashing for API keys
sha2 = "0.10"

# Semantic versioning for agent updates
semver = "1"

# Environment variables
dotenvy = "0.15"

# Futures for WebSocket
futures-util = "0.3"

# Pin transitive dependencies to stable versions
home = "0.5.9"  # 0.5.12 requires Rust 1.88

[profile.release]
opt-level = 3
lto = true
codegen-units = 1


     1→//! Server configuration
     2→//!
     3→//! Configuration is loaded from environment variables.
     4→//! Required variables:
     5→//! - DATABASE_URL: PostgreSQL connection string
     6→//! - JWT_SECRET: Secret key for JWT token signing
     7→//!
     8→//! Optional variables:
     9→//! - SERVER_HOST: Host to bind to (default: 0.0.0.0)
    10→//! - SERVER_PORT: Port to bind to (default: 3001)
    11→//! - DB_MAX_CONNECTIONS: Max database connections (default: 10)
    12→//! - DOWNLOADS_DIR: Directory containing agent binaries (default: /var/www/downloads)
    13→//! - DOWNLOADS_BASE_URL: Base URL for downloads (default: http://localhost:3001/downloads)
    14→//! - AUTO_UPDATE_ENABLED: Enable automatic agent updates (default: true)
    15→//! - UPDATE_TIMEOUT_SECS: Timeout for agent updates (default: 180)
    16→
    17→use std::path::PathBuf;
    18→
    19→use anyhow::{Context, Result};
    20→use serde::{Deserialize, Serialize};
    21→
    22→/// Root server configuration
    23→#[derive(Debug, Clone, Serialize, Deserialize)]
    24→pub struct ServerConfig {
    25→    /// Server binding configuration
    26→    pub server: ServerBindConfig,
    27→
    28→    /// Database configuration
    29→    pub database: DatabaseConfig,
    30→
    31→    /// Authentication configuration
    32→    pub auth: AuthConfig,
    33→
    34→    /// SSO configuration
    35→    pub sso: SsoConfig,
    36→
    37→    /// Agent updates configuration
    38→    pub updates: UpdatesConfig,
    39→}
    40→
    41→/// Server binding configuration
    42→#[derive(Debug, Clone, Serialize, Deserialize)]
    43→pub struct ServerBindConfig {
    44→    /// Host to bind to
    45→    pub host: String,
    46→
    47→    /// Port to bind to
    48→    pub port: u16,
    49→}
    50→
    51→/// Database configuration
    52→#[derive(Debug, Clone, Serialize, Deserialize)]
    53→pub struct DatabaseConfig {
    54→    /// PostgreSQL connection URL
    55→    pub url: String,
    56→
    57→    /// Maximum number of connections in the pool
    58→    pub max_connections: u32,
    59→}
    60→
    61→/// Authentication configuration
    62→#[derive(Debug, Clone, Serialize, Deserialize)]
    63→pub struct AuthConfig {
    64→    /// JWT signing secret
    65→    pub jwt_secret: String,
    66→
    67→    /// JWT token expiry in hours
    68→    pub jwt_expiry_hours: u64,
    69→
    70→    /// API key prefix for agents
    71→    pub api_key_prefix: String,
    72→}
    73→
    74→/// SSO configuration
    75→#[derive(Debug, Clone, Serialize, Deserialize)]
    76→pub struct SsoConfig {
    77→    /// Microsoft Entra ID (Azure AD) configuration
    78→    pub entra: Option<EntraConfig>,
    79→}
    80→
    81→/// Microsoft Entra ID configuration
    82→#[derive(Debug, Clone, Serialize, Deserialize)]
    83→pub struct EntraConfig {
    84→    /// Azure AD application (client) ID
    85→    pub client_id: String,
    86→
    87→    /// Azure AD client secret
    88→    pub client_secret: String,
    89→
    90→    /// OAuth2 redirect URI
    91→    pub redirect_uri: String,
    92→
    93→    /// Default role assigned to new SSO users
    94→    pub default_role: String,
    95→}
    96→
    97→/// Agent updates configuration
    98→#[derive(Debug, Clone, Serialize, Deserialize)]
    99→pub struct UpdatesConfig {
   100→    /// Directory containing agent binaries

<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>

//! Server configuration
//!
//! Configuration is loaded from environment variables.
//! Required variables:
//! - DATABASE_URL: PostgreSQL connection string
//! - JWT_SECRET: Secret key for JWT token signing
//!
//! Optional variables:
//! - SERVER_HOST: Host to bind to (default: 0.0.0.0)
//! - SERVER_PORT: Port to bind to (default: 3001)
//! - DB_MAX_CONNECTIONS: Max database connections (default: 10)
//! - DOWNLOADS_DIR: Directory containing agent binaries (default: /var/www/downloads)
//! - DOWNLOADS_BASE_URL: Base URL for downloads (default: http://localhost:3001/downloads)
//! - AUTO_UPDATE_ENABLED: Enable automatic agent updates (default: true)
//! - UPDATE_TIMEOUT_SECS: Timeout for agent updates (default: 180)

use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// Root server configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
    /// Server binding configuration
    pub server: ServerBindConfig,

    /// Database configuration
    pub database: DatabaseConfig,

    /// Authentication configuration
    pub auth: AuthConfig,

    /// SSO configuration
    pub sso: SsoConfig,

    /// Agent updates configuration
    pub updates: UpdatesConfig,
}

/// Server binding configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerBindConfig {
    /// Host to bind to
    pub host: String,

    /// Port to bind to
    pub port: u16,
}

/// Database configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
    /// PostgreSQL connection URL
    pub url: String,

    /// Maximum number of connections in the pool
    pub max_connections: u32,
}

/// Authentication configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
    /// JWT signing secret
    pub jwt_secret: String,

    /// JWT token expiry in hours
    pub jwt_expiry_hours: u64,

    /// API key prefix for agents
    pub api_key_prefix: String,
}

/// SSO configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsoConfig {
    /// Microsoft Entra ID (Azure AD) configuration
    pub entra: Option<EntraConfig>,
}

/// Microsoft Entra ID configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntraConfig {
    /// Azure AD application (client) ID
    pub client_id: String,

    /// Azure AD client secret
    pub client_secret: String,

    /// OAuth2 redirect URI
    pub redirect_uri: String,

    /// Default role assigned to new SSO users
    pub default_role: String,
}

/// Agent updates configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatesConfig {
    /// Directory containing agent binaries


Excellent! Now I have a comprehensive understanding of the GuruRMM server architecture. Let me compile my findings into a detailed report.

## GuruRMM Server Architecture Analysis - Policy Delivery & Alert System Design

Based on my thorough exploration of the codebase, here's a detailed report on the server architecture:

---

### 1. API ROUTE PATTERNS AND EXTENSIBILITY

**Current Route Structure** (from `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\api\mod.rs`):

The API is built with **Axum**, a Rust web framework, using a modular routing pattern:

```rust
Router::new()
    // Authentication (POST/GET)
    .route("/auth/login", post(auth::login))
    .route("/auth/register", post(auth::register))
    .route("/auth/me", get(auth::me))

    // Clients & Sites (full CRUD)
    .route("/clients", get/post/put/delete)
    .route("/sites", get/post/put/delete)

    // Agents (management)
    .route("/agents", get/post)
    .route("/agents/:id", get/delete)
    .route("/agents/:id/move", post)
    .route("/agents/:id/update", post)
    .route("/agents/:id/state", get)

    // Metrics (time-series retrieval)
    .route("/agents/:id/metrics", get)
    .route("/metrics/summary", get)

    // Commands (dispatch & history)
    .route("/agents/:id/command", post)
    .route("/commands", get)
    .route("/commands/:id", get)
```

**How to Add New Routes:**
The pattern is straightforward. New routes would be added as:
1. Create a new module file in `server/src/api/` (e.g., `policies.rs`)
2. Add handler functions with signatures like: `async fn handler(State(state): State<AppState>, auth: AuthUser, ...) -> Result<Json<T>, (StatusCode, String)>`
3. Register in `api/mod.rs` via `.route("/policies/...", post/get/put/delete(module::handler))`

**Key Advantages for Policy Routes:**
- Type-safe error handling with `StatusCode` responses
- JWT authentication via `AuthUser` extractor (automatic on all routes)
- State injection provides access to: DB pool, WebSocket connections, config, update manager
- Supports path parameters: `/sites/:site_id/policies`
- Query parameters supported for filtering: `/agents?site_id=...&status=...`

---

### 2. WEBSOCKET MESSAGE FLOW TO AGENTS

**Architecture** (from `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\ws\mod.rs`):

The server maintains **real-time bidirectional communication** via WebSocket with a clean message envelope system:

**Agent → Server Message Types:**
```rust
pub enum AgentMessage {
    Auth(AuthPayload),           // Initial handshake
    Metrics(MetricsPayload),      // CPU, memory, disk, temps (every ~30s)
    NetworkState(NetworkStatePayload),  // Interface list
    CommandResult(CommandResultPayload),  // Command execution results
    WatchdogEvent(WatchdogEventPayload),  // Service/process alerts
    UpdateResult(UpdateResultPayload),    // Agent update completion
    Heartbeat,                   // Keep-alive
}
```

**Server → Agent Message Types:**
```rust
pub enum ServerMessage {
    AuthAck(AuthAckPayload),     // Authentication response
    Command(CommandPayload),      // Execute shell/PowerShell/Python
    ConfigUpdate(serde_json::Value),  // FLEXIBLE JSON config (unused currently!)
    Update(UpdatePayload),        // Push binary update
    Ack { message_id: Option<String> },  // Acknowledgment
    Error { code: String, message: String },  // Error notification
}
```

**Message Flow Diagram:**

```
Agent connects → sends Auth → server verifies & stores in AgentConnections map

While connected:
  - Agent sends Metrics → stored in DB metrics table (time-series)
  - Agent sends WatchdogEvent → logged (not stored systematically yet)
  - Server sends Command → forwarded via channel → agent executes
  - Agent sends CommandResult → stored in commands table with exit code/output
  - Server can send ConfigUpdate → NOT YET IMPLEMENTED (empty JSON)
  - Server can send Update → agent downloads & installs new binary

Agent disconnects → removed from AgentConnections, status set to "offline"
```

**Critical for Policy System:**
- `ConfigUpdate(serde_json::Value)` message type **already exists** but is not used
- This is the perfect hook for delivering policies to agents
- Uses JSON serialization: flexible for different policy schemas
- Delivery mechanism: immediate via `agents.send_to(&agent_id, msg).await`

**Connection Management:**
```rust
pub struct AgentConnections {
    connections: HashMap<Uuid, mpsc::Sender<ServerMessage>>,
}

// Send to specific agent
pub async fn send_to(&self, agent_id: &Uuid, msg: ServerMessage) -> bool {
    // Returns false if agent not connected (offline)
}

// Check connection status
pub fn is_connected(&agent_id: &Uuid) -> bool
```

---

### 3. CONFIGURATION/COMMAND DELIVERY MECHANISM

**Current Command System** (from `server/src/api/commands.rs`):

Commands are sent via REST API and dispatched immediately to connected agents:

```rust
POST /api/agents/:id/command
{
    "command_type": "shell",      // or "powershell", "python", "script"
    "command": "systemctl status nginx",
    "timeout_seconds": 300,
    "elevated": false
}

Response:
{
    "command_id": "uuid",
    "status": "running|pending",
    "message": "Command sent to agent | Agent offline, queued"
}
```

**Execution Flow:**
1. REST endpoint creates command record in DB (status = "pending")
2. Checks if agent is connected: `agents.is_connected(&agent_id)`
3. If connected: sends via WebSocket immediately (status = "running")
4. If offline: command stays "pending" until agent reconnects
5. Agent executes and returns `CommandResult`
6. Server updates DB with exit code, stdout, stderr

**For Policies, This Suggests:**

Policies can follow the same pattern:
1. Create policy in DB (new `policies` table)
2. Send via `ConfigUpdate` message when agent connects
3. Store delivery acknowledgment in DB
4. For offline agents: queue policies, deliver on reconnection
5. Track policy compliance via metrics from agents

---

### 4. EXISTING ALERTING & THRESHOLD PATTERNS

**What Currently Exists:**

**Metrics Storage** (from `server/src/db/metrics.rs`):
- Every agent sends metrics every ~30s (timestamp-based)
- Stored in PostgreSQL `metrics` table with time-series indexing
- Fields available: CPU%, memory%, disk%, network I/O, uptime, logged-in user, IPs, temperatures

```rust
pub struct Metrics {
    pub cpu_percent: Option<f32>,
    pub memory_percent: Option<f32>,
    pub memory_used_bytes: Option<i64>,
    pub disk_percent: Option<f32>,
    pub cpu_temp_celsius: Option<f32>,
    pub gpu_temp_celsius: Option<f32>,
    pub temperatures: Option<serde_json::Value>,  // Array of sensors
}
```

**Summary Queries Available:**
- `get_metrics_summary()` → aggregates last hour: AVG(cpu), AVG(memory), SUM(network)
- `get_agent_metrics_range(start, end)` → time-range queries
- Index on `(agent_id, timestamp DESC)` for efficient historical queries

**Watchdog Events** (from `ws/mod.rs`):
```rust
AgentMessage::WatchdogEvent(WatchdogEventPayload) {
    pub name: String,           // "nginx", "mysql", etc.
    pub event: String,          // "stopped", "restarted", etc.
    pub details: Option<String>,
}
```
- Currently **logged but not stored in DB** (comment: "table exists but we'll add the insert function later")
- Perfect for alert triggering

**What's NOT Yet Implemented:**
- No `alert_thresholds` table
- No `alert_policies` table
- No alert evaluation engine
- No notification mechanism (email, Slack, etc.)
- No alert history/acknowledgment tracking

---

### 5. DATABASE SCHEMA FOR POLICY SYSTEM

**Current Tables** (from migrations):

```
agents
├── id, hostname, status, os_type, version, last_seen
├── device_id (hardware-derived ID)
└── site_id (FK to sites)

sites
├── id, name, site_code, client_id
└── api_key_hash (shared by all agents at site)

clients
└── id, name, code, is_active

metrics (time-series)
├── agent_id, timestamp
├── cpu_percent, memory_percent, disk_percent
├── network_rx_bytes, network_tx_bytes
├── uptime_seconds, logged_in_user, public_ip
└── cpu_temp_celsius, gpu_temp_celsius, temperatures JSONB

commands
├── agent_id, command_type, status
├── exit_code, stdout, stderr
└── created_by (user who issued it)

watchdog_events
├── agent_id, timestamp, service_name
└── event_type, details

agent_state (latest snapshot, not time-series)
├── agent_id
├── network_interfaces JSONB
├── uptime_seconds, logged_in_user, public_ip
└── metrics_updated_at, network_updated_at
```

**Schema Needed for Policies:**

```sql
-- Policy definitions (templates)
policies
├── id, site_id, name, description
├── type ('cpu_threshold' | 'memory_threshold' | 'disk_threshold' | 'service_monitoring' | 'custom')
├── enabled BOOLEAN, priority ('low' | 'medium' | 'high' | 'critical')
└── created_at, updated_at

-- Policy conditions/thresholds
policy_conditions
├── id, policy_id
├── metric ('cpu_percent' | 'memory_percent' | 'disk_percent' | 'temperature' | custom)
├── operator ('>' | '<' | '==' | '!=' for numeric/string comparisons)
├── threshold_value, duration_seconds (how long before triggering)
└── created_at

-- Alert rules (what happens when policy triggered)
alert_rules
├── id, policy_id
├── action_type ('notify' | 'command' | 'escalate')
├── action_config JSONB (email addresses, command to run, escalation level)
└── enabled BOOLEAN

-- Alert history (for dashboards, auditing)
alerts
├── id, policy_id, agent_id, timestamp
├── status ('triggered' | 'resolved' | 'acknowledged')
├── value (actual metric value at trigger)
├── acknowledged_by, acknowledged_at
└── resolved_at
```

---

### 6. ARCHITECTURE FOR NEW POLICY FEATURES

**Proposed Implementation Path:**

**1. REST API Layer** (`server/src/api/policies.rs`):
```
POST   /api/policies                    → Create policy
GET    /api/policies                    → List policies
GET    /api/policies/:id                → Get policy details
PUT    /api/policies/:id                → Update policy
DELETE /api/policies/:id                → Delete policy

POST   /api/policies/:id/deploy         → Push to agents
GET    /api/policies/:id/deployment     → Check rollout status
GET    /api/alerts                      → List alerts (with filtering)
POST   /api/alerts/:id/acknowledge      → Mark alert as reviewed
```

**2. Database Module** (`server/src/db/policies.rs`):
- CRUD operations for policies, conditions, rules
- Batch queries: "all active policies for site X"
- Alert insertion, retrieval, acknowledgment

**3. Evaluation Engine** (`server/src/policy_engine.rs`):
- Background task that runs every N seconds
- Queries latest metrics from `metrics` table
- Evaluates each active policy's conditions
- Creates alerts in DB when conditions met
- Handles escalation (repeat alerts, notify admins)

**4. WebSocket Integration** (`server/src/ws/mod.rs`):
- When policy is deployed: send `ConfigUpdate` with policy JSON
- Agent periodically evaluates policies locally
- Agent sends back `WatchdogEvent` for policy violations
- Server correlation: match agent-reported events with expected policies

---

### 7. KEY ARCHITECTURAL ADVANTAGES

1. **Real-time Delivery**: WebSocket channel already exists for instant config pushes
2. **Metrics Foundation**: Metrics stored with agent ID + timestamp, perfect for threshold evaluation
3. **Existing Audit Trail**: Commands already track who initiated them → policies can do same
4. **Site-based Scoping**: Sites table provides organizational boundaries for policy templates
5. **JSON Flexibility**: `ConfigUpdate(serde_json::Value)` allows any policy schema
6. **Offline Handling**: Command system already queues for offline agents → model for policies
7. **Modular**: New modules don't require changes to core WebSocket or agent authentication

---

### 8. FILE PATHS FOR REFERENCE

- **API Routes**: `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\api\mod.rs`
- **WebSocket Core**: `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\ws\mod.rs`
- **Message Payloads**: Lines 77-251 in ws/mod.rs (all message types defined)
- **Database Schema**: `/C:\Users\MikeSwanson\claude-projects\gururmm\server\migrations\` (all 5 migration files)
- **Commands Handler**: `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\api\commands.rs`
- **Metrics Handler**: `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\api\metrics.rs`
- **Metrics DB Queries**: `/C:\Users\MikeSwanson\claude-projects\gururmm\server\src\db\metrics.rs`

---

### Summary

The GuruRMM server is **well-architected for policy delivery**. The ConfigUpdate message type exists but is currently unused—this is your primary vehicle for policy distribution. The metrics storage is solid with proper time-series indexing, the command dispatch pattern provides a proven model for off-line queueing, and the site-based hierarchy enables policy templates. The main work is:

1. Create policy/threshold schema tables
2. Implement REST API for policy CRUD and deployment
3. Build evaluation engine (background task or triggered on metrics ingestion)
4. Wire up alert history and escalation rules