diff --git a/web/ui/mantine-ui/src/components/SettingsMenu.tsx b/web/ui/mantine-ui/src/components/SettingsMenu.tsx index ada252d57..aae38909d 100644 --- a/web/ui/mantine-ui/src/components/SettingsMenu.tsx +++ b/web/ui/mantine-ui/src/components/SettingsMenu.tsx @@ -1,4 +1,12 @@ -import { Popover, ActionIcon, Fieldset, Checkbox, Stack } from "@mantine/core"; +import { + Popover, + ActionIcon, + Fieldset, + Checkbox, + Stack, + Group, + NumberInput, +} from "@mantine/core"; import { IconSettings } from "@tabler/icons-react"; import { FC } from "react"; import { useAppDispatch } from "../state/hooks"; @@ -13,6 +21,8 @@ const SettingsMenu: FC = () => { enableSyntaxHighlighting, enableLinter, showAnnotations, + ruleGroupsPerPage, + alertGroupsPerPage, } = useSettings(); const dispatch = useAppDispatch(); @@ -29,82 +39,126 @@ const SettingsMenu: FC = () => { - -
- - dispatch( - updateSettings({ useLocalTime: event.currentTarget.checked }) - ) - } - /> -
+ + +
+ + dispatch( + updateSettings({ + useLocalTime: event.currentTarget.checked, + }) + ) + } + /> +
-
- - - dispatch( - updateSettings({ - enableQueryHistory: event.currentTarget.checked, - }) - ) - } - /> - - dispatch( - updateSettings({ - enableAutocomplete: event.currentTarget.checked, - }) - ) - } - /> - - dispatch( - updateSettings({ - enableSyntaxHighlighting: event.currentTarget.checked, - }) - ) - } - /> - - dispatch( - updateSettings({ - enableLinter: event.currentTarget.checked, - }) - ) - } - /> - -
+
+ + + dispatch( + updateSettings({ + enableQueryHistory: event.currentTarget.checked, + }) + ) + } + /> + + dispatch( + updateSettings({ + enableAutocomplete: event.currentTarget.checked, + }) + ) + } + /> + + dispatch( + updateSettings({ + enableSyntaxHighlighting: event.currentTarget.checked, + }) + ) + } + /> + + dispatch( + updateSettings({ + enableLinter: event.currentTarget.checked, + }) + ) + } + /> + +
+
-
- - dispatch( - updateSettings({ - showAnnotations: event.currentTarget.checked, - }) - ) - } - /> -
-
+ +
+ + dispatch( + updateSettings({ + showAnnotations: event.currentTarget.checked, + }) + ) + } + /> +
+
+ { + if (typeof value !== "number") { + return; + } + + dispatch( + updateSettings({ + alertGroupsPerPage: value, + }) + ); + }} + /> +
+
+ { + if (typeof value !== "number") { + return; + } + + dispatch( + updateSettings({ + ruleGroupsPerPage: value, + }) + ); + }} + /> +
+
+
); diff --git a/web/ui/mantine-ui/src/pages/AlertsPage.tsx b/web/ui/mantine-ui/src/pages/AlertsPage.tsx index 1513f73d5..3143f0b41 100644 --- a/web/ui/mantine-ui/src/pages/AlertsPage.tsx +++ b/web/ui/mantine-ui/src/pages/AlertsPage.tsx @@ -11,6 +11,7 @@ import { Alert, TextInput, Anchor, + Pagination, } from "@mantine/core"; import { useSuspenseAPIQuery } from "../api/api"; import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules"; @@ -18,7 +19,7 @@ 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, useMemo } from "react"; +import { Fragment, useEffect, useMemo } from "react"; import { StateMultiSelect } from "../components/StateMultiSelect"; import { IconInfoCircle, IconSearch } from "@tabler/icons-react"; import { LabelBadges } from "../components/LabelBadges"; @@ -26,6 +27,7 @@ import { useSettings } from "../state/settingsSlice"; import { ArrayParam, BooleanParam, + NumberParam, StringParam, useQueryParam, withDefault, @@ -33,6 +35,7 @@ import { 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. @@ -132,6 +135,12 @@ const buildAlertsPageData = ( 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({ @@ -146,7 +155,7 @@ export default function AlertsPage() { // Define URL query params. const [stateFilter, setStateFilter] = useQueryParam( "state", - withDefault(ArrayParam, []) + withDefault(ArrayParam, emptyStateFilter) ); const [searchFilter, setSearchFilter] = useQueryParam( "search", @@ -158,132 +167,117 @@ export default function AlertsPage() { withDefault(BooleanParam, 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 = showEmptyGroups - ? alertsPageData.groups - : alertsPageData.groups.filter((g) => g.rules.length > 0); + const shownGroups = useMemo( + () => + showEmptyGroups + ? alertsPageData.groups + : alertsPageData.groups.filter((g) => g.rules.length > 0), + [alertsPageData.groups, showEmptyGroups] + ); - 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 - - - ) - )} - - {shownGroups.map((g, i) => { - return ( - - - - - {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 - - - ) : ( + // 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, i) => ( + + + + + {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 + + + ) : ( + ( - {g.rules.map((r, j) => { + {items.map((r, j) => { return ( 0 && ( - + Alert labels State Active Since @@ -405,9 +399,71 @@ export default function AlertsPage() { })} )} - - ); - })} + /> + )} + + )), + [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} ); diff --git a/web/ui/mantine-ui/src/pages/RulesPage.tsx b/web/ui/mantine-ui/src/pages/RulesPage.tsx index a4ed44e7c..0888fe473 100644 --- a/web/ui/mantine-ui/src/pages/RulesPage.tsx +++ b/web/ui/mantine-ui/src/pages/RulesPage.tsx @@ -4,6 +4,7 @@ import { Badge, Card, Group, + Pagination, rem, Stack, Text, @@ -29,6 +30,10 @@ import { RulesResult } from "../api/responseTypes/rules"; import badgeClasses from "../Badge.module.css"; import RuleDefinition from "../components/RuleDefinition"; import { badgeIconStyle } from "../styles"; +import { NumberParam, useQueryParam, withDefault } from "use-query-params"; +import { useSettings } from "../state/settingsSlice"; +import { useEffect } from "react"; +import CustomInfiniteScroll from "../components/CustomInfiniteScroll"; const healthBadgeClass = (state: string) => { switch (state) { @@ -45,6 +50,23 @@ const healthBadgeClass = (state: string) => { export default function RulesPage() { const { data } = useSuspenseAPIQuery({ path: `/rules` }); + const { ruleGroupsPerPage } = useSettings(); + + const [activePage, setActivePage] = useQueryParam( + "page", + withDefault(NumberParam, 1) + ); + + // If we were e.g. on page 10 and the number of total pages decreases to 5 (due + // changing the max number of items per page), go to the largest possible page. + const totalPageCount = Math.ceil(data.data.groups.length / ruleGroupsPerPage); + const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount)); + + useEffect(() => { + if (effectiveActivePage !== activePage) { + setActivePage(effectiveActivePage); + } + }, [effectiveActivePage, activePage, setActivePage]); return ( @@ -53,157 +75,178 @@ export default function RulesPage() { No rule groups configured. )} - {data.data.groups.map((g, i) => ( - - - - - {g.name} - - - {g.file} - + + {data.data.groups + .slice( + (effectiveActivePage - 1) * ruleGroupsPerPage, + effectiveActivePage * ruleGroupsPerPage + ) + .map((g, i) => ( + + + + + {g.name} + + + {g.file} + + + + + } + > + last run {humanizeDurationRelative(g.lastEvaluation, now())} + + + + } + > + took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)} + + + + } + > + every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "} + + + - - - } - > - last run {humanizeDurationRelative(g.lastEvaluation, now())} - - - - } - > - took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)} - - - - } - > - every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "} - - - - - {g.rules.length === 0 && ( - }> - No rules in rule group. - - )} - - {g.rules.map((r, j) => ( - - - - - {r.type === "alerting" ? ( - - - - ) : ( - - - - )} - {r.name} - - - - - } - > - {humanizeDurationRelative(r.lastEvaluation, now())} - - - - - - } - > - {humanizeDuration( - parseFloat(r.evaluationTime) * 1000 - )} - - - - - {r.health} - - - - - - - {r.lastError && ( - } + {g.rules.length === 0 && ( + }> + No rules in rule group. + + )} + ( + + {items.map((r, j) => ( + - Error: {r.lastError} - - )} - - - ))} - - - ))} + + + + {r.type === "alerting" ? ( + + + + ) : ( + + + + )} + {r.name} + + + + + + } + > + {humanizeDurationRelative( + r.lastEvaluation, + now() + )} + + + + + + } + > + {humanizeDuration( + parseFloat(r.evaluationTime) * 1000 + )} + + + + + {r.health} + + + + + + + {r.lastError && ( + } + > + Error: {r.lastError} + + )} + + + ))} + + )} + /> + + ))} ); } diff --git a/web/ui/mantine-ui/src/state/localStorageMiddleware.ts b/web/ui/mantine-ui/src/state/localStorageMiddleware.ts index 93eae950e..79baa5ac6 100644 --- a/web/ui/mantine-ui/src/state/localStorageMiddleware.ts +++ b/web/ui/mantine-ui/src/state/localStorageMiddleware.ts @@ -63,6 +63,7 @@ startAppListening({ case "enableSyntaxHighlighting": case "enableLinter": case "showAnnotations": + case "ruleGroupsPerPage": return persistToLocalStorage(`settings.${key}`, value); } }); diff --git a/web/ui/mantine-ui/src/state/settingsSlice.ts b/web/ui/mantine-ui/src/state/settingsSlice.ts index ea744e014..c4154b725 100644 --- a/web/ui/mantine-ui/src/state/settingsSlice.ts +++ b/web/ui/mantine-ui/src/state/settingsSlice.ts @@ -14,6 +14,8 @@ interface Settings { enableSyntaxHighlighting: boolean; enableLinter: boolean; showAnnotations: boolean; + ruleGroupsPerPage: number; + alertGroupsPerPage: number; } // Declared/defined in public/index.html, value replaced by Prometheus when serving bundle. @@ -29,6 +31,8 @@ export const localStorageKeyEnableSyntaxHighlighting = "settings.enableSyntaxHighlighting"; export const localStorageKeyEnableLinter = "settings.enableLinter"; export const localStorageKeyShowAnnotations = "settings.showAnnotations"; +export const localStorageKeyRuleGroupsPerPage = "settings.ruleGroupsPerPage"; +export const localStorageKeyAlertGroupsPerPage = "settings.alertGroupsPerPage"; // This dynamically/generically determines the pathPrefix by stripping the first known // endpoint suffix from the window location path. It works out of the box for both direct @@ -95,6 +99,14 @@ export const initialState: Settings = { localStorageKeyShowAnnotations, true ), + ruleGroupsPerPage: initializeFromLocalStorage( + localStorageKeyRuleGroupsPerPage, + 10 + ), + alertGroupsPerPage: initializeFromLocalStorage( + localStorageKeyAlertGroupsPerPage, + 10 + ), }; export const settingsSlice = createSlice({