From 22aee93fb3db54831d4c245cb78a3ffb3c14983d Mon Sep 17 00:00:00 2001 From: Syahdan Date: Mon, 29 Dec 2025 06:07:13 +0700 Subject: [PATCH] feat(monitor): add alert pairing logic and display incident names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pair_alerts() to pair consecutive Alerting→Normal state transitions - Add parse_labels_from_text() to extract labels from alert text field - Update ProcessedAlert struct with alertTime, resolveTime, isResolved, labels - Display incident name in alert list items instead of incident ID - Show parsed labels in alert row (excluding alertname) --- apps/web/src-tauri/src/commands.rs | 8 +- apps/web/src-tauri/src/lib.rs | 101 +++++++++++------ .../components/monitor/alert-list-item.tsx | 54 +++++---- .../web/src/components/monitor/alert-list.tsx | 17 ++- apps/web/src/lib/types/alerts.ts | 8 +- apps/web/src/routes/monitor.tsx | 106 +++++++++--------- 6 files changed, 165 insertions(+), 129 deletions(-) diff --git a/apps/web/src-tauri/src/commands.rs b/apps/web/src-tauri/src/commands.rs index ae8af7e..12a4c02 100644 --- a/apps/web/src-tauri/src/commands.rs +++ b/apps/web/src-tauri/src/commands.rs @@ -1,19 +1,17 @@ -use crate::{calculate_kpis, AppError, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert}; +use crate::{calculate_kpis, pair_alerts, AppError, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert}; use std::fs; #[tauri::command] pub async fn load_alerts_from_file(path: String) -> Result, AppError> { let contents = fs::read_to_string(&path)?; let raw_alerts: Vec = serde_json::from_str(&contents)?; - let processed: Vec = raw_alerts.into_iter().map(ProcessedAlert::from).collect(); - Ok(processed) + Ok(pair_alerts(raw_alerts)) } #[tauri::command] pub fn process_alerts_json(json_content: String) -> Result, AppError> { let raw_alerts: Vec = serde_json::from_str(&json_content)?; - let processed: Vec = raw_alerts.into_iter().map(ProcessedAlert::from).collect(); - Ok(processed) + Ok(pair_alerts(raw_alerts)) } #[tauri::command] diff --git a/apps/web/src-tauri/src/lib.rs b/apps/web/src-tauri/src/lib.rs index 7ffd32b..456ab6f 100644 --- a/apps/web/src-tauri/src/lib.rs +++ b/apps/web/src-tauri/src/lib.rs @@ -98,44 +98,76 @@ pub struct ProcessedAlert { pub alert_id: u32, #[serde(default)] pub alert_name: Option, - pub new_state: String, - pub prev_state: String, - pub time: u64, - pub time_end: u64, + pub alert_time: u64, + pub resolve_time: Option, pub text: String, pub duration_ms: u64, - pub is_firing: bool, + pub is_resolved: bool, pub is_invalid: bool, pub attached_incident_id: Option, pub values: HashMap, } -impl From for ProcessedAlert { - fn from(alert: GrafanaAlert) -> Self { - let duration_ms = if alert.time_end > alert.time { - alert.time_end - alert.time - } else { - 0 - }; +pub fn pair_alerts(raw_alerts: Vec) -> Vec { + use std::collections::BTreeMap; - let is_firing = alert.new_state == "Alerting" || alert.new_state == "Firing"; + let mut by_alert_id: BTreeMap> = BTreeMap::new(); + for alert in raw_alerts { + by_alert_id.entry(alert.alert_id).or_default().push(alert); + } - 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(), + let mut result = Vec::new(); + + for (_alert_id, mut alerts) in by_alert_id { + alerts.sort_by_key(|a| a.time); + + let mut i = 0; + while i < alerts.len() { + let current = &alerts[i]; + + if current.new_state == "Alerting" { + if i + 1 < alerts.len() && alerts[i + 1].new_state == "Normal" { + let resolve = &alerts[i + 1]; + let duration_ms = resolve.time.saturating_sub(current.time); + + result.push(ProcessedAlert { + id: current.id, + alert_id: current.alert_id, + alert_name: current.alert_name.clone(), + alert_time: current.time, + resolve_time: Some(resolve.time), + text: current.text.clone(), + duration_ms, + is_resolved: true, + is_invalid: false, + attached_incident_id: None, + values: current.data.clone().map(|d| d.values).unwrap_or_default(), + }); + i += 2; + } else { + result.push(ProcessedAlert { + id: current.id, + alert_id: current.alert_id, + alert_name: current.alert_name.clone(), + alert_time: current.time, + resolve_time: None, + text: current.text.clone(), + duration_ms: 0, + is_resolved: false, + is_invalid: false, + attached_incident_id: None, + values: current.data.clone().map(|d| d.values).unwrap_or_default(), + }); + i += 1; + } + } else { + i += 1; + } } } + + result.sort_by(|a, b| b.alert_time.cmp(&a.alert_time)); + result } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -176,19 +208,16 @@ pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiM let overall_downtime_ms: u64 = alerts .iter() - .filter(|a| a.is_firing && !a.is_invalid) + .filter(|a| !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 + let total_alerts = alerts.len(); + let invalid_alerts = alerts.iter().filter(|a| a.is_invalid).count(); + let invalid_alert_ratio = if total_alerts > 0 { + (invalid_alerts as f64 / total_alerts as f64) * 100.0 } else { 0.0 }; @@ -200,7 +229,7 @@ pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiM overall_downtime_ms, overall_downtime_formatted, invalid_alert_ratio, - total_firing_alerts, + total_firing_alerts: total_alerts, invalid_alerts, } } diff --git a/apps/web/src/components/monitor/alert-list-item.tsx b/apps/web/src/components/monitor/alert-list-item.tsx index b69370c..c920838 100644 --- a/apps/web/src/components/monitor/alert-list-item.tsx +++ b/apps/web/src/components/monitor/alert-list-item.tsx @@ -1,6 +1,6 @@ import { cva } from "class-variance-authority"; import { format } from "date-fns"; -import { Activity, Link as LinkIcon } from "lucide-react"; +import { AlertTriangle, CheckCircle, Link as LinkIcon } from "lucide-react"; import * as React from "react"; import { Checkbox } from "@/components/ui/checkbox"; @@ -19,19 +19,14 @@ function formatDuration(ms: number): string { return `${seconds}s`; } -function formatTimeRange(start: number, end: number): string { - return `${format(start, "HH:mm:ss")} - ${format(end, "HH:mm:ss")}`; -} - const alertItemVariants = cva( "group relative flex w-full items-center gap-3 border-b border-border p-3 transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", { variants: { status: { - firing: + unresolved: "bg-destructive/5 hover:bg-destructive/10 border-l-2 border-l-destructive", - resolved: "border-l-2 border-l-transparent", - pending: "border-l-2 border-l-yellow-500/50", + resolved: "border-l-2 border-l-green-500/50", }, invalid: { true: "opacity-60 grayscale bg-muted/30", @@ -47,6 +42,7 @@ const alertItemVariants = cva( interface AlertListItemProps { alert: ProcessedAlert; + incidentName?: string; isSelected?: boolean; onToggleInvalid: (id: number, isInvalid: boolean) => void; onSelect: (alert: ProcessedAlert) => void; @@ -56,12 +52,13 @@ interface AlertListItemProps { export const AlertListItem = React.memo( ({ alert, + incidentName, isSelected, onToggleInvalid, onSelect, style, }: AlertListItemProps) => { - const status = alert.isFiring ? "firing" : "resolved"; + const status = alert.isResolved ? "resolved" : "unresolved"; return (
- {alert.alertName || `Unknown Alert #${alert.alertId}`} + {alert.alertName || `Alert #${alert.alertId}`} {alert.durationMs > 0 && ( @@ -106,35 +103,36 @@ export const AlertListItem = React.memo(
- - {alert.newState} - + {alert.isResolved ? ( + + + Resolved + + ) : ( + + + Unresolved + + )} | - {formatTimeRange(alert.time, alert.timeEnd)} + {format(alert.alertTime, "MM/dd HH:mm:ss")} + {alert.resolveTime && ( + <> → {format(alert.resolveTime, "HH:mm:ss")} + )}
- {alert.attachedIncidentId && ( + {incidentName && (
- {alert.attachedIncidentId} + {incidentName}
)} - {alert.isInvalid && ( - - )}
@@ -148,7 +146,7 @@ export const AlertListItem = React.memo(
- {alert.isFiring && !alert.isInvalid && ( + {!alert.isResolved && !alert.isInvalid && (
)}
diff --git a/apps/web/src/components/monitor/alert-list.tsx b/apps/web/src/components/monitor/alert-list.tsx index 6ea93b0..39db343 100644 --- a/apps/web/src/components/monitor/alert-list.tsx +++ b/apps/web/src/components/monitor/alert-list.tsx @@ -1,11 +1,12 @@ import * as React from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { cn } from "@/lib/utils"; -import type { ProcessedAlert } from "@/lib/types/alerts"; +import type { Incident, ProcessedAlert } from "@/lib/types/alerts"; import { AlertListItem } from "./alert-list-item"; interface AlertListProps { alerts: ProcessedAlert[]; + incidents: Incident[]; onToggleInvalid: (alertId: number, isInvalid: boolean) => void; onSelectAlert: (alert: ProcessedAlert) => void; selectedAlertId?: number; @@ -14,6 +15,7 @@ interface AlertListProps { export function AlertList({ alerts, + incidents, onToggleInvalid, onSelectAlert, selectedAlertId, @@ -21,6 +23,14 @@ export function AlertList({ }: AlertListProps) { const parentRef = React.useRef(null); + const incidentMap = React.useMemo(() => { + const map = new Map(); + for (const incident of incidents) { + map.set(incident.id, incident.title); + } + return map; + }, [incidents]); + const getItemKey = React.useCallback( (index: number) => alerts[index]?.id ?? index, [alerts], @@ -54,6 +64,11 @@ export function AlertList({ ; diff --git a/apps/web/src/routes/monitor.tsx b/apps/web/src/routes/monitor.tsx index e155e59..c31ea38 100644 --- a/apps/web/src/routes/monitor.tsx +++ b/apps/web/src/routes/monitor.tsx @@ -72,25 +72,17 @@ function MonitorPage() { } }; - const handleToggleInvalid = async (alertId: number, isInvalid: boolean) => { - try { - const updatedAlerts = await invoke( - "set_alert_invalid", - { - alerts, - alertId, - isInvalid, - }, + const handleToggleInvalid = (alertId: number, isInvalid: boolean) => { + setAlerts((prev) => { + const updated = prev.map((a) => + a.id === alertId ? { ...a, isInvalid } : a, ); - setAlerts(updatedAlerts); - await recalculateKpis(updatedAlerts, incidents); + recalculateKpis(updated, incidents); + return updated; + }); - if (selectedAlert?.id === alertId) { - const updated = updatedAlerts.find((a) => a.id === alertId); - if (updated) setSelectedAlert(updated); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + if (selectedAlert?.id === alertId) { + setSelectedAlert((prev) => (prev ? { ...prev, isInvalid } : null)); } }; @@ -108,50 +100,55 @@ function MonitorPage() { recalculateKpis(alerts, newIncidents); }; - const handleAttachAlert = async (alertId: number, incidentId: string) => { - try { - const [updatedAlerts, updatedIncidents] = await invoke< - [ProcessedAlert[], Incident[]] - >("attach_alert_to_incident", { - alerts, - incidents, - alertId, - incidentId, - }); + const handleAttachAlert = (alertId: number, incidentId: string) => { + const updatedAlerts = alerts.map((a) => + a.id === alertId ? { ...a, attachedIncidentId: incidentId } : a, + ); + const updatedIncidents = incidents.map((i) => + i.id === incidentId && !i.attachedAlertIds.includes(alertId) + ? { ...i, attachedAlertIds: [...i.attachedAlertIds, alertId] } + : i, + ); - setAlerts(updatedAlerts); - setIncidents(updatedIncidents); - await recalculateKpis(updatedAlerts, updatedIncidents); + setAlerts(updatedAlerts); + setIncidents(updatedIncidents); + recalculateKpis(updatedAlerts, updatedIncidents); - if (selectedAlert?.id === alertId) { - const updated = updatedAlerts.find((a) => a.id === alertId); - if (updated) setSelectedAlert(updated); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + if (selectedAlert?.id === alertId) { + setSelectedAlert((prev) => + prev ? { ...prev, attachedIncidentId: incidentId } : null, + ); } }; - const handleDetachAlert = async (alertId: number) => { - try { - const [updatedAlerts, updatedIncidents] = await invoke< - [ProcessedAlert[], Incident[]] - >("detach_alert_from_incident", { - alerts, - incidents, - alertId, - }); + const handleDetachAlert = (alertId: number) => { + const alert = alerts.find((a) => a.id === alertId); + const incidentId = alert?.attachedIncidentId; - setAlerts(updatedAlerts); - setIncidents(updatedIncidents); - await recalculateKpis(updatedAlerts, updatedIncidents); + const updatedAlerts = alerts.map((a) => + a.id === alertId ? { ...a, attachedIncidentId: null } : a, + ); + const updatedIncidents = incidentId + ? incidents.map((i) => + i.id === incidentId + ? { + ...i, + attachedAlertIds: i.attachedAlertIds.filter( + (aid) => aid !== alertId, + ), + } + : i, + ) + : incidents; - if (selectedAlert?.id === alertId) { - const updated = updatedAlerts.find((a) => a.id === alertId); - if (updated) setSelectedAlert(updated); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setAlerts(updatedAlerts); + setIncidents(updatedIncidents); + recalculateKpis(updatedAlerts, updatedIncidents); + + if (selectedAlert?.id === alertId) { + setSelectedAlert((prev) => + prev ? { ...prev, attachedIncidentId: null } : null, + ); } }; @@ -197,6 +194,7 @@ function MonitorPage() { ) : (