mirror of
https://github.com/prometheus/prometheus.git
synced 2025-01-30 07:03:06 -08:00
Paginate rule groups, add infinite scroll to rules within groups
Some checks failed
CI / Go tests (push) Has been cancelled
CI / More Go tests (push) Has been cancelled
CI / Go tests with previous Go version (push) Has been cancelled
CI / UI tests (push) Has been cancelled
CI / Go tests on Windows (push) Has been cancelled
CI / Mixins tests (push) Has been cancelled
CI / Build Prometheus for common architectures (0) (push) Has been cancelled
CI / Build Prometheus for common architectures (1) (push) Has been cancelled
CI / Build Prometheus for common architectures (2) (push) Has been cancelled
CI / Build Prometheus for all architectures (0) (push) Has been cancelled
CI / Build Prometheus for all architectures (1) (push) Has been cancelled
CI / Build Prometheus for all architectures (10) (push) Has been cancelled
CI / Build Prometheus for all architectures (11) (push) Has been cancelled
CI / Build Prometheus for all architectures (2) (push) Has been cancelled
CI / Build Prometheus for all architectures (3) (push) Has been cancelled
CI / Build Prometheus for all architectures (4) (push) Has been cancelled
CI / Build Prometheus for all architectures (5) (push) Has been cancelled
CI / Build Prometheus for all architectures (6) (push) Has been cancelled
CI / Build Prometheus for all architectures (7) (push) Has been cancelled
CI / Build Prometheus for all architectures (8) (push) Has been cancelled
CI / Build Prometheus for all architectures (9) (push) Has been cancelled
CI / Check generated parser (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
CI / fuzzing (push) Has been cancelled
CI / codeql (push) Has been cancelled
CI / Report status of build Prometheus for all architectures (push) Has been cancelled
CI / Publish main branch artifacts (push) Has been cancelled
CI / Publish release artefacts (push) Has been cancelled
CI / Publish UI on npm Registry (push) Has been cancelled
Some checks failed
CI / Go tests (push) Has been cancelled
CI / More Go tests (push) Has been cancelled
CI / Go tests with previous Go version (push) Has been cancelled
CI / UI tests (push) Has been cancelled
CI / Go tests on Windows (push) Has been cancelled
CI / Mixins tests (push) Has been cancelled
CI / Build Prometheus for common architectures (0) (push) Has been cancelled
CI / Build Prometheus for common architectures (1) (push) Has been cancelled
CI / Build Prometheus for common architectures (2) (push) Has been cancelled
CI / Build Prometheus for all architectures (0) (push) Has been cancelled
CI / Build Prometheus for all architectures (1) (push) Has been cancelled
CI / Build Prometheus for all architectures (10) (push) Has been cancelled
CI / Build Prometheus for all architectures (11) (push) Has been cancelled
CI / Build Prometheus for all architectures (2) (push) Has been cancelled
CI / Build Prometheus for all architectures (3) (push) Has been cancelled
CI / Build Prometheus for all architectures (4) (push) Has been cancelled
CI / Build Prometheus for all architectures (5) (push) Has been cancelled
CI / Build Prometheus for all architectures (6) (push) Has been cancelled
CI / Build Prometheus for all architectures (7) (push) Has been cancelled
CI / Build Prometheus for all architectures (8) (push) Has been cancelled
CI / Build Prometheus for all architectures (9) (push) Has been cancelled
CI / Check generated parser (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
CI / fuzzing (push) Has been cancelled
CI / codeql (push) Has been cancelled
CI / Report status of build Prometheus for all architectures (push) Has been cancelled
CI / Publish main branch artifacts (push) Has been cancelled
CI / Publish release artefacts (push) Has been cancelled
CI / Publish UI on npm Registry (push) Has been cancelled
This addresses extreme slowness when you have thousands of rules in potentially hundreds of rule groups. It can still be a bit slow even with pagination and infinite scroll for very large use cases, but it's much better already than before. Fixes https://github.com/prometheus/prometheus/issues/15551 Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
addaf419ef
commit
84e0f43a0c
|
@ -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 { IconSettings } from "@tabler/icons-react";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useAppDispatch } from "../state/hooks";
|
import { useAppDispatch } from "../state/hooks";
|
||||||
|
@ -13,6 +21,8 @@ const SettingsMenu: FC = () => {
|
||||||
enableSyntaxHighlighting,
|
enableSyntaxHighlighting,
|
||||||
enableLinter,
|
enableLinter,
|
||||||
showAnnotations,
|
showAnnotations,
|
||||||
|
ruleGroupsPerPage,
|
||||||
|
alertGroupsPerPage,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
@ -29,82 +39,126 @@ const SettingsMenu: FC = () => {
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Stack>
|
<Group align="flex-start">
|
||||||
<Fieldset p="md" legend="Global settings">
|
<Stack>
|
||||||
<Checkbox
|
<Fieldset p="md" legend="Global settings">
|
||||||
checked={useLocalTime}
|
<Checkbox
|
||||||
label="Use local time"
|
checked={useLocalTime}
|
||||||
onChange={(event) =>
|
label="Use local time"
|
||||||
dispatch(
|
onChange={(event) =>
|
||||||
updateSettings({ useLocalTime: event.currentTarget.checked })
|
dispatch(
|
||||||
)
|
updateSettings({
|
||||||
}
|
useLocalTime: event.currentTarget.checked,
|
||||||
/>
|
})
|
||||||
</Fieldset>
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
<Fieldset p="md" legend="Query page settings">
|
<Fieldset p="md" legend="Query page settings">
|
||||||
<Stack>
|
<Stack>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={enableQueryHistory}
|
checked={enableQueryHistory}
|
||||||
label="Enable query history"
|
label="Enable query history"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
dispatch(
|
dispatch(
|
||||||
updateSettings({
|
updateSettings({
|
||||||
enableQueryHistory: event.currentTarget.checked,
|
enableQueryHistory: event.currentTarget.checked,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={enableAutocomplete}
|
checked={enableAutocomplete}
|
||||||
label="Enable autocomplete"
|
label="Enable autocomplete"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
dispatch(
|
dispatch(
|
||||||
updateSettings({
|
updateSettings({
|
||||||
enableAutocomplete: event.currentTarget.checked,
|
enableAutocomplete: event.currentTarget.checked,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={enableSyntaxHighlighting}
|
checked={enableSyntaxHighlighting}
|
||||||
label="Enable syntax highlighting"
|
label="Enable syntax highlighting"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
dispatch(
|
dispatch(
|
||||||
updateSettings({
|
updateSettings({
|
||||||
enableSyntaxHighlighting: event.currentTarget.checked,
|
enableSyntaxHighlighting: event.currentTarget.checked,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={enableLinter}
|
checked={enableLinter}
|
||||||
label="Enable linter"
|
label="Enable linter"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
dispatch(
|
dispatch(
|
||||||
updateSettings({
|
updateSettings({
|
||||||
enableLinter: event.currentTarget.checked,
|
enableLinter: event.currentTarget.checked,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Fieldset p="md" legend="Alerts page settings">
|
<Stack>
|
||||||
<Checkbox
|
<Fieldset p="md" legend="Alerts page settings">
|
||||||
checked={showAnnotations}
|
<Checkbox
|
||||||
label="Show expanded annotations"
|
checked={showAnnotations}
|
||||||
onChange={(event) =>
|
label="Show expanded annotations"
|
||||||
dispatch(
|
onChange={(event) =>
|
||||||
updateSettings({
|
dispatch(
|
||||||
showAnnotations: event.currentTarget.checked,
|
updateSettings({
|
||||||
})
|
showAnnotations: event.currentTarget.checked,
|
||||||
)
|
})
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
</Fieldset>
|
/>
|
||||||
</Stack>
|
</Fieldset>
|
||||||
|
<Fieldset p="md" legend="Alerts page settings">
|
||||||
|
<NumberInput
|
||||||
|
min={1}
|
||||||
|
allowDecimal={false}
|
||||||
|
label="Alert groups per page"
|
||||||
|
value={alertGroupsPerPage}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (typeof value !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateSettings({
|
||||||
|
alertGroupsPerPage: value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fieldset>
|
||||||
|
<Fieldset p="md" legend="Rules page settings">
|
||||||
|
<NumberInput
|
||||||
|
min={1}
|
||||||
|
allowDecimal={false}
|
||||||
|
label="Rule groups per page"
|
||||||
|
value={ruleGroupsPerPage}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (typeof value !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateSettings({
|
||||||
|
ruleGroupsPerPage: value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fieldset>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
TextInput,
|
TextInput,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Pagination,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useSuspenseAPIQuery } from "../api/api";
|
import { useSuspenseAPIQuery } from "../api/api";
|
||||||
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
|
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
|
||||||
|
@ -18,7 +19,7 @@ import badgeClasses from "../Badge.module.css";
|
||||||
import panelClasses from "../Panel.module.css";
|
import panelClasses from "../Panel.module.css";
|
||||||
import RuleDefinition from "../components/RuleDefinition";
|
import RuleDefinition from "../components/RuleDefinition";
|
||||||
import { humanizeDurationRelative, now } from "../lib/formatTime";
|
import { humanizeDurationRelative, now } from "../lib/formatTime";
|
||||||
import { Fragment, useMemo } from "react";
|
import { Fragment, useEffect, useMemo } from "react";
|
||||||
import { StateMultiSelect } from "../components/StateMultiSelect";
|
import { StateMultiSelect } from "../components/StateMultiSelect";
|
||||||
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
||||||
import { LabelBadges } from "../components/LabelBadges";
|
import { LabelBadges } from "../components/LabelBadges";
|
||||||
|
@ -26,6 +27,7 @@ import { useSettings } from "../state/settingsSlice";
|
||||||
import {
|
import {
|
||||||
ArrayParam,
|
ArrayParam,
|
||||||
BooleanParam,
|
BooleanParam,
|
||||||
|
NumberParam,
|
||||||
StringParam,
|
StringParam,
|
||||||
useQueryParam,
|
useQueryParam,
|
||||||
withDefault,
|
withDefault,
|
||||||
|
@ -33,6 +35,7 @@ import {
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { KVSearch } from "@nexucis/kvsearch";
|
import { KVSearch } from "@nexucis/kvsearch";
|
||||||
import { inputIconStyle } from "../styles";
|
import { inputIconStyle } from "../styles";
|
||||||
|
import CustomInfiniteScroll from "../components/CustomInfiniteScroll";
|
||||||
|
|
||||||
type AlertsPageData = {
|
type AlertsPageData = {
|
||||||
// How many rules are in each state across all groups.
|
// How many rules are in each state across all groups.
|
||||||
|
@ -132,6 +135,12 @@ const buildAlertsPageData = (
|
||||||
return pageData;
|
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() {
|
export default function AlertsPage() {
|
||||||
// Fetch the alerting rules data.
|
// Fetch the alerting rules data.
|
||||||
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
|
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
|
||||||
|
@ -146,7 +155,7 @@ export default function AlertsPage() {
|
||||||
// Define URL query params.
|
// Define URL query params.
|
||||||
const [stateFilter, setStateFilter] = useQueryParam(
|
const [stateFilter, setStateFilter] = useQueryParam(
|
||||||
"state",
|
"state",
|
||||||
withDefault(ArrayParam, [])
|
withDefault(ArrayParam, emptyStateFilter)
|
||||||
);
|
);
|
||||||
const [searchFilter, setSearchFilter] = useQueryParam(
|
const [searchFilter, setSearchFilter] = useQueryParam(
|
||||||
"search",
|
"search",
|
||||||
|
@ -158,132 +167,117 @@ export default function AlertsPage() {
|
||||||
withDefault(BooleanParam, true)
|
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.
|
// Update the page data whenever the fetched data or filters change.
|
||||||
const alertsPageData: AlertsPageData = useMemo(
|
const alertsPageData: AlertsPageData = useMemo(
|
||||||
() => buildAlertsPageData(data.data, debouncedSearch, stateFilter),
|
() => buildAlertsPageData(data.data, debouncedSearch, stateFilter),
|
||||||
[data, stateFilter, debouncedSearch]
|
[data, stateFilter, debouncedSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const shownGroups = showEmptyGroups
|
const shownGroups = useMemo(
|
||||||
? alertsPageData.groups
|
() =>
|
||||||
: alertsPageData.groups.filter((g) => g.rules.length > 0);
|
showEmptyGroups
|
||||||
|
? alertsPageData.groups
|
||||||
|
: alertsPageData.groups.filter((g) => g.rules.length > 0),
|
||||||
|
[alertsPageData.groups, showEmptyGroups]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
// If we were e.g. on page 10 and the number of total pages decreases to 5 (due to filtering
|
||||||
<Stack mt="xs">
|
// or changing the max number of items per page), go to the largest possible page.
|
||||||
<Group>
|
const totalPageCount = Math.ceil(shownGroups.length / alertGroupsPerPage);
|
||||||
<StateMultiSelect
|
const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount));
|
||||||
options={["inactive", "pending", "firing"]}
|
|
||||||
optionClass={(o) =>
|
useEffect(() => {
|
||||||
o === "inactive"
|
if (effectiveActivePage !== activePage) {
|
||||||
? badgeClasses.healthOk
|
setActivePage(effectiveActivePage);
|
||||||
: o === "pending"
|
}
|
||||||
? badgeClasses.healthWarn
|
}, [effectiveActivePage, activePage, setActivePage]);
|
||||||
: badgeClasses.healthErr
|
|
||||||
}
|
const currentPageGroups = useMemo(
|
||||||
optionCount={(o) =>
|
() =>
|
||||||
alertsPageData.globalCounts[
|
shownGroups.slice(
|
||||||
o as keyof typeof alertsPageData.globalCounts
|
(effectiveActivePage - 1) * alertGroupsPerPage,
|
||||||
]
|
effectiveActivePage * alertGroupsPerPage
|
||||||
}
|
),
|
||||||
placeholder="Filter by rule state"
|
[shownGroups, effectiveActivePage, alertGroupsPerPage]
|
||||||
values={(stateFilter?.filter((v) => v !== null) as string[]) || []}
|
);
|
||||||
onChange={(values) => setStateFilter(values)}
|
|
||||||
/>
|
// We memoize the actual rendering of the page items to avoid re-rendering
|
||||||
<TextInput
|
// them on every state change. This is especially important when the user
|
||||||
flex={1}
|
// types into the search box, as the search filter changes on every keystroke,
|
||||||
leftSection={<IconSearch style={inputIconStyle} />}
|
// even before debouncing takes place (extracting the filters and results list
|
||||||
placeholder="Filter by rule name or labels"
|
// into separate components would be an alternative to this, but it's kinda
|
||||||
value={searchFilter || ""}
|
// convenient to have in the same file IMO).
|
||||||
onChange={(event) =>
|
const renderedPageItems = useMemo(
|
||||||
setSearchFilter(event.currentTarget.value || null)
|
() =>
|
||||||
}
|
currentPageGroups.map((g, i) => (
|
||||||
></TextInput>
|
<Card
|
||||||
</Group>
|
shadow="xs"
|
||||||
{alertsPageData.groups.length === 0 ? (
|
withBorder
|
||||||
<Alert title="No rules found" icon={<IconInfoCircle />}>
|
p="md"
|
||||||
No rules found.
|
key={i} // TODO: Find a stable and definitely unique key.
|
||||||
</Alert>
|
>
|
||||||
) : (
|
<Group mb="md" mt="xs" ml="xs" justify="space-between">
|
||||||
!showEmptyGroups &&
|
<Group align="baseline">
|
||||||
alertsPageData.groups.length !== shownGroups.length && (
|
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
|
||||||
<Alert
|
{g.name}
|
||||||
title="Hiding groups with no matching rules"
|
</Text>
|
||||||
icon={<IconInfoCircle/>}
|
<Text fz="sm" c="gray.6">
|
||||||
>
|
{g.file}
|
||||||
Hiding {alertsPageData.groups.length - shownGroups.length} empty
|
</Text>
|
||||||
groups due to filters or no rules.
|
</Group>
|
||||||
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}>
|
<Group>
|
||||||
Show empty groups
|
{g.counts.firing > 0 && (
|
||||||
</Anchor>
|
<Badge className={badgeClasses.healthErr}>
|
||||||
</Alert>
|
firing ({g.counts.firing})
|
||||||
)
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Stack>
|
{g.counts.pending > 0 && (
|
||||||
{shownGroups.map((g, i) => {
|
<Badge className={badgeClasses.healthWarn}>
|
||||||
return (
|
pending ({g.counts.pending})
|
||||||
<Card
|
</Badge>
|
||||||
shadow="xs"
|
)}
|
||||||
withBorder
|
{g.counts.inactive > 0 && (
|
||||||
p="md"
|
<Badge className={badgeClasses.healthOk}>
|
||||||
key={i} // TODO: Find a stable and definitely unique key.
|
inactive ({g.counts.inactive})
|
||||||
>
|
</Badge>
|
||||||
<Group mb="md" mt="xs" ml="xs" justify="space-between">
|
)}
|
||||||
<Group align="baseline">
|
</Group>
|
||||||
<Text
|
</Group>
|
||||||
fz="xl"
|
{g.counts.total === 0 ? (
|
||||||
fw={600}
|
<Alert title="No rules" icon={<IconInfoCircle />}>
|
||||||
c="var(--mantine-primary-color-filled)"
|
No rules in this group.
|
||||||
>
|
<Anchor
|
||||||
{g.name}
|
ml="md"
|
||||||
</Text>
|
fz="1em"
|
||||||
<Text fz="sm" c="gray.6">
|
onClick={() => setShowEmptyGroups(false)}
|
||||||
{g.file}
|
>
|
||||||
</Text>
|
Hide empty groups
|
||||||
</Group>
|
</Anchor>
|
||||||
<Group>
|
</Alert>
|
||||||
{g.counts.firing > 0 && (
|
) : g.rules.length === 0 ? (
|
||||||
<Badge className={badgeClasses.healthErr}>
|
<Alert title="No matching rules" icon={<IconInfoCircle />}>
|
||||||
firing ({g.counts.firing})
|
No rules in this group match your filter criteria (omitted{" "}
|
||||||
</Badge>
|
{g.counts.total} filtered rules).
|
||||||
)}
|
<Anchor
|
||||||
{g.counts.pending > 0 && (
|
ml="md"
|
||||||
<Badge className={badgeClasses.healthWarn}>
|
fz="1em"
|
||||||
pending ({g.counts.pending})
|
onClick={() => setShowEmptyGroups(false)}
|
||||||
</Badge>
|
>
|
||||||
)}
|
Hide empty groups
|
||||||
{g.counts.inactive > 0 && (
|
</Anchor>
|
||||||
<Badge className={badgeClasses.healthOk}>
|
</Alert>
|
||||||
inactive ({g.counts.inactive})
|
) : (
|
||||||
</Badge>
|
<CustomInfiniteScroll
|
||||||
)}
|
allItems={g.rules}
|
||||||
</Group>
|
child={({ items }) => (
|
||||||
</Group>
|
|
||||||
{g.counts.total === 0 ? (
|
|
||||||
<Alert title="No rules" icon={<IconInfoCircle />}>
|
|
||||||
No rules in this group.
|
|
||||||
<Anchor
|
|
||||||
ml="md"
|
|
||||||
fz="1em"
|
|
||||||
onClick={() => setShowEmptyGroups(false)}
|
|
||||||
>
|
|
||||||
Hide empty groups
|
|
||||||
</Anchor>
|
|
||||||
</Alert>
|
|
||||||
) : g.rules.length === 0 ? (
|
|
||||||
<Alert title="No matching rules" icon={<IconInfoCircle />}>
|
|
||||||
No rules in this group match your filter criteria (omitted{" "}
|
|
||||||
{g.counts.total} filtered rules).
|
|
||||||
<Anchor
|
|
||||||
ml="md"
|
|
||||||
fz="1em"
|
|
||||||
onClick={() => setShowEmptyGroups(false)}
|
|
||||||
>
|
|
||||||
Hide empty groups
|
|
||||||
</Anchor>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Accordion multiple variant="separated">
|
<Accordion multiple variant="separated">
|
||||||
{g.rules.map((r, j) => {
|
{items.map((r, j) => {
|
||||||
return (
|
return (
|
||||||
<Accordion.Item
|
<Accordion.Item
|
||||||
styles={{
|
styles={{
|
||||||
|
@ -327,7 +321,7 @@ export default function AlertsPage() {
|
||||||
{r.rule.alerts.length > 0 && (
|
{r.rule.alerts.length > 0 && (
|
||||||
<Table mt="lg">
|
<Table mt="lg">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr style={{whiteSpace: "nowrap"}}>
|
<Table.Tr style={{ whiteSpace: "nowrap" }}>
|
||||||
<Table.Th>Alert labels</Table.Th>
|
<Table.Th>Alert labels</Table.Th>
|
||||||
<Table.Th>State</Table.Th>
|
<Table.Th>State</Table.Th>
|
||||||
<Table.Th>Active Since</Table.Th>
|
<Table.Th>Active Since</Table.Th>
|
||||||
|
@ -405,9 +399,71 @@ export default function AlertsPage() {
|
||||||
})}
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)}
|
)}
|
||||||
</Card>
|
/>
|
||||||
);
|
)}
|
||||||
})}
|
</Card>
|
||||||
|
)),
|
||||||
|
[currentPageGroups, showAnnotations, setShowEmptyGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack mt="xs">
|
||||||
|
<Group>
|
||||||
|
<StateMultiSelect
|
||||||
|
options={["inactive", "pending", "firing"]}
|
||||||
|
optionClass={(o) =>
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
flex={1}
|
||||||
|
leftSection={<IconSearch style={inputIconStyle} />}
|
||||||
|
placeholder="Filter by rule name or labels"
|
||||||
|
value={searchFilter || ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearchFilter(event.currentTarget.value || null)
|
||||||
|
}
|
||||||
|
></TextInput>
|
||||||
|
</Group>
|
||||||
|
{alertsPageData.groups.length === 0 ? (
|
||||||
|
<Alert title="No rules found" icon={<IconInfoCircle />}>
|
||||||
|
No rules found.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
!showEmptyGroups &&
|
||||||
|
alertsPageData.groups.length !== shownGroups.length && (
|
||||||
|
<Alert
|
||||||
|
title="Hiding groups with no matching rules"
|
||||||
|
icon={<IconInfoCircle />}
|
||||||
|
>
|
||||||
|
Hiding {alertsPageData.groups.length - shownGroups.length} empty
|
||||||
|
groups due to filters or no rules.
|
||||||
|
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}>
|
||||||
|
Show empty groups
|
||||||
|
</Anchor>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Stack>
|
||||||
|
<Pagination
|
||||||
|
total={totalPageCount}
|
||||||
|
value={effectiveActivePage}
|
||||||
|
onChange={setActivePage}
|
||||||
|
hideWithOnePage
|
||||||
|
/>
|
||||||
|
{renderedPageItems}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
Badge,
|
Badge,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Pagination,
|
||||||
rem,
|
rem,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
@ -29,6 +30,10 @@ import { RulesResult } from "../api/responseTypes/rules";
|
||||||
import badgeClasses from "../Badge.module.css";
|
import badgeClasses from "../Badge.module.css";
|
||||||
import RuleDefinition from "../components/RuleDefinition";
|
import RuleDefinition from "../components/RuleDefinition";
|
||||||
import { badgeIconStyle } from "../styles";
|
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) => {
|
const healthBadgeClass = (state: string) => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
@ -45,6 +50,23 @@ const healthBadgeClass = (state: string) => {
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` });
|
const { data } = useSuspenseAPIQuery<RulesResult>({ 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 (
|
return (
|
||||||
<Stack mt="xs">
|
<Stack mt="xs">
|
||||||
|
@ -53,157 +75,178 @@ export default function RulesPage() {
|
||||||
No rule groups configured.
|
No rule groups configured.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{data.data.groups.map((g, i) => (
|
<Pagination
|
||||||
<Card
|
total={totalPageCount}
|
||||||
shadow="xs"
|
value={effectiveActivePage}
|
||||||
withBorder
|
onChange={setActivePage}
|
||||||
p="md"
|
hideWithOnePage
|
||||||
mb="md"
|
/>
|
||||||
key={i} // TODO: Find a stable and definitely unique key.
|
{data.data.groups
|
||||||
>
|
.slice(
|
||||||
<Group mb="md" mt="xs" ml="xs" justify="space-between">
|
(effectiveActivePage - 1) * ruleGroupsPerPage,
|
||||||
<Group align="baseline">
|
effectiveActivePage * ruleGroupsPerPage
|
||||||
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
|
)
|
||||||
{g.name}
|
.map((g, i) => (
|
||||||
</Text>
|
<Card
|
||||||
<Text fz="sm" c="gray.6">
|
shadow="xs"
|
||||||
{g.file}
|
withBorder
|
||||||
</Text>
|
p="md"
|
||||||
|
mb="md"
|
||||||
|
key={i} // TODO: Find a stable and definitely unique key.
|
||||||
|
>
|
||||||
|
<Group mb="md" mt="xs" ml="xs" justify="space-between">
|
||||||
|
<Group align="baseline">
|
||||||
|
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
|
||||||
|
{g.name}
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" c="gray.6">
|
||||||
|
{g.file}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Tooltip label="Last group evaluation" withArrow>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
className={badgeClasses.statsBadge}
|
||||||
|
styles={{ label: { textTransform: "none" } }}
|
||||||
|
leftSection={<IconRefresh style={badgeIconStyle} />}
|
||||||
|
>
|
||||||
|
last run {humanizeDurationRelative(g.lastEvaluation, now())}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Duration of last group evaluation" withArrow>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
className={badgeClasses.statsBadge}
|
||||||
|
styles={{ label: { textTransform: "none" } }}
|
||||||
|
leftSection={<IconHourglass style={badgeIconStyle} />}
|
||||||
|
>
|
||||||
|
took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Group evaluation interval" withArrow>
|
||||||
|
<Badge
|
||||||
|
variant="transparent"
|
||||||
|
className={badgeClasses.statsBadge}
|
||||||
|
styles={{ label: { textTransform: "none" } }}
|
||||||
|
leftSection={<IconRepeat style={badgeIconStyle} />}
|
||||||
|
>
|
||||||
|
every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
{g.rules.length === 0 && (
|
||||||
<Tooltip label="Last group evaluation" withArrow>
|
<Alert title="No rules" icon={<IconInfoCircle />}>
|
||||||
<Badge
|
No rules in rule group.
|
||||||
variant="light"
|
</Alert>
|
||||||
className={badgeClasses.statsBadge}
|
)}
|
||||||
styles={{ label: { textTransform: "none" } }}
|
<CustomInfiniteScroll
|
||||||
leftSection={<IconRefresh style={badgeIconStyle} />}
|
allItems={g.rules}
|
||||||
>
|
child={({ items }) => (
|
||||||
last run {humanizeDurationRelative(g.lastEvaluation, now())}
|
<Accordion multiple variant="separated">
|
||||||
</Badge>
|
{items.map((r, j) => (
|
||||||
</Tooltip>
|
<Accordion.Item
|
||||||
<Tooltip label="Duration of last group evaluation" withArrow>
|
styles={{
|
||||||
<Badge
|
item: {
|
||||||
variant="light"
|
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
||||||
className={badgeClasses.statsBadge}
|
// have a different background color than their surrounding group card in dark mode,
|
||||||
styles={{ label: { textTransform: "none" } }}
|
// but it would be better to use CSS to override the light/dark colors for
|
||||||
leftSection={<IconHourglass style={badgeIconStyle} />}
|
// collapsed/expanded accordion items.
|
||||||
>
|
backgroundColor: "#c0c0c015",
|
||||||
took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
|
},
|
||||||
</Badge>
|
}}
|
||||||
</Tooltip>
|
key={j}
|
||||||
<Tooltip label="Group evaluation interval" withArrow>
|
value={j.toString()}
|
||||||
<Badge
|
style={{
|
||||||
variant="transparent"
|
borderLeft:
|
||||||
className={badgeClasses.statsBadge}
|
r.health === "err"
|
||||||
styles={{ label: { textTransform: "none" } }}
|
? "5px solid var(--mantine-color-red-4)"
|
||||||
leftSection={<IconRepeat style={badgeIconStyle} />}
|
: r.health === "unknown"
|
||||||
>
|
? "5px solid var(--mantine-color-gray-5)"
|
||||||
every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "}
|
: "5px solid var(--mantine-color-green-4)",
|
||||||
</Badge>
|
}}
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
{g.rules.length === 0 && (
|
|
||||||
<Alert title="No rules" icon={<IconInfoCircle />}>
|
|
||||||
No rules in rule group.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Accordion multiple variant="separated">
|
|
||||||
{g.rules.map((r, j) => (
|
|
||||||
<Accordion.Item
|
|
||||||
styles={{
|
|
||||||
item: {
|
|
||||||
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
|
||||||
// have a different background color than their surrounding group card in dark mode,
|
|
||||||
// but it would be better to use CSS to override the light/dark colors for
|
|
||||||
// collapsed/expanded accordion items.
|
|
||||||
backgroundColor: "#c0c0c015",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
key={j}
|
|
||||||
value={j.toString()}
|
|
||||||
style={{
|
|
||||||
borderLeft:
|
|
||||||
r.health === "err"
|
|
||||||
? "5px solid var(--mantine-color-red-4)"
|
|
||||||
: r.health === "unknown"
|
|
||||||
? "5px solid var(--mantine-color-gray-5)"
|
|
||||||
: "5px solid var(--mantine-color-green-4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Accordion.Control>
|
|
||||||
<Group justify="space-between" mr="lg">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
{r.type === "alerting" ? (
|
|
||||||
<Tooltip label="Alerting rule" withArrow>
|
|
||||||
<IconBell
|
|
||||||
style={{ width: rem(15), height: rem(15) }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Tooltip label="Recording rule" withArrow>
|
|
||||||
<IconTimeline
|
|
||||||
style={{ width: rem(15), height: rem(15) }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Text>{r.name}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Group gap="xs" wrap="wrap">
|
|
||||||
<Tooltip label="Last rule evaluation" withArrow>
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
className={badgeClasses.statsBadge}
|
|
||||||
styles={{ label: { textTransform: "none" } }}
|
|
||||||
leftSection={<IconRefresh style={badgeIconStyle} />}
|
|
||||||
>
|
|
||||||
{humanizeDurationRelative(r.lastEvaluation, now())}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
label="Duration of last rule evaluation"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
className={badgeClasses.statsBadge}
|
|
||||||
styles={{ label: { textTransform: "none" } }}
|
|
||||||
leftSection={
|
|
||||||
<IconHourglass style={badgeIconStyle} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{humanizeDuration(
|
|
||||||
parseFloat(r.evaluationTime) * 1000
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
<Badge className={healthBadgeClass(r.health)}>
|
|
||||||
{r.health}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Accordion.Control>
|
|
||||||
<Accordion.Panel>
|
|
||||||
<RuleDefinition rule={r} />
|
|
||||||
{r.lastError && (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
mt="sm"
|
|
||||||
title="Rule failed to evaluate"
|
|
||||||
icon={<IconAlertTriangle />}
|
|
||||||
>
|
>
|
||||||
<strong>Error:</strong> {r.lastError}
|
<Accordion.Control>
|
||||||
</Alert>
|
<Group justify="space-between" mr="lg">
|
||||||
)}
|
<Group gap="xs" wrap="nowrap">
|
||||||
</Accordion.Panel>
|
{r.type === "alerting" ? (
|
||||||
</Accordion.Item>
|
<Tooltip label="Alerting rule" withArrow>
|
||||||
))}
|
<IconBell
|
||||||
</Accordion>
|
style={{ width: rem(15), height: rem(15) }}
|
||||||
</Card>
|
/>
|
||||||
))}
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="Recording rule" withArrow>
|
||||||
|
<IconTimeline
|
||||||
|
style={{ width: rem(15), height: rem(15) }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Text>{r.name}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
<Tooltip label="Last rule evaluation" withArrow>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
className={badgeClasses.statsBadge}
|
||||||
|
styles={{ label: { textTransform: "none" } }}
|
||||||
|
leftSection={
|
||||||
|
<IconRefresh style={badgeIconStyle} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{humanizeDurationRelative(
|
||||||
|
r.lastEvaluation,
|
||||||
|
now()
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label="Duration of last rule evaluation"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
className={badgeClasses.statsBadge}
|
||||||
|
styles={{ label: { textTransform: "none" } }}
|
||||||
|
leftSection={
|
||||||
|
<IconHourglass style={badgeIconStyle} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{humanizeDuration(
|
||||||
|
parseFloat(r.evaluationTime) * 1000
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
<Badge className={healthBadgeClass(r.health)}>
|
||||||
|
{r.health}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<RuleDefinition rule={r} />
|
||||||
|
{r.lastError && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
mt="sm"
|
||||||
|
title="Rule failed to evaluate"
|
||||||
|
icon={<IconAlertTriangle />}
|
||||||
|
>
|
||||||
|
<strong>Error:</strong> {r.lastError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,6 +63,7 @@ startAppListening({
|
||||||
case "enableSyntaxHighlighting":
|
case "enableSyntaxHighlighting":
|
||||||
case "enableLinter":
|
case "enableLinter":
|
||||||
case "showAnnotations":
|
case "showAnnotations":
|
||||||
|
case "ruleGroupsPerPage":
|
||||||
return persistToLocalStorage(`settings.${key}`, value);
|
return persistToLocalStorage(`settings.${key}`, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,6 +14,8 @@ interface Settings {
|
||||||
enableSyntaxHighlighting: boolean;
|
enableSyntaxHighlighting: boolean;
|
||||||
enableLinter: boolean;
|
enableLinter: boolean;
|
||||||
showAnnotations: boolean;
|
showAnnotations: boolean;
|
||||||
|
ruleGroupsPerPage: number;
|
||||||
|
alertGroupsPerPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
|
// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
|
||||||
|
@ -29,6 +31,8 @@ export const localStorageKeyEnableSyntaxHighlighting =
|
||||||
"settings.enableSyntaxHighlighting";
|
"settings.enableSyntaxHighlighting";
|
||||||
export const localStorageKeyEnableLinter = "settings.enableLinter";
|
export const localStorageKeyEnableLinter = "settings.enableLinter";
|
||||||
export const localStorageKeyShowAnnotations = "settings.showAnnotations";
|
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
|
// 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
|
// 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,
|
localStorageKeyShowAnnotations,
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
|
ruleGroupsPerPage: initializeFromLocalStorage<number>(
|
||||||
|
localStorageKeyRuleGroupsPerPage,
|
||||||
|
10
|
||||||
|
),
|
||||||
|
alertGroupsPerPage: initializeFromLocalStorage<number>(
|
||||||
|
localStorageKeyAlertGroupsPerPage,
|
||||||
|
10
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsSlice = createSlice({
|
export const settingsSlice = createSlice({
|
||||||
|
|
Loading…
Reference in a new issue