mirror of
https://github.com/prometheus/prometheus.git
synced 2024-12-26 22:19:40 -08:00
Add filtering to alerts page
Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
4efd47741e
commit
d6e5e39bf7
|
@ -18,7 +18,7 @@ type CommonRuleFields = {
|
||||||
lastEvaluation: string;
|
lastEvaluation: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AlertingRule = {
|
export type AlertingRule = {
|
||||||
type: "alerting";
|
type: "alerting";
|
||||||
// For alerting rules, the 'labels' field is always present, even when there are no labels.
|
// For alerting rules, the 'labels' field is always present, even when there are no labels.
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
@ -46,7 +46,7 @@ interface RuleGroup {
|
||||||
lastEvaluation: string;
|
lastEvaluation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertingRuleGroup = Omit<RuleGroup, "rules"> & {
|
export type AlertingRuleGroup = Omit<RuleGroup, "rules"> & {
|
||||||
rules: AlertingRule[];
|
rules: AlertingRule[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,7 @@ export function StatePill({ value, onRemove, ...others }: StatePillProps) {
|
||||||
interface StateMultiSelectProps {
|
interface StateMultiSelectProps {
|
||||||
options: string[];
|
options: string[];
|
||||||
optionClass: (option: string) => string;
|
optionClass: (option: string) => string;
|
||||||
|
optionCount?: (option: string) => number;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
onChange: (values: string[]) => void;
|
onChange: (values: string[]) => void;
|
||||||
|
@ -41,6 +42,7 @@ interface StateMultiSelectProps {
|
||||||
export const StateMultiSelect: FC<StateMultiSelectProps> = ({
|
export const StateMultiSelect: FC<StateMultiSelectProps> = ({
|
||||||
options,
|
options,
|
||||||
optionClass,
|
optionClass,
|
||||||
|
optionCount,
|
||||||
placeholder,
|
placeholder,
|
||||||
values,
|
values,
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -60,7 +62,7 @@ export const StateMultiSelect: FC<StateMultiSelectProps> = ({
|
||||||
|
|
||||||
const renderedValues = values.map((item) => (
|
const renderedValues = values.map((item) => (
|
||||||
<StatePill
|
<StatePill
|
||||||
value={item}
|
value={optionCount ? `${item} (${optionCount(item)})` : item}
|
||||||
className={optionClass(item)}
|
className={optionClass(item)}
|
||||||
onRemove={() => handleValueRemove(item)}
|
onRemove={() => handleValueRemove(item)}
|
||||||
key={item}
|
key={item}
|
||||||
|
@ -123,7 +125,12 @@ export const StateMultiSelect: FC<StateMultiSelectProps> = ({
|
||||||
{values.includes(value) ? (
|
{values.includes(value) ? (
|
||||||
<CheckIcon size={12} color="gray" />
|
<CheckIcon size={12} color="gray" />
|
||||||
) : null}
|
) : null}
|
||||||
<StatePill value={value} className={optionClass(value)} />
|
<StatePill
|
||||||
|
value={
|
||||||
|
optionCount ? `${value} (${optionCount(value)})` : value
|
||||||
|
}
|
||||||
|
className={optionClass(value)}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,24 +8,133 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
Input,
|
|
||||||
Alert,
|
Alert,
|
||||||
|
TextInput,
|
||||||
|
Anchor,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useSuspenseAPIQuery } from "../api/api";
|
import { useSuspenseAPIQuery } from "../api/api";
|
||||||
import { AlertingRulesResult } from "../api/responseTypes/rules";
|
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
|
||||||
import badgeClasses from "../Badge.module.css";
|
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 } from "react";
|
import { Fragment, useMemo } from "react";
|
||||||
import { StateMultiSelect } from "../components/StateMultiSelect";
|
import { StateMultiSelect } from "../components/StateMultiSelect";
|
||||||
import { useAppDispatch, useAppSelector } from "../state/hooks";
|
|
||||||
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
||||||
import { LabelBadges } from "../components/LabelBadges";
|
import { LabelBadges } from "../components/LabelBadges";
|
||||||
import { updateAlertFilters } from "../state/alertsPageSlice";
|
|
||||||
import { useSettings } from "../state/settingsSlice";
|
import { useSettings } from "../state/settingsSlice";
|
||||||
|
import {
|
||||||
|
ArrayParam,
|
||||||
|
BooleanParam,
|
||||||
|
StringParam,
|
||||||
|
useQueryParam,
|
||||||
|
withDefault,
|
||||||
|
} from "use-query-params";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { KVSearch } from "@nexucis/kvsearch";
|
||||||
|
|
||||||
|
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<AlertingRule>({
|
||||||
|
shouldSort: true,
|
||||||
|
indexedKeys: ["name", "labels", ["labels", /.*/]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAlertsPageData = (
|
||||||
|
data: AlertingRulesResult,
|
||||||
|
debouncedSearch: 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[] = (
|
||||||
|
debouncedSearch === ""
|
||||||
|
? group.rules
|
||||||
|
: kvSearch
|
||||||
|
.filter(debouncedSearch, 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;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AlertsPage() {
|
export default function AlertsPage() {
|
||||||
|
// Fetch the alerting rules data.
|
||||||
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
|
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
|
||||||
path: `/rules`,
|
path: `/rules`,
|
||||||
params: {
|
params: {
|
||||||
|
@ -33,23 +142,36 @@ export default function AlertsPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { showAnnotations } = useSettings();
|
const { showAnnotations } = useSettings();
|
||||||
const filters = useAppSelector((state) => state.alertsPage.filters);
|
|
||||||
|
|
||||||
const ruleStatsCount = {
|
// Define URL query params.
|
||||||
inactive: 0,
|
const [stateFilter, setStateFilter] = useQueryParam(
|
||||||
pending: 0,
|
"state",
|
||||||
firing: 0,
|
withDefault(ArrayParam, [])
|
||||||
};
|
);
|
||||||
|
const [searchFilter, setSearchFilter] = useQueryParam(
|
||||||
data.data.groups.forEach((el) =>
|
"search",
|
||||||
el.rules.forEach((r) => ruleStatsCount[r.state]++)
|
withDefault(StringParam, "")
|
||||||
|
);
|
||||||
|
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
|
||||||
|
const [showEmptyGroups, setShowEmptyGroups] = useQueryParam(
|
||||||
|
"showEmptyGroups",
|
||||||
|
withDefault(BooleanParam, true)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack mt="xs">
|
||||||
<Group mb="md" mt="xs">
|
<Group>
|
||||||
<StateMultiSelect
|
<StateMultiSelect
|
||||||
options={["inactive", "pending", "firing"]}
|
options={["inactive", "pending", "firing"]}
|
||||||
optionClass={(o) =>
|
optionClass={(o) =>
|
||||||
|
@ -59,21 +181,46 @@ export default function AlertsPage() {
|
||||||
? badgeClasses.healthWarn
|
? badgeClasses.healthWarn
|
||||||
: badgeClasses.healthErr
|
: badgeClasses.healthErr
|
||||||
}
|
}
|
||||||
placeholder="Filter by alert state"
|
optionCount={(o) =>
|
||||||
values={filters.state}
|
alertsPageData.globalCounts[
|
||||||
onChange={(values) => dispatch(updateAlertFilters({ state: values }))}
|
o as keyof typeof alertsPageData.globalCounts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
placeholder="Filter by rule state"
|
||||||
|
values={(stateFilter?.filter((v) => v !== null) as string[]) || []}
|
||||||
|
onChange={(values) => setStateFilter(values)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<TextInput
|
||||||
flex={1}
|
flex={1}
|
||||||
leftSection={<IconSearch size={14} />}
|
leftSection={<IconSearch size={14} />}
|
||||||
placeholder="Filter by alert name or labels"
|
placeholder="Filter by rule name or labels"
|
||||||
></Input>
|
value={searchFilter || ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearchFilter(event.currentTarget.value || null)
|
||||||
|
}
|
||||||
|
></TextInput>
|
||||||
</Group>
|
</Group>
|
||||||
|
{alertsPageData.groups.length === 0 ? (
|
||||||
|
<Alert title="No rules found" icon={<IconInfoCircle size={14} />}>
|
||||||
|
No rules found.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
!showEmptyGroups &&
|
||||||
|
alertsPageData.groups.length !== shownGroups.length && (
|
||||||
|
<Alert
|
||||||
|
title="Hiding groups with no matching rules"
|
||||||
|
icon={<IconInfoCircle size={14} />}
|
||||||
|
>
|
||||||
|
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>
|
<Stack>
|
||||||
{data.data.groups.map((g, i) => {
|
{shownGroups.map((g, i) => {
|
||||||
const filteredRules = g.rules.filter(
|
|
||||||
(r) => filters.state.length === 0 || filters.state.includes(r.state)
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
shadow="xs"
|
shadow="xs"
|
||||||
|
@ -94,24 +241,50 @@ export default function AlertsPage() {
|
||||||
{g.file}
|
{g.file}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
<Group>
|
||||||
{filteredRules.length === 0 && (
|
{g.counts.firing > 0 && (
|
||||||
<Alert
|
<Badge className={badgeClasses.healthErr}>
|
||||||
title="No matching rules"
|
firing ({g.counts.firing})
|
||||||
icon={<IconInfoCircle size={14} />}
|
</Badge>
|
||||||
>
|
|
||||||
No rules found that match your filter criteria.
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
{g.counts.pending > 0 && (
|
||||||
|
<Badge className={badgeClasses.healthWarn}>
|
||||||
|
pending ({g.counts.pending})
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{g.counts.inactive > 0 && (
|
||||||
|
<Badge className={badgeClasses.healthOk}>
|
||||||
|
inactive ({g.counts.inactive})
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</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">
|
||||||
{filteredRules.map((r, j) => {
|
{g.rules.map((r, j) => {
|
||||||
const numFiring = r.alerts.filter(
|
|
||||||
(a) => a.state === "firing"
|
|
||||||
).length;
|
|
||||||
const numPending = r.alerts.filter(
|
|
||||||
(a) => a.state === "pending"
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item
|
<Accordion.Item
|
||||||
styles={{
|
styles={{
|
||||||
|
@ -126,33 +299,33 @@ export default function AlertsPage() {
|
||||||
key={j}
|
key={j}
|
||||||
value={j.toString()}
|
value={j.toString()}
|
||||||
className={
|
className={
|
||||||
numFiring > 0
|
r.counts.firing > 0
|
||||||
? panelClasses.panelHealthErr
|
? panelClasses.panelHealthErr
|
||||||
: numPending > 0
|
: r.counts.pending > 0
|
||||||
? panelClasses.panelHealthWarn
|
? panelClasses.panelHealthWarn
|
||||||
: panelClasses.panelHealthOk
|
: panelClasses.panelHealthOk
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||||
<Text>{r.name}</Text>
|
<Text>{r.rule.name}</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{numFiring > 0 && (
|
{r.counts.firing > 0 && (
|
||||||
<Badge className={badgeClasses.healthErr}>
|
<Badge className={badgeClasses.healthErr}>
|
||||||
firing ({numFiring})
|
firing ({r.counts.firing})
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{numPending > 0 && (
|
{r.counts.pending > 0 && (
|
||||||
<Badge className={badgeClasses.healthWarn}>
|
<Badge className={badgeClasses.healthWarn}>
|
||||||
pending ({numPending})
|
pending ({r.counts.pending})
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<RuleDefinition rule={r} />
|
<RuleDefinition rule={r.rule} />
|
||||||
{r.alerts.length > 0 && (
|
{r.rule.alerts.length > 0 && (
|
||||||
<Table mt="lg">
|
<Table mt="lg">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
@ -163,8 +336,8 @@ export default function AlertsPage() {
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{r.type === "alerting" &&
|
{r.rule.type === "alerting" &&
|
||||||
r.alerts.map((a, k) => (
|
r.rule.alerts.map((a, k) => (
|
||||||
<Fragment key={k}>
|
<Fragment key={k}>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
@ -222,10 +395,11 @@ export default function AlertsPage() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
|
||||||
|
|
||||||
interface AlertFilters {
|
|
||||||
state: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertsPage {
|
|
||||||
filters: AlertFilters;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: AlertsPage = {
|
|
||||||
filters: {
|
|
||||||
state: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const alertsPageSlice = createSlice({
|
|
||||||
name: "alertsPage",
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
updateAlertFilters: (
|
|
||||||
state,
|
|
||||||
{ payload }: PayloadAction<Partial<AlertFilters>>
|
|
||||||
) => {
|
|
||||||
Object.assign(state.filters, payload);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { updateAlertFilters } = alertsPageSlice.actions;
|
|
||||||
|
|
||||||
export default alertsPageSlice.reducer;
|
|
|
@ -2,7 +2,6 @@ import { configureStore } from "@reduxjs/toolkit";
|
||||||
import queryPageSlice from "./queryPageSlice";
|
import queryPageSlice from "./queryPageSlice";
|
||||||
import settingsSlice from "./settingsSlice";
|
import settingsSlice from "./settingsSlice";
|
||||||
import targetsPageSlice from "./targetsPageSlice";
|
import targetsPageSlice from "./targetsPageSlice";
|
||||||
import alertsPageSlice from "./alertsPageSlice";
|
|
||||||
import { localStorageMiddleware } from "./localStorageMiddleware";
|
import { localStorageMiddleware } from "./localStorageMiddleware";
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
|
@ -10,7 +9,6 @@ const store = configureStore({
|
||||||
settings: settingsSlice,
|
settings: settingsSlice,
|
||||||
queryPage: queryPageSlice,
|
queryPage: queryPageSlice,
|
||||||
targetsPage: targetsPageSlice,
|
targetsPage: targetsPageSlice,
|
||||||
alertsPage: alertsPageSlice,
|
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().prepend(localStorageMiddleware.middleware),
|
getDefaultMiddleware().prepend(localStorageMiddleware.middleware),
|
||||||
|
|
Loading…
Reference in a new issue