feat(monitor): add alert pairing logic and display incident names

- 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)
This commit is contained in:
Syahdan 2025-12-29 06:07:13 +07:00
parent ea1c9105a7
commit 22aee93fb3
6 changed files with 165 additions and 129 deletions

View file

@ -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; use std::fs;
#[tauri::command] #[tauri::command]
pub async fn load_alerts_from_file(path: String) -> Result<Vec<ProcessedAlert>, AppError> { pub async fn load_alerts_from_file(path: String) -> Result<Vec<ProcessedAlert>, AppError> {
let contents = fs::read_to_string(&path)?; let contents = fs::read_to_string(&path)?;
let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&contents)?; let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&contents)?;
let processed: Vec<ProcessedAlert> = raw_alerts.into_iter().map(ProcessedAlert::from).collect(); Ok(pair_alerts(raw_alerts))
Ok(processed)
} }
#[tauri::command] #[tauri::command]
pub fn process_alerts_json(json_content: String) -> Result<Vec<ProcessedAlert>, AppError> { pub fn process_alerts_json(json_content: String) -> Result<Vec<ProcessedAlert>, AppError> {
let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&json_content)?; let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&json_content)?;
let processed: Vec<ProcessedAlert> = raw_alerts.into_iter().map(ProcessedAlert::from).collect(); Ok(pair_alerts(raw_alerts))
Ok(processed)
} }
#[tauri::command] #[tauri::command]

View file

