import { Card, Group, Table, Text, Accordion, Badge, Tooltip, Box, Stack, Alert, TextInput, Anchor, Pagination, } from "@mantine/core"; import { useSuspenseAPIQuery } from "../api/api"; import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules"; import badgeClasses from "../Badge.module.css"; import panelClasses from "../Panel.module.css"; import RuleDefinition from "../components/RuleDefinition"; import { humanizeDurationRelative, now } from "../lib/formatTime"; import { Fragment, useEffect, useMemo } from "react"; import { StateMultiSelect } from "../components/StateMultiSelect"; import { IconInfoCircle, IconSearch } from "@tabler/icons-react"; import { LabelBadges } from "../components/LabelBadges"; import { useLocalStorage } from "@mantine/hooks"; import { useSettings } from "../state/settingsSlice"; import { ArrayParam, NumberParam, StringParam, useQueryParam, withDefault, } from "use-query-params"; import { useDebouncedValue } from "@mantine/hooks"; import { KVSearch } from "@nexucis/kvsearch"; import { inputIconStyle } from "../styles"; import CustomInfiniteScroll from "../components/CustomInfiniteScroll"; type AlertsPageData = { // How many rules are in each state across all groups. globalCounts: { inactive: number; pending: number; firing: number; }; groups: { name: string; file: string; // How many rules are in each state for this group. counts: { total: number; inactive: number; pending: number; firing: number; }; rules: { rule: AlertingRule; // How many alerts are in each state for this rule. counts: { firing: number; pending: number; }; }[]; }[]; }; const kvSearch = new KVSearch({ shouldSort: true, indexedKeys: ["name", "labels", ["labels", /.*/]], }); const buildAlertsPageData = ( data: AlertingRulesResult, search: string, stateFilter: (string | null)[] ) => { const pageData: AlertsPageData = { globalCounts: { inactive: 0, pending: 0, firing: 0, }, groups: [], }; for (const group of data.groups) { const groupCounts = { total: 0, inactive: 0, pending: 0, firing: 0, }; for (const r of group.rules) { groupCounts.total++; switch (r.state) { case "inactive": pageData.globalCounts.inactive++; groupCounts.inactive++; break; case "firing": pageData.globalCounts.firing++; groupCounts.firing++; break; case "pending": pageData.globalCounts.pending++; groupCounts.pending++; break; default: throw new Error(`Unknown rule state: ${r.state}`); } } const filteredRules: AlertingRule[] = ( search === "" ? group.rules : kvSearch.filter(search, group.rules).map((value) => value.original) ).filter((r) => stateFilter.length === 0 || stateFilter.includes(r.state)); pageData.groups.push({ name: group.name, file: group.file, counts: groupCounts, rules: filteredRules.map((r) => ({ rule: r, counts: { firing: r.alerts.filter((a) => a.state === "firing").length, pending: r.alerts.filter((a) => a.state === "pending").length, }, })), }); } return pageData; }; // Should be defined as a constant here instead of inline as a value // to avoid unnecessary re-renders. Otherwise the empty array has // a different reference on each render and causes subsequent memoized // computations to re-run as long as no state filter is selected. const emptyStateFilter: string[] = []; export default function AlertsPage() { // Fetch the alerting rules data. const { data } = useSuspenseAPIQuery({ path: `/rules`, params: { type: "alert", }, }); const { showAnnotations } = useSettings(); // Define URL query params. const [stateFilter, setStateFilter] = useQueryParam( "state", withDefault(ArrayParam, emptyStateFilter) ); const [searchFilter, setSearchFilter] = useQueryParam( "search", withDefault(StringParam, "") ); const [debouncedSearch] = useDebouncedValue(searchFilter.trim(), 250); const [showEmptyGroups, setShowEmptyGroups] = useLocalStorage({ key: "alertsPage.showEmptyGroups", defaultValue: true, }); const { alertGroupsPerPage } = useSettings(); const [activePage, setActivePage] = useQueryParam( "page", withDefault(NumberParam, 1) ); // Update the page data whenever the fetched data or filters change. const alertsPageData: AlertsPageData = useMemo( () => buildAlertsPageData(data.data, debouncedSearch, stateFilter), [data, stateFilter, debouncedSearch] ); const shownGroups = useMemo( () => showEmptyGroups ? alertsPageData.groups : alertsPageData.groups.filter((g) => g.rules.length > 0), [alertsPageData.groups, showEmptyGroups] ); // If we were e.g. on page 10 and the number of total pages decreases to 5 (due to filtering // or changing the max number of items per page), go to the largest possible page. const totalPageCount = Math.ceil(shownGroups.length / alertGroupsPerPage); const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount)); useEffect(() => { if (effectiveActivePage !== activePage) { setActivePage(effectiveActivePage); } }, [effectiveActivePage, activePage, setActivePage]); const currentPageGroups = useMemo( () => shownGroups.slice( (effectiveActivePage - 1) * alertGroupsPerPage, effectiveActivePage * alertGroupsPerPage ), [shownGroups, effectiveActivePage, alertGroupsPerPage] ); // We memoize the actual rendering of the page items to avoid re-rendering // them on every state change. This is especially important when the user // types into the search box, as the search filter changes on every keystroke, // even before debouncing takes place (extracting the filters and results list // into separate components would be an alternative to this, but it's kinda // convenient to have in the same file IMO). const renderedPageItems = useMemo( () => currentPageGroups.map((g) => ( {g.name} {g.file} {g.counts.firing > 0 && ( firing ({g.counts.firing}) )} {g.counts.pending > 0 && ( pending ({g.counts.pending}) )} {g.counts.inactive > 0 && ( inactive ({g.counts.inactive}) )} {g.counts.total === 0 ? ( }> No rules in this group. setShowEmptyGroups(false)} > Hide empty groups ) : g.rules.length === 0 ? ( }> No rules in this group match your filter criteria (omitted{" "} {g.counts.total} filtered rules). setShowEmptyGroups(false)} > Hide empty groups ) : ( ( {items.map((r, j) => { return ( 0 ? panelClasses.panelHealthErr : r.counts.pending > 0 ? panelClasses.panelHealthWarn : panelClasses.panelHealthOk } > {r.rule.name} {r.counts.firing > 0 && ( firing ({r.counts.firing}) )} {r.counts.pending > 0 && ( pending ({r.counts.pending}) )} {r.rule.alerts.length > 0 && ( Alert labels State Active Since Value {r.rule.type === "alerting" && r.rule.alerts.map((a, k) => ( {a.state} {humanizeDurationRelative( a.activeAt, now(), "" )} {isNaN(Number(a.value)) ? a.value : Number(a.value)} {showAnnotations && (
{Object.entries( a.annotations ).map(([k, v]) => ( {k} {v} ))}
)} ))} )}
); })}
)} /> )}
)), [currentPageGroups, showAnnotations, setShowEmptyGroups] ); return ( o === "inactive" ? badgeClasses.healthOk : o === "pending" ? badgeClasses.healthWarn : badgeClasses.healthErr } optionCount={(o) => alertsPageData.globalCounts[ o as keyof typeof alertsPageData.globalCounts ] } placeholder="Filter by rule state" values={(stateFilter?.filter((v) => v !== null) as string[]) || []} onChange={(values) => setStateFilter(values)} /> } placeholder="Filter by rule name or labels" value={searchFilter || ""} onChange={(event) => setSearchFilter(event.currentTarget.value || null) } > {alertsPageData.groups.length === 0 ? ( }> No rules found. ) : ( !showEmptyGroups && alertsPageData.groups.length !== shownGroups.length && ( } > Hiding {alertsPageData.groups.length - shownGroups.length} empty groups due to filters or no rules. setShowEmptyGroups(true)}> Show empty groups ) )} {renderedPageItems} ); }