feat(monitor): add alert monitoring page with KPI dashboard, alert list, and incident management

- Add Tauri plugins for file dialog and filesystem access
- Create Rust backend for processing Grafana alerts (pairing, KPI calculation)
- Add virtualized alert list with invalid toggle and incident attachment
- Add incident manager with create, attach, and detach functionality
- Add KPI dashboard showing coverage ratio, downtime, and invalid rate
- Add date-fns for date formatting
This commit is contained in:
Syahdan 2025-12-29 05:44:21 +07:00
parent ee3c0c156b
commit ea1c9105a7
15 changed files with 1757 additions and 19 deletions

View file

@ -0,0 +1,76 @@
use crate::{calculate_kpis, AppError, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert};
use std::fs;
#[tauri::command]
pub async fn load_alerts_from_file(path: String) -> Result<Vec<ProcessedAlert>, AppError> {
let contents = fs::read_to_string(&path)?;
let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&contents)?;
let processed: Vec<ProcessedAlert> = raw_alerts.into_iter().map(ProcessedAlert::from).collect();
Ok(processed)
}
#[tauri::command]
pub fn process_alerts_json(json_content: String) -> Result<Vec<ProcessedAlert>, AppError> {
let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&json_content)?;
let processed: Vec<ProcessedAlert> = raw_alerts.into_iter().map(ProcessedAlert::from).collect();
Ok(processed)
}
#[tauri::command]
pub fn calculate_kpis_command(alerts: Vec<ProcessedAlert>, incidents: Vec<Incident>) -> KpiMetrics {
calculate_kpis(&alerts, &incidents)
}
#[tauri::command]
pub fn set_alert_invalid(
mut alerts: Vec<ProcessedAlert>,
alert_id: u64,
is_invalid: bool,
) -> Vec<ProcessedAlert> {
if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) {
alert.is_invalid = is_invalid;
}
alerts
}
#[tauri::command]
pub fn attach_alert_to_incident(
mut alerts: Vec<ProcessedAlert>,
mut incidents: Vec<Incident>,
alert_id: u64,
incident_id: String,
) -> (Vec<ProcessedAlert>, Vec<Incident>) {
if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) {
alert.attached_incident_id = Some(incident_id.clone());
}
if let Some(incident) = incidents.iter_mut().find(|i| i.id == incident_id) {
if !incident.attached_alert_ids.contains(&alert_id) {
incident.attached_alert_ids.push(alert_id);
}
}
(alerts, incidents)
}
#[tauri::command]
pub fn detach_alert_from_incident(
mut alerts: Vec<ProcessedAlert>,
mut incidents: Vec<Incident>,
alert_id: u64,
) -> (Vec<ProcessedAlert>, Vec<Incident>) {
let incident_id = alerts
.iter()
.find(|a| a.id == alert_id)
.and_then(|a| a.attached_incident_id.clone());
if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) {
alert.attached_incident_id = None;
}
if let Some(id) = incident_id {
if let Some(incident) = incidents.iter_mut().find(|i| i.id == id) {
incident.attached_alert_ids.retain(|&aid| aid != alert_id);
}
}
(alerts, incidents)
}

View file