@ -98,44 +98,76 @@ pub struct ProcessedAlert {
pub alert_id: u32, pub alert_id: u32,
#[serde(default)] #[serde(default)]
pub alert_name: Option<String>, pub alert_name: Option<String>,
pub new_state: String, pub alert_time: u64,
pub prev_state: String, pub resolve_time: Option<u64>,
pub time: u64,
pub time_end: u64,
pub text: String, pub text: String,
pub duration_ms: u64, pub duration_ms: u64,
pub is_firing: bool, pub is_resolved: bool,
pub is_invalid: bool, pub is_invalid: bool,
pub attached_incident_id: Option<String>, pub attached_incident_id: Option<String>,
pub values: HashMap<String, f64>, pub values: HashMap<String, f64>,
} }
impl From<GrafanaAlert> for ProcessedAlert { pub fn pair_alerts(raw_alerts: Vec<GrafanaAlert>) -> Vec<ProcessedAlert> {
fn from(alert: GrafanaAlert) -> Self { use std::collections::BTreeMap;
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"; let mut by_alert_id: BTreeMap<u32, Vec<GrafanaAlert>> = BTreeMap::new();
for alert in raw_alerts {
by_alert_id.entry(alert.alert_id).or_default().push(alert);
}
ProcessedAlert { let mut result = Vec::new();
id: alert.id,
alert_id: alert.alert_id, for (_alert_id, mut alerts) in by_alert_id {
alert_name: alert.alert_name, alerts.sort_by_key(|a| a.time);
new_state: alert.new_state,
prev_state: alert.prev_state, let mut i = 0;
time: alert.time, while i < alerts.len() {
time_end: alert.time_end, let current = &alerts[i];
text: alert.text,
duration_ms, if current.new_state == "Alerting" {
is_firing, if i + 1 < alerts.len() && alerts[i + 1].new_state == "Normal" {
is_invalid: false, let resolve = &alerts[i + 1];
attached_incident_id: None, let duration_ms = resolve.time.saturating_sub(current.time);
values: alert.data.map(|d| d.values).unwrap_or_default(),
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -176,19 +208,16 @@ pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiM
let overall_downtime_ms: u64 = alerts let overall_downtime_ms: u64 = alerts
.iter() .iter()
.filter(|a| a.is_firing && !a.is_invalid) .filter(|a| !a.is_invalid)
.map(|a| a.duration_ms) .map(|a| a.duration_ms)
.sum(); .sum();
let overall_downtime_formatted = format_duration(overall_downtime_ms); let overall_downtime_formatted = format_duration(overall_downtime_ms);
let total_firing_alerts = alerts.iter().filter(|a| a.is_firing).count(); let total_alerts = alerts.len();
let invalid_alerts = alerts let invalid_alerts = alerts.iter().filter(|a| a.is_invalid).count();
.iter() let invalid_alert_ratio = if total_alerts > 0 {
.filter(|a| a.is_firing && a.is_invalid) (invalid_alerts as f64 / total_alerts as f64) * 100.0
.count();
let invalid_alert_ratio = if total_firing_alerts > 0 {
(invalid_alerts as f64 / total_firing_alerts as f64) * 100.0
} else { } else {
0.0 0.0
}; };
@ -200,7 +229,7 @@ pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiM
overall_downtime_ms, overall_downtime_ms,
overall_downtime_formatted, overall_downtime_formatted,
invalid_alert_ratio, invalid_alert_ratio,
total_firing_alerts, total_firing_alerts: total_alerts,
invalid_alerts, invalid_alerts,
} }
} }

View file

@ -1,6 +1,6 @@
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { format } from "date-fns"; 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 * as React from "react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@ -19,19 +19,14 @@ function formatDuration(ms: number): string {
return `${seconds}s`; return `${seconds}s`;
} }
function formatTimeRange(start: number, end: number): string {
return `${format(start, "HH:mm:ss")} - ${format(end, "HH:mm:ss")}`;
}
const alertItemVariants = cva( 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", "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: { variants: {
status: { status: {
firing: unresolved:
"bg-destructive/5 hover:bg-destructive/10 border-l-2 border-l-destructive", "bg-destructive/5 hover:bg-destructive/10 border-l-2 border-l-destructive",
resolved: "border-l-2 border-l-transparent", resolved: "border-l-2 border-l-green-500/50",
pending: "border-l-2 border-l-yellow-500/50",
}, },
invalid: { invalid: {
true: "opacity-60 grayscale bg-muted/30", true: "opacity-60 grayscale bg-muted/30",
@ -47,6 +42,7 @@ const alertItemVariants = cva(
interface AlertListItemProps { interface AlertListItemProps {
alert: ProcessedAlert; alert: ProcessedAlert;
incidentName?: string;
isSelected?: boolean; isSelected?: boolean;
onToggleInvalid: (id: number, isInvalid: boolean) => void; onToggleInvalid: (id: number, isInvalid: boolean) => void;
onSelect: (alert: ProcessedAlert) => void; onSelect: (alert: ProcessedAlert) => void;
@ -56,12 +52,13 @@ interface AlertListItemProps {
export const AlertListItem = React.memo( export const AlertListItem = React.memo(
({ ({
alert, alert,
incidentName,
isSelected, isSelected,
onToggleInvalid, onToggleInvalid,
onSelect, onSelect,
style, style,
}: AlertListItemProps) => { }: AlertListItemProps) => {
const status = alert.isFiring ? "firing" : "resolved"; const status = alert.isResolved ? "resolved" : "unresolved";
return ( return (
<div <div
@ -95,7 +92,7 @@ export const AlertListItem = React.memo(
<div className="flex min-w-0 flex-1 flex-col gap-1"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-semibold text-foreground"> <span className="truncate text-xs font-semibold text-foreground">
{alert.alertName || `Unknown Alert #${alert.alertId}`} {alert.alertName || `Alert #${alert.alertId}`}
</span> </span>
<span className="flex shrink-0 items-center gap-1 text-[10px] font-mono text-muted-foreground"> <span className="flex shrink-0 items-center gap-1 text-[10px] font-mono text-muted-foreground">
{alert.durationMs > 0 && ( {alert.durationMs > 0 && (
@ -106,35 +103,36 @@ export const AlertListItem = React.memo(
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground"> <div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span {alert.isResolved ? (
className={cn( <span className="flex items-center gap-1 text-green-600 dark:text-green-500">
"uppercase tracking-wider", <CheckCircle className="size-3" />
alert.isFiring Resolved
? "text-destructive font-bold" </span>
: "text-green-600 dark:text-green-500", ) : (
)} <span className="flex items-center gap-1 text-destructive font-bold">
> <AlertTriangle className="size-3" />
{alert.newState} Unresolved
</span> </span>
)}
<span className="text-border">|</span> <span className="text-border">|</span>
<span className="font-mono"> <span className="font-mono">
{formatTimeRange(alert.time, alert.timeEnd)} {format(alert.alertTime, "MM/dd HH:mm:ss")}
{alert.resolveTime && (
<> {format(alert.resolveTime, "HH:mm:ss")}</>
)}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{alert.attachedIncidentId && ( {incidentName && (
<div <div
className="flex items-center gap-0.5 text-primary" className="flex items-center gap-0.5 text-primary"
title="Attached to incident" title={`Attached to: ${incidentName}`}
> >
<LinkIcon className="size-3" /> <LinkIcon className="size-3" />
<span className="font-mono">{alert.attachedIncidentId}</span> <span className="max-w-24 truncate">{incidentName}</span>
</div> </div>
)} )}
{alert.isInvalid && (
<Activity className="size-3 text-muted-foreground/50" />
)}
</div> </div>
</div> </div>
@ -148,7 +146,7 @@ export const AlertListItem = React.memo(
</div> </div>
</div> </div>
{alert.isFiring && !alert.isInvalid && ( {!alert.isResolved && !alert.isInvalid && (
<div className="absolute right-0 top-0 h-full w-0.5 bg-destructive" /> <div className="absolute right-0 top-0 h-full w-0.5 bg-destructive" />
)} )}
</div> </div>

View file

@ -1,11 +1,12 @@
import * as React from "react"; import * as React from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils"; 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"; import { AlertListItem } from "./alert-list-item";
interface AlertListProps { interface AlertListProps {
alerts: ProcessedAlert[]; alerts: ProcessedAlert[];
incidents: Incident[];
onToggleInvalid: (alertId: number, isInvalid: boolean) => void; onToggleInvalid: (alertId: number, isInvalid: boolean) => void;
onSelectAlert: (alert: ProcessedAlert) => void; onSelectAlert: (alert: ProcessedAlert) => void;
selectedAlertId?: number; selectedAlertId?: number;
@ -14,6 +15,7 @@ interface AlertListProps {
export function AlertList({ export function AlertList({
alerts, alerts,
incidents,
onToggleInvalid, onToggleInvalid,
onSelectAlert, onSelectAlert,
selectedAlertId, selectedAlertId,
@ -21,6 +23,14 @@ export function AlertList({
}: AlertListProps) { }: AlertListProps) {
const parentRef = React.useRef<HTMLDivElement>(null); const parentRef = React.useRef<HTMLDivElement>(null);
const incidentMap = React.useMemo(() => {
const map = new Map<string, string>();
for (const incident of incidents) {
map.set(incident.id, incident.title);
}
return map;
}, [incidents]);
const getItemKey = React.useCallback( const getItemKey = React.useCallback(
(index: number) => alerts[index]?.id ?? index, (index: number) => alerts[index]?.id ?? index,
[alerts], [alerts],
@ -54,6 +64,11 @@ export function AlertList({
<AlertListItem <AlertListItem
key={virtualItem.key} key={virtualItem.key}
alert={alert} alert={alert}
incidentName={
alert.attachedIncidentId
? incidentMap.get(alert.attachedIncidentId)
: undefined
}
isSelected={selectedAlertId === alert.id} isSelected={selectedAlertId === alert.id}
onToggleInvalid={onToggleInvalid} onToggleInvalid={onToggleInvalid}
onSelect={onSelectAlert} onSelect={onSelectAlert}

View file

@ -2,13 +2,11 @@ export interface ProcessedAlert {
id: number; id: number;
alertId: number; alertId: number;
alertName?: string; alertName?: string;
newState: string; alertTime: number;
prevState: string; resolveTime: number | null;
time: number;
timeEnd: number;
text: string; text: string;
durationMs: number; durationMs: number;
isFiring: boolean; isResolved: boolean;
isInvalid: boolean; isInvalid: boolean;
attachedIncidentId: string | null; attachedIncidentId: string | null;
values: Record<string, number>; values: Record<string, number>;

View file

@ -72,25 +72,17 @@ function MonitorPage() {
} }
}; };
const handleToggleInvalid = async (alertId: number, isInvalid: boolean) => { const handleToggleInvalid = (alertId: number, isInvalid: boolean) => {
try { setAlerts((prev) => {
const updatedAlerts = await invoke<ProcessedAlert[]>( const updated = prev.map((a) =>
"set_alert_invalid", a.id === alertId ? { ...a, isInvalid } : a,
{
alerts,
alertId,
isInvalid,
},
); );
setAlerts(updatedAlerts); recalculateKpis(updated, incidents);
await recalculateKpis(updatedAlerts, incidents); return updated;
});
if (selectedAlert?.id === alertId) { if (selectedAlert?.id === alertId) {
const updated = updatedAlerts.find((a) => a.id === alertId); setSelectedAlert((prev) => (prev ? { ...prev, isInvalid } : null));
if (updated) setSelectedAlert(updated);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} }
}; };
@ -108,50 +100,55 @@ function MonitorPage() {
recalculateKpis(alerts, newIncidents); recalculateKpis(alerts, newIncidents);
}; };
const handleAttachAlert = async (alertId: number, incidentId: string) => { const handleAttachAlert = (alertId: number, incidentId: string) => {
try { const updatedAlerts = alerts.map((a) =>
const [updatedAlerts, updatedIncidents] = await invoke< a.id === alertId ? { ...a, attachedIncidentId: incidentId } : a,
[ProcessedAlert[], Incident[]] );
>("attach_alert_to_incident", { const updatedIncidents = incidents.map((i) =>
alerts, i.id === incidentId && !i.attachedAlertIds.includes(alertId)
incidents, ? { ...i, attachedAlertIds: [...i.attachedAlertIds, alertId] }
alertId, : i,
incidentId, );
});
setAlerts(updatedAlerts); setAlerts(updatedAlerts);
setIncidents(updatedIncidents); setIncidents(updatedIncidents);
await recalculateKpis(updatedAlerts, updatedIncidents); recalculateKpis(updatedAlerts, updatedIncidents);
if (selectedAlert?.id === alertId) { if (selectedAlert?.id === alertId) {
const updated = updatedAlerts.find((a) => a.id === alertId); setSelectedAlert((prev) =>
if (updated) setSelectedAlert(updated); prev ? { ...prev, attachedIncidentId: incidentId } : null,
} );
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} }
}; };
const handleDetachAlert = async (alertId: number) => { const handleDetachAlert = (alertId: number) => {
try { const alert = alerts.find((a) => a.id === alertId);
const [updatedAlerts, updatedIncidents] = await invoke< const incidentId = alert?.attachedIncidentId;
[ProcessedAlert[], Incident[]]
>("detach_alert_from_incident", {
alerts,
incidents,
alertId,
});
setAlerts(updatedAlerts); const updatedAlerts = alerts.map((a) =>
setIncidents(updatedIncidents); a.id === alertId ? { ...a, attachedIncidentId: null } : a,
await recalculateKpis(updatedAlerts, updatedIncidents); );
const updatedIncidents = incidentId
? incidents.map((i) =>
i.id === incidentId
? {
...i,
attachedAlertIds: i.attachedAlertIds.filter(
(aid) => aid !== alertId,
),
}
: i,
)
: incidents;
if (selectedAlert?.id === alertId) { setAlerts(updatedAlerts);
const updated = updatedAlerts.find((a) => a.id === alertId); setIncidents(updatedIncidents);
if (updated) setSelectedAlert(updated); recalculateKpis(updatedAlerts, updatedIncidents);
}
} catch (err) { if (selectedAlert?.id === alertId) {
setError(err instanceof Error ? err.message : String(err)); setSelectedAlert((prev) =>
prev ? { ...prev, attachedIncidentId: null } : null,
);
} }
}; };
@ -197,6 +194,7 @@ function MonitorPage() {
) : ( ) : (
<AlertList <AlertList
alerts={alerts} alerts={alerts}
incidents={incidents}
onToggleInvalid={handleToggleInvalid} onToggleInvalid={handleToggleInvalid}
onSelectAlert={handleSelectAlert} onSelectAlert={handleSelectAlert}
selectedAlertId={selectedAlert?.id} selectedAlertId={selectedAlert?.id}