mirror of
https://github.com/LittleQuartZ/addmon.git
synced 2026-02-07 02:45:28 +07:00
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:
parent
ea1c9105a7
commit
22aee93fb3
6 changed files with 165 additions and 129 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue