feat(backend): implémentation complète Phase 1
- Migration SQLite initiale : agents, devices, metrics, events - API REST v1 complète : /agents, /network, /metrics, /events, /widgets - Endpoints widgets Glance : /api/v1/widgets/network et /api/v1/widgets/metrics - Spec OpenAPI générée et servie sur /api-docs/openapi.json - Gestion d'erreurs centralisée (AppError) - CORS permissif pour développement - push réseau met à jour le last_seen de l'agent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -13,5 +13,5 @@ tracing = { workspace = true }
|
|||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
utoipa = { version = "4", features = ["axum_extras"] }
|
utoipa = { version = "5", features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "7", features = ["axum"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Agents enregistrés (scan-network, metric)
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
agent_type TEXT NOT NULL CHECK(agent_type IN ('scan-network', 'metric')),
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'online' CHECK(status IN ('online', 'offline')),
|
||||||
|
last_seen TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Équipements réseau découverts
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
mac TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
vendor TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'unknown' CHECK(state IN ('online', 'offline', 'sleep', 'unknown')),
|
||||||
|
services TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||||
|
open_ports TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||||
|
last_seen TEXT NOT NULL,
|
||||||
|
first_seen TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Métriques système (dernière valeur par agent)
|
||||||
|
CREATE TABLE IF NOT EXISTS metrics (
|
||||||
|
agent_id TEXT NOT NULL REFERENCES agents(id),
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
cpu_percent REAL,
|
||||||
|
ram_percent REAL,
|
||||||
|
load_avg REAL,
|
||||||
|
temperature_c REAL,
|
||||||
|
disk_percent REAL,
|
||||||
|
net_rx_bps INTEGER,
|
||||||
|
net_tx_bps INTEGER,
|
||||||
|
extra TEXT DEFAULT '{}', -- JSON pour champs futurs
|
||||||
|
PRIMARY KEY (agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Événements système
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL DEFAULT '{}' -- JSON
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_agent ON events(agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_state ON devices(state);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||||
|
|
||||||
|
pub async fn connect(database_url: &str) -> anyhow::Result<SqlitePool> {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
pub struct AppError(anyhow::Error);
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let code = StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
|
let body = Json(json!({ "error": self.0.to_string() }));
|
||||||
|
(code, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Into<anyhow::Error>> From<E> for AppError {
|
||||||
|
fn from(e: E) -> Self {
|
||||||
|
Self(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
+51
-10
@@ -1,24 +1,65 @@
|
|||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Json};
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod models;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(
|
||||||
|
routes::health::health,
|
||||||
|
routes::agents::list,
|
||||||
|
routes::agents::register,
|
||||||
|
routes::agents::get_one,
|
||||||
|
routes::network::list,
|
||||||
|
routes::network::push,
|
||||||
|
routes::network::get_one,
|
||||||
|
routes::metrics::list,
|
||||||
|
routes::metrics::push,
|
||||||
|
routes::metrics::get_one,
|
||||||
|
routes::events::list,
|
||||||
|
routes::events::push,
|
||||||
|
routes::widgets::network,
|
||||||
|
routes::widgets::metrics,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
models::Agent, models::RegisterAgent,
|
||||||
|
models::Device, models::PushDevices, models::DeviceUpdate,
|
||||||
|
models::Metric, models::PushMetrics,
|
||||||
|
models::Event, models::PushEvent,
|
||||||
|
)),
|
||||||
|
info(title = "SentinelMesh API", version = "0.1.0", description = "API centrale SentinelMesh")
|
||||||
|
)]
|
||||||
|
struct ApiDoc;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::from_default_env())
|
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse()?))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let app = Router::new().route("/api/v1/health", get(health));
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite://sentinelmesh.sqlite".into());
|
||||||
|
|
||||||
let addr = "0.0.0.0:8080";
|
let db = db::connect(&database_url).await?;
|
||||||
info!("SentinelMesh backend démarré sur {addr}");
|
info!("Base de données connectée : {database_url}");
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let spec = ApiDoc::openapi();
|
||||||
|
let app = routes::api_router(db)
|
||||||
|
.route("/api-docs/openapi.json", get(move || async move { Json(spec) }))
|
||||||
|
.layer(CorsLayer::permissive());
|
||||||
|
|
||||||
|
let addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into());
|
||||||
|
info!("SentinelMesh backend démarré sur http://{addr}");
|
||||||
|
info!("OpenAPI spec : http://{addr}/api-docs/openapi.json");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> &'static str {
|
|
||||||
"ok"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
|
||||||
|
pub struct Agent {
|
||||||
|
pub id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub agent_type: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub version: String,
|
||||||
|
pub status: String,
|
||||||
|
pub last_seen: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct RegisterAgent {
|
||||||
|
pub id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub agent_type: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
|
||||||
|
pub struct Device {
|
||||||
|
pub ip: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub vendor: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
pub services: String, // JSON serialisé
|
||||||
|
pub open_ports: String, // JSON serialisé
|
||||||
|
pub last_seen: String,
|
||||||
|
pub first_seen: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct PushDevices {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub devices: Vec<DeviceUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct DeviceUpdate {
|
||||||
|
pub ip: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub vendor: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
pub services: Vec<String>,
|
||||||
|
pub open_ports: Vec<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
|
||||||
|
pub struct Metric {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub cpu_percent: Option<f64>,
|
||||||
|
pub ram_percent: Option<f64>,
|
||||||
|
pub load_avg: Option<f64>,
|
||||||
|
pub temperature_c: Option<f64>,
|
||||||
|
pub disk_percent: Option<f64>,
|
||||||
|
pub net_rx_bps: Option<i64>,
|
||||||
|
pub net_tx_bps: Option<i64>,
|
||||||
|
pub extra: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct PushMetrics {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub cpu_percent: Option<f64>,
|
||||||
|
pub ram_percent: Option<f64>,
|
||||||
|
pub load_avg: Option<f64>,
|
||||||
|
pub temperature_c: Option<f64>,
|
||||||
|
pub disk_percent: Option<f64>,
|
||||||
|
pub net_rx_bps: Option<i64>,
|
||||||
|
pub net_tx_bps: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
|
||||||
|
pub struct Event {
|
||||||
|
pub id: i64,
|
||||||
|
pub agent_id: String,
|
||||||
|
pub event_type: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct PushEvent {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub event_type: String,
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use axum::{extract::{Path, State}, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use utoipa::path as oapath;
|
||||||
|
|
||||||
|
use crate::{error::Result, models::{Agent, RegisterAgent}};
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/agents",
|
||||||
|
responses((status = 200, body = Vec<Agent>)))]
|
||||||
|
pub async fn list(State(db): State<SqlitePool>) -> Result<Json<Vec<Agent>>> {
|
||||||
|
let agents = sqlx::query_as::<_, Agent>("SELECT * FROM agents ORDER BY last_seen DESC")
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(agents))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(post, path = "/api/v1/agents",
|
||||||
|
request_body = RegisterAgent,
|
||||||
|
responses((status = 200, body = Agent)))]
|
||||||
|
pub async fn register(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Json(body): Json<RegisterAgent>,
|
||||||
|
) -> Result<Json<Agent>> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO agents (id, hostname, agent_type, ip, version, status, last_seen, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'online', ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
hostname = excluded.hostname, ip = excluded.ip,
|
||||||
|
version = excluded.version, status = 'online', last_seen = excluded.last_seen",
|
||||||
|
)
|
||||||
|
.bind(&body.id).bind(&body.hostname).bind(&body.agent_type)
|
||||||
|
.bind(&body.ip).bind(&body.version).bind(&now).bind(&now)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let agent = sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE id = ?")
|
||||||
|
.bind(&body.id)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(agent))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/agents/{id}",
|
||||||
|
params(("id" = String, Path, description = "Identifiant de l'agent")),
|
||||||
|
responses((status = 200, body = Agent), (status = 404, description = "Agent inconnu")))]
|
||||||
|
pub async fn get_one(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Agent>> {
|
||||||
|
let agent = sqlx::query_as::<_, Agent>("SELECT * FROM agents WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(agent))
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use utoipa::path as oapath;
|
||||||
|
|
||||||
|
use crate::{error::Result, models::{Event, PushEvent}};
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/events",
|
||||||
|
responses((status = 200, body = Vec<Event>)))]
|
||||||
|
pub async fn list(State(db): State<SqlitePool>) -> Result<Json<Vec<Event>>> {
|
||||||
|
let events = sqlx::query_as::<_, Event>(
|
||||||
|
"SELECT * FROM events ORDER BY timestamp DESC LIMIT 200",
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(post, path = "/api/v1/events",
|
||||||
|
request_body = PushEvent,
|
||||||
|
responses((status = 200, description = "Événement enregistré")))]
|
||||||
|
pub async fn push(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Json(body): Json<PushEvent>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let data = body.data.map(|v| v.to_string()).unwrap_or_else(|| "{}".into());
|
||||||
|
let id: i64 = sqlx::query_scalar(
|
||||||
|
"INSERT INTO events (agent_id, event_type, timestamp, data) VALUES (?, ?, ?, ?) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&body.agent_id).bind(&body.event_type).bind(&now).bind(&data)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utoipa::path as oapath;
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/health",
|
||||||
|
responses((status = 200, description = "Backend opérationnel")))]
|
||||||
|
pub async fn health() -> Json<Value> {
|
||||||
|
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use axum::{extract::{Path, State}, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use utoipa::path as oapath;
|
||||||
|
|
||||||
|
use crate::{error::Result, models::{Metric, PushMetrics}};
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/metrics",
|
||||||
|
responses((status = 200, body = Vec<Metric>)))]
|
||||||
|
pub async fn list(State(db): State<SqlitePool>) -> Result<Json<Vec<Metric>>> {
|
||||||
|
let metrics = sqlx::query_as::<_, Metric>("SELECT * FROM metrics")
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(metrics))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(post, path = "/api/v1/metrics",
|
||||||
|
request_body = PushMetrics,
|
||||||
|
responses((status = 200, description = "Métriques enregistrées")))]
|
||||||
|
pub async fn push(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Json(body): Json<PushMetrics>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO metrics (agent_id, timestamp, cpu_percent, ram_percent, load_avg,
|
||||||
|
temperature_c, disk_percent, net_rx_bps, net_tx_bps)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(agent_id) DO UPDATE SET
|
||||||
|
timestamp = excluded.timestamp, cpu_percent = excluded.cpu_percent,
|
||||||
|
ram_percent = excluded.ram_percent, load_avg = excluded.load_avg,
|
||||||
|
temperature_c = excluded.temperature_c, disk_percent = excluded.disk_percent,
|
||||||
|
net_rx_bps = excluded.net_rx_bps, net_tx_bps = excluded.net_tx_bps",
|
||||||
|
)
|
||||||
|
.bind(&body.agent_id).bind(&now)
|
||||||
|
.bind(body.cpu_percent).bind(body.ram_percent).bind(body.load_avg)
|
||||||
|
.bind(body.temperature_c).bind(body.disk_percent)
|
||||||
|
.bind(body.net_rx_bps).bind(body.net_tx_bps)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/metrics/{agent_id}",
|
||||||
|
params(("agent_id" = String, Path, description = "Identifiant de l'agent")),
|
||||||
|
responses((status = 200, body = Metric), (status = 404, description = "Agent inconnu")))]
|
||||||
|
pub async fn get_one(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(agent_id): Path<String>,
|
||||||
|
) -> Result<Json<Metric>> {
|
||||||
|
let metric = sqlx::query_as::<_, Metric>("SELECT * FROM metrics WHERE agent_id = ?")
|
||||||
|
.bind(&agent_id)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(metric))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use axum::{routing::get, Router};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
pub mod agents;
|
||||||
|
pub mod events;
|
||||||
|
pub mod health;
|
||||||
|
pub mod metrics;
|
||||||
|
pub mod network;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
|
pub fn api_router(db: SqlitePool) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/v1/health", get(health::health))
|
||||||
|
.route("/api/v1/agents", get(agents::list).post(agents::register))
|
||||||
|
.route("/api/v1/agents/{id}", get(agents::get_one))
|
||||||
|
.route("/api/v1/network", get(network::list).post(network::push))
|
||||||
|
.route("/api/v1/network/{ip}", get(network::get_one))
|
||||||
|
.route("/api/v1/metrics", get(metrics::list).post(metrics::push))
|
||||||
|
.route("/api/v1/metrics/{agent_id}", get(metrics::get_one))
|
||||||
|
.route("/api/v1/events", get(events::list).post(events::push))
|
||||||
|
.route("/api/v1/widgets/network", get(widgets::network))
|
||||||
|
.route("/api/v1/widgets/metrics", get(widgets::metrics))
|
||||||
|
.with_state(db)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use axum::{extract::{Path, State}, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use utoipa::path as oapath;
|
||||||
|
|
||||||
|
use crate::{error::Result, models::{Device, PushDevices}};
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/network",
|
||||||
|
responses((status = 200, body = Vec<Device>)))]
|
||||||
|
pub async fn list(State(db): State<SqlitePool>) -> Result<Json<Vec<Device>>> {
|
||||||
|
let devices = sqlx::query_as::<_, Device>(
|
||||||
|
"SELECT * FROM devices ORDER BY state, ip",
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(devices))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(post, path = "/api/v1/network",
|
||||||
|
request_body = PushDevices,
|
||||||
|
responses((status = 200, description = "Données enregistrées")))]
|
||||||
|
pub async fn push(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Json(body): Json<PushDevices>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
// Mise à jour last_seen de l'agent
|
||||||
|
sqlx::query("UPDATE agents SET last_seen = ?, status = 'online' WHERE id = ?")
|
||||||
|
.bind(&now).bind(&body.agent_id)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
for d in &body.devices {
|
||||||
|
let services = serde_json::to_string(&d.services).unwrap_or_default();
|
||||||
|
let ports = serde_json::to_string(&d.open_ports).unwrap_or_default();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO devices (ip, mac, hostname, vendor, state, services, open_ports, last_seen, first_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
mac = excluded.mac, hostname = excluded.hostname, vendor = excluded.vendor,
|
||||||
|
state = excluded.state, services = excluded.services,
|
||||||
|
open_ports = excluded.open_ports, last_seen = excluded.last_seen",
|
||||||
|
)
|
||||||
|
.bind(&d.ip).bind(&d.mac).bind(&d.hostname).bind(&d.vendor)
|
||||||
|
.bind(&d.state).bind(&services).bind(&ports).bind(&now).bind(&now)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "inserted": body.devices.len() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/network/{ip}",
|
||||||
|
params(("ip" = String, Path, description = "Adresse IP de l'équipement")),
|
||||||
|
responses((status = 200, body = Device), (status = 404, description = "Équipement inconnu")))]
|
||||||
|
pub async fn get_one(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(ip): Path<String>,
|
||||||
|
) -> Result<Json<Device>> {
|
||||||
|
let device = sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE ip = ?")
|
||||||
|
.bind(&ip)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(device))
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use utoipa::path as oapath;
|
||||||
|
|
||||||
|
use crate::{error::Result, models::Device};
|
||||||
|
|
||||||
|
// --- Widget réseau ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NetworkWidget {
|
||||||
|
pub last_scan_at: Option<String>,
|
||||||
|
pub total: usize,
|
||||||
|
pub online: usize,
|
||||||
|
pub devices: Vec<DeviceSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DeviceSummary {
|
||||||
|
pub ip: String,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub vendor: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
pub services: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/widgets/network",
|
||||||
|
responses((status = 200, description = "Données widget réseau pour Glance")))]
|
||||||
|
pub async fn network(State(db): State<SqlitePool>) -> Result<Json<NetworkWidget>> {
|
||||||
|
let devices = sqlx::query_as::<_, Device>("SELECT * FROM devices ORDER BY state, ip")
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let online = devices.iter().filter(|d| d.state == "online").count();
|
||||||
|
let summary = devices
|
||||||
|
.iter()
|
||||||
|
.map(|d| DeviceSummary {
|
||||||
|
ip: d.ip.clone(),
|
||||||
|
hostname: d.hostname.clone(),
|
||||||
|
vendor: d.vendor.clone(),
|
||||||
|
state: d.state.clone(),
|
||||||
|
services: serde_json::from_str(&d.services).unwrap_or(Value::Array(vec![])),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let last_scan = devices.iter().map(|d| d.last_seen.clone()).max();
|
||||||
|
|
||||||
|
Ok(Json(NetworkWidget {
|
||||||
|
last_scan_at: last_scan,
|
||||||
|
total: devices.len(),
|
||||||
|
online,
|
||||||
|
devices: summary,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Widget métriques ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MetricsWidget {
|
||||||
|
pub agents: Vec<AgentMetricSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AgentMetricSummary {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub status: String,
|
||||||
|
pub cpu_percent: Option<f64>,
|
||||||
|
pub ram_percent: Option<f64>,
|
||||||
|
pub disk_percent: Option<f64>,
|
||||||
|
pub temperature_c: Option<f64>,
|
||||||
|
pub last_seen: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[oapath(get, path = "/api/v1/widgets/metrics",
|
||||||
|
responses((status = 200, description = "Données widget métriques pour Glance")))]
|
||||||
|
pub async fn metrics(State(db): State<SqlitePool>) -> Result<Json<MetricsWidget>> {
|
||||||
|
let rows = sqlx::query_as::<_, (
|
||||||
|
String, String, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>, String,
|
||||||
|
)>(
|
||||||
|
"SELECT a.id, a.hostname, a.status, m.cpu_percent, m.ram_percent,
|
||||||
|
m.disk_percent, m.temperature_c, a.last_seen
|
||||||
|
FROM agents a LEFT JOIN metrics m ON m.agent_id = a.id
|
||||||
|
WHERE a.agent_type = 'metric'
|
||||||
|
ORDER BY a.hostname",
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let agents = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, hostname, status, cpu, ram, disk, temp, last_seen)| AgentMetricSummary {
|
||||||
|
agent_id: id, hostname, status, cpu_percent: cpu, ram_percent: ram,
|
||||||
|
disk_percent: disk, temperature_c: temp, last_seen,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(MetricsWidget { agents }))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user