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 = () => {
-
-
+
+
+
-
+
+
-
-
+
+
+
+
+
+
);
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({