@ -1,16 +1,250 @@
mod commands;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("Not found: {0}")]
NotFound(String),
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GrafanaAlert {
pub id: u64,
pub alert_id: u32,
#[serde(default)]
pub alert_name: Option<String>,
#[serde(default)]
pub dashboard_uid: Option<String>,
pub new_state: String,
pub prev_state: String,
pub created: u64,
pub updated: u64,
pub time: u64,
pub time_end: u64,
pub text: String,
#[serde(default)]
pub data: Option<AlertData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertData {
#[serde(default, deserialize_with = "deserialize_metric_values")]
pub values: HashMap<String, f64>,
}
/// Deserialize metric values that may contain "+Inf", "-Inf", "NaN" as strings
fn deserialize_metric_values<'de, D>(deserializer: D) -> Result<HashMap<String, f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{MapAccess, Visitor};
use std::fmt;
struct MetricValuesVisitor;
impl<'de> Visitor<'de> for MetricValuesVisitor {
type Value = HashMap<String, f64>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a map of string to number or special float strings")
}
fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut map = HashMap::new();
while let Some((key, value)) = access.next_entry::<String, serde_json::Value>()? {
let num = match value {
serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
serde_json::Value::String(s) => match s.as_str() {
"+Inf" | "Inf" | "infinity" | "-Inf" | "-infinity" | "NaN" | "nan" => 0.0,
other => other.parse().unwrap_or(0.0),
},
_ => 0.0,
};
map.insert(key, num);
}
Ok(map)
}
}
deserializer.deserialize_map(MetricValuesVisitor)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessedAlert {
pub id: u64,
pub alert_id: u32,
#[serde(default)]
pub alert_name: Option<String>,
pub new_state: String,
pub prev_state: String,
pub time: u64,
pub time_end: u64,
pub text: String,
pub duration_ms: u64,
pub is_firing: bool,
pub is_invalid: bool,
pub attached_incident_id: Option<String>,
pub values: HashMap<String, f64>,
}
impl From<GrafanaAlert> for ProcessedAlert {
fn from(alert: GrafanaAlert) -> Self {
let duration_ms = if alert.time_end > alert.time {
alert.time_end - alert.time
} else {
0
};
let is_firing = alert.new_state == "Alerting" || alert.new_state == "Firing";
ProcessedAlert {
id: alert.id,
alert_id: alert.alert_id,
alert_name: alert.alert_name,
new_state: alert.new_state,
prev_state: alert.prev_state,
time: alert.time,
time_end: alert.time_end,
text: alert.text,
duration_ms,
is_firing,
is_invalid: false,
attached_incident_id: None,
values: alert.data.map(|d| d.values).unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Incident {
pub id: String,
pub title: String,
pub description: String,
pub start_time: u64,
pub end_time: u64,
pub attached_alert_ids: Vec<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KpiMetrics {
pub error_coverage_ratio: f64,
pub total_incidents: usize,
pub covered_incidents: usize,
pub overall_downtime_ms: u64,
pub overall_downtime_formatted: String,
pub invalid_alert_ratio: f64,
pub total_firing_alerts: usize,
pub invalid_alerts: usize,
}
pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiMetrics {
let total_incidents = incidents.len();
let covered_incidents = incidents
.iter()
.filter(|incident| !incident.attached_alert_ids.is_empty())
.count();
let error_coverage_ratio = if total_incidents > 0 {
(covered_incidents as f64 / total_incidents as f64) * 100.0
} else {
0.0
};
let overall_downtime_ms: u64 = alerts
.iter()
.filter(|a| a.is_firing && !a.is_invalid)
.map(|a| a.duration_ms)
.sum();
let overall_downtime_formatted = format_duration(overall_downtime_ms);
let total_firing_alerts = alerts.iter().filter(|a| a.is_firing).count();
let invalid_alerts = alerts
.iter()
.filter(|a| a.is_firing && a.is_invalid)
.count();
let invalid_alert_ratio = if total_firing_alerts > 0 {
(invalid_alerts as f64 / total_firing_alerts as f64) * 100.0
} else {
0.0
};
KpiMetrics {
error_coverage_ratio,
total_incidents,
covered_incidents,
overall_downtime_ms,
overall_downtime_formatted,
invalid_alert_ratio,
total_firing_alerts,
invalid_alerts,
}
}
fn format_duration(ms: u64) -> String {
let seconds = ms / 1000;
let minutes = seconds / 60;
let hours = minutes / 60;
let days = hours / 24;
if days > 0 {
format!("{}d {}h {}m", days, hours % 24, minutes % 60)
} else if hours > 0 {
format!("{}h {}m {}s", hours, minutes % 60, seconds % 60)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds % 60)
} else {
format!("{}s", seconds)
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::load_alerts_from_file,
commands::process_alerts_json,
commands::calculate_kpis_command,
commands::set_alert_invalid,
commands::attach_alert_to_incident,
commands::detach_alert_from_incident,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}