diff --git a/apps/web/src-tauri/src/lib.rs b/apps/web/src-tauri/src/lib.rs index 456ab6f..b5f2604 100644 --- a/apps/web/src-tauri/src/lib.rs +++ b/apps/web/src-tauri/src/lib.rs @@ -106,6 +106,31 @@ pub struct ProcessedAlert { pub is_invalid: bool, pub attached_incident_id: Option, pub values: HashMap, + pub labels: HashMap, +} + +fn parse_labels_from_text(text: &str) -> HashMap { + let mut labels = HashMap::new(); + + if let Some(start) = text.find('{') { + if let Some(end) = text.rfind('}') { + if start < end { + let content = &text[start + 1..end]; + for pair in content.split(',') { + let pair = pair.trim(); + if let Some(eq_pos) = pair.find('=') { + let key = pair[..eq_pos].trim().to_string(); + let value = pair[eq_pos + 1..].trim().to_string(); + if !key.is_empty() { + labels.insert(key, value); + } + } + } + } + } + } + + labels } pub fn pair_alerts(raw_alerts: Vec) -> Vec { @@ -126,6 +151,8 @@ pub fn pair_alerts(raw_alerts: Vec) -> Vec { let current = &alerts[i]; if current.new_state == "Alerting" { + let labels = parse_labels_from_text(¤t.text); + 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); @@ -142,6 +169,7 @@ pub fn pair_alerts(raw_alerts: Vec) -> Vec { is_invalid: false, attached_incident_id: None, values: current.data.clone().map(|d| d.values).unwrap_or_default(), + labels, }); i += 2; } else { @@ -157,6 +185,7 @@ pub fn pair_alerts(raw_alerts: Vec) -> Vec { is_invalid: false, attached_incident_id: None, values: current.data.clone().map(|d| d.values).unwrap_or_default(), + labels, }); i += 1; } @@ -208,7 +237,7 @@ pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiM let overall_downtime_ms: u64 = alerts .iter() - .filter(|a| !a.is_invalid) + .filter(|a| !a.is_invalid && a.attached_incident_id.is_some()) .map(|a| a.duration_ms) .sum(); diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index ec22338..172885b 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -3,7 +3,7 @@ import { Link } from "@tanstack/react-router"; import { ModeToggle } from "./mode-toggle"; export default function Header() { - const links = [{ to: "/", label: "Home" }] as const; + const links = [{ to: "/", label: "Home" }, { to: '/monitor', label: 'Monitor' }] as const; return (
diff --git a/apps/web/src/components/monitor/alert-list-item.tsx b/apps/web/src/components/monitor/alert-list-item.tsx index c920838..acc9a24 100644 --- a/apps/web/src/components/monitor/alert-list-item.tsx +++ b/apps/web/src/components/monitor/alert-list-item.tsx @@ -137,11 +137,9 @@ export const AlertListItem = React.memo(
- {Object.entries(alert.values) - .map( - ([k, v]) => - `${k}=${Number.isFinite(v) ? v.toFixed(1) : String(v)}`, - ) + {Object.entries(alert.labels) + .filter(([k]) => k !== "alertname") + .map(([k, v]) => `${k}=${v}`) .join(", ")}
diff --git a/apps/web/src/components/monitor/kpi-dashboard.tsx b/apps/web/src/components/monitor/kpi-dashboard.tsx index e12d709..28da693 100644 --- a/apps/web/src/components/monitor/kpi-dashboard.tsx +++ b/apps/web/src/components/monitor/kpi-dashboard.tsx @@ -1,7 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; import type { KpiMetrics } from "@/lib/types/alerts"; +import { cn } from "@/lib/utils"; interface KpiDashboardProps { kpis: KpiMetrics | null; @@ -51,7 +51,7 @@ export function KpiDashboard({ kpis, isLoading = false }: KpiDashboardProps) { if (!kpis) { return ( - + No KPI data available diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index af3f70a..6217712 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -1,14 +1,19 @@ import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, MinusIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { +function Checkbox({ + className, + indeterminate, + ...props +}: CheckboxPrimitive.Root.Props & { indeterminate?: boolean }) { return ( - + {indeterminate ? : } ); diff --git a/apps/web/src/lib/types/alerts.ts b/apps/web/src/lib/types/alerts.ts index 24623fa..0c923ab 100644 --- a/apps/web/src/lib/types/alerts.ts +++ b/apps/web/src/lib/types/alerts.ts @@ -10,6 +10,7 @@ export interface ProcessedAlert { isInvalid: boolean; attachedIncidentId: string | null; values: Record; + labels: Record; } export interface Incident { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index b737b65..4449f24 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ component: HomeComponent, @@ -29,9 +29,6 @@ function HomeComponent() {

API Status

- - Go to Monitor Page - ); } diff --git a/apps/web/src/routes/monitor.tsx b/apps/web/src/routes/monitor.tsx index c31ea38..7cb7a20 100644 --- a/apps/web/src/routes/monitor.tsx +++ b/apps/web/src/routes/monitor.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { readTextFile } from "@tauri-apps/plugin-fs"; -import { FileUp } from "lucide-react"; +import { FileUp, Filter, Search } from "lucide-react"; import * as React from "react"; import { AlertList } from "@/components/monitor/alert-list"; @@ -10,8 +10,19 @@ import { IncidentManager } from "@/components/monitor/incident-manager"; import { KpiDashboard } from "@/components/monitor/kpi-dashboard"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import type { Incident, KpiMetrics, ProcessedAlert } from "@/lib/types/alerts"; +type StatusFilter = "all" | "resolved" | "unresolved"; + export const Route = createFileRoute("/monitor")({ component: MonitorPage, }); @@ -24,6 +35,34 @@ function MonitorPage() { React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [searchQuery, setSearchQuery] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("all"); + + const filteredAlerts = React.useMemo(() => { + return alerts.filter((alert) => { + if (statusFilter === "resolved" && !alert.isResolved) return false; + if (statusFilter === "unresolved" && alert.isResolved) return false; + + if (!searchQuery.trim()) return true; + + const query = searchQuery.toLowerCase(); + if (alert.alertName?.toLowerCase().includes(query)) return true; + + for (const value of Object.values(alert.labels)) { + if (value.toLowerCase().includes(query)) return true; + } + + return false; + }); + }, [alerts, searchQuery, statusFilter]); + + const selectAllState = React.useMemo(() => { + if (filteredAlerts.length === 0) return "none" as const; + const invalidCount = filteredAlerts.filter((a) => a.isInvalid).length; + if (invalidCount === 0) return "none" as const; + if (invalidCount === filteredAlerts.length) return "all" as const; + return "some" as const; + }, [filteredAlerts]); const recalculateKpis = React.useCallback( async (currentAlerts: ProcessedAlert[], currentIncidents: Incident[]) => { @@ -86,6 +125,21 @@ function MonitorPage() { } }; + const handleToggleAllInvalid = (isInvalid: boolean) => { + const filteredIds = new Set(filteredAlerts.map((a) => a.id)); + setAlerts((prev) => { + const updated = prev.map((a) => + filteredIds.has(a.id) ? { ...a, isInvalid } : a, + ); + recalculateKpis(updated, incidents); + return updated; + }); + + if (selectedAlert && filteredIds.has(selectedAlert.id)) { + setSelectedAlert((prev) => (prev ? { ...prev, isInvalid } : null)); + } + }; + const handleSelectAlert = (alert: ProcessedAlert) => { setSelectedAlert(alert); }; @@ -174,10 +228,60 @@ function MonitorPage() {
-
+
+ + handleToggleAllInvalid(checked === true) + } + disabled={filteredAlerts.length === 0} + aria-label="Mark all filtered as invalid" + /> - Alerts ({alerts.length}) + {filteredAlerts.length} + {(searchQuery || statusFilter !== "all") && ` / ${alerts.length}`} + + + + + {statusFilter === "all" + ? "All" + : statusFilter === "resolved" + ? "Resolved" + : "Unresolved"} + + + } + /> + + setStatusFilter(v as StatusFilter)} + > + All + + Resolved + + + Unresolved + + + + +
+ + setSearchQuery(e.target.value)} + className="h-7 pl-7 text-xs" + /> +
{alerts.length === 0 ? (
@@ -193,7 +297,7 @@ function MonitorPage() {
) : (