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;
#[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)
Ok(pair_alerts(raw_alerts))
}
#[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)
Ok(pair_alerts(raw_alerts))
}
#[tauri::command]

View file

@ -98,44 +98,76 @@ pub struct ProcessedAlert {
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 alert_time: u64,
pub resolve_time: Option<u64>,
pub text: String,
pub duration_ms: u64,
pub is_firing: bool,
pub is_resolved: 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
};
pub fn pair_alerts(raw_alerts: Vec<GrafanaAlert>) -> Vec<ProcessedAlert> {
use std::collections::BTreeMap;
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 {
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,
}
}

View file

@ -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 (
<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 items-center justify-between gap-2">
<span className="truncate text-xs font-semibold text-foreground">
{alert.alertName || `Unknown Alert #${alert.alertId}`}
{alert.alertName || `Alert #${alert.alertId}`}
</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] font-mono text-muted-foreground">
{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 gap-2">
<span
className={cn(
"uppercase tracking-wider",
alert.isFiring
? "text-destructive font-bold"
: "text-green-600 dark:text-green-500",
)}
>
{alert.newState}
</span>
{alert.isResolved ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-500">
<CheckCircle className="size-3" />
Resolved
</span>
) : (
<span className="flex items-center gap-1 text-destructive font-bold">
<AlertTriangle className="size-3" />
Unresolved
</span>
)}
<span className="text-border">|</span>
<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>
</div>
<div className="flex items-center gap-1.5">
{alert.attachedIncidentId && (
{incidentName && (
<div
className="flex items-center gap-0.5 text-primary"
title="Attached to incident"
title={`Attached to: ${incidentName}`}
>
<LinkIcon className="size-3" />
<span className="font-mono">{alert.attachedIncidentId}</span>
<span className="max-w-24 truncate">{incidentName}</span>
</div>
)}
{alert.isInvalid && (
<Activity className="size-3 text-muted-foreground/50" />
)}
</div>
</div>
@ -148,7 +146,7 @@ export const AlertListItem = React.memo(
</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>

View file

@ -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<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(
(index: number) => alerts[index]?.id ?? index,
[alerts],
@ -54,6 +64,11 @@ export function AlertList({
<AlertListItem
key={virtualItem.key}
alert={alert}
incidentName={
alert.attachedIncidentId
? incidentMap.get(alert.attachedIncidentId)
: undefined
}
isSelected={selectedAlertId === alert.id}
onToggleInvalid={onToggleInvalid}
onSelect={onSelectAlert}

View file

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

View file

@ -72,25 +72,17 @@ function MonitorPage() {
}
};
const handleToggleInvalid = async (alertId: number, isInvalid: boolean) => {
try {
const updatedAlerts = await invoke<ProcessedAlert[]>(
"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() {
) : (
<AlertList
alerts={alerts}
incidents={incidents}
onToggleInvalid={handleToggleInvalid}
onSelectAlert={handleSelectAlert}
selectedAlertId={selectedAlert?.id}