mirror of
https://github.com/prometheus/prometheus.git
synced 2025-08-05 13:47:10 +02:00
Optimize memoization and search debouncing on /targets page (#16589)
Moving the debouncing of the search field to the parent component and then memoizing the ScrapePoolsList component prevents a lot of superfluous re-renders of the entire scrape pools list that previously got triggered immediately when you typed in the search box or even just collapsed a pool. (While the computation of what data to show was already memoized in the ScrapePoolList component, the component itself still had to re-render a lot with the same data.) Discovered this problem + verified fix using react-scan. Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
8b0d33e5b2
commit
5c06804df8
@ -19,7 +19,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useSuspenseAPIQuery } from "../../api/api";
|
||||
import { Target, TargetsResult } from "../../api/responseTypes/targets";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import React, { FC, memo, useMemo } from "react";
|
||||
import {
|
||||
humanizeDurationRelative,
|
||||
humanizeDuration,
|
||||
@ -37,7 +37,6 @@ import CustomInfiniteScroll from "../../components/CustomInfiniteScroll";
|
||||
import badgeClasses from "../../Badge.module.css";
|
||||
import panelClasses from "../../Panel.module.css";
|
||||
import TargetLabels from "./TargetLabels";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { targetPoolDisplayLimit } from "./TargetsPage";
|
||||
import { badgeIconStyle } from "../../styles";
|
||||
|
||||
@ -145,278 +144,280 @@ type ScrapePoolListProp = {
|
||||
searchFilter: string;
|
||||
};
|
||||
|
||||
const ScrapePoolList: FC<ScrapePoolListProp> = ({
|
||||
poolNames,
|
||||
selectedPool,
|
||||
healthFilter,
|
||||
searchFilter,
|
||||
}) => {
|
||||
// Based on the selected pool (if any), load the list of targets.
|
||||
const {
|
||||
data: {
|
||||
data: { activeTargets },
|
||||
},
|
||||
} = useSuspenseAPIQuery<TargetsResult>({
|
||||
path: `/targets`,
|
||||
params: {
|
||||
state: "active",
|
||||
scrapePool: selectedPool === null ? "" : selectedPool,
|
||||
},
|
||||
});
|
||||
const ScrapePoolList: FC<ScrapePoolListProp> = memo(
|
||||
({ poolNames, selectedPool, healthFilter, searchFilter }) => {
|
||||
// Based on the selected pool (if any), load the list of targets.
|
||||
const {
|
||||
data: {
|
||||
data: { activeTargets },
|
||||
},
|
||||
} = useSuspenseAPIQuery<TargetsResult>({
|
||||
path: `/targets`,
|
||||
params: {
|
||||
state: "active",
|
||||
scrapePool: selectedPool === null ? "" : selectedPool,
|
||||
},
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [showEmptyPools, setShowEmptyPools] = useLocalStorage<boolean>({
|
||||
key: "targetsPage.showEmptyPools",
|
||||
defaultValue: false,
|
||||
});
|
||||
const dispatch = useAppDispatch();
|
||||
const [showEmptyPools, setShowEmptyPools] = useLocalStorage<boolean>({
|
||||
key: "targetsPage.showEmptyPools",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const { collapsedPools, showLimitAlert } = useAppSelector(
|
||||
(state) => state.targetsPage
|
||||
);
|
||||
const { collapsedPools, showLimitAlert } = useAppSelector(
|
||||
(state) => state.targetsPage
|
||||
);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
|
||||
const allPools = useMemo(
|
||||
() =>
|
||||
buildPoolsData(
|
||||
selectedPool ? [selectedPool] : poolNames,
|
||||
activeTargets,
|
||||
searchFilter,
|
||||
healthFilter
|
||||
),
|
||||
[selectedPool, poolNames, activeTargets, searchFilter, healthFilter]
|
||||
);
|
||||
|
||||
const allPools = useMemo(
|
||||
() =>
|
||||
buildPoolsData(
|
||||
selectedPool ? [selectedPool] : poolNames,
|
||||
activeTargets,
|
||||
debouncedSearch,
|
||||
healthFilter
|
||||
),
|
||||
[selectedPool, poolNames, activeTargets, debouncedSearch, healthFilter]
|
||||
);
|
||||
const allPoolNames = Object.keys(allPools);
|
||||
const shownPoolNames = showEmptyPools
|
||||
? allPoolNames
|
||||
: allPoolNames.filter((pn) => allPools[pn].targets.length !== 0);
|
||||
|
||||
const allPoolNames = Object.keys(allPools);
|
||||
const shownPoolNames = showEmptyPools
|
||||
? allPoolNames
|
||||
: allPoolNames.filter((pn) => allPools[pn].targets.length !== 0);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{allPoolNames.length === 0 ? (
|
||||
<Alert title="No scrape pools found" icon={<IconInfoCircle />}>
|
||||
No scrape pools found.
|
||||
</Alert>
|
||||
) : (
|
||||
!showEmptyPools &&
|
||||
allPoolNames.length !== shownPoolNames.length && (
|
||||
<Alert
|
||||
title="Hiding pools with no matching targets"
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
Hiding {allPoolNames.length - shownPoolNames.length} empty pools due
|
||||
to filters or no targets.
|
||||
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyPools(true)}>
|
||||
Show empty pools
|
||||
</Anchor>
|
||||
return (
|
||||
<Stack>
|
||||
{allPoolNames.length === 0 ? (
|
||||
<Alert title="No scrape pools found" icon={<IconInfoCircle />}>
|
||||
No scrape pools found.
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
{showLimitAlert && (
|
||||
<Alert
|
||||
title="Found many pools, showing only one"
|
||||
icon={<IconInfoCircle />}
|
||||
withCloseButton
|
||||
onClose={() => dispatch(setShowLimitAlert(false))}
|
||||
>
|
||||
There are more than {targetPoolDisplayLimit} scrape pools. Showing
|
||||
only the first one. Use the dropdown to select a different pool.
|
||||
</Alert>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
variant="separated"
|
||||
value={allPoolNames.filter((p) => !collapsedPools.includes(p))}
|
||||
onChange={(value) =>
|
||||
dispatch(
|
||||
setCollapsedPools(allPoolNames.filter((p) => !value.includes(p)))
|
||||
)
|
||||
}
|
||||
>
|
||||
{shownPoolNames.map((poolName) => {
|
||||
const pool = allPools[poolName];
|
||||
return (
|
||||
<Accordion.Item
|
||||
key={poolName}
|
||||
value={poolName}
|
||||
className={poolPanelHealthClass(pool)}
|
||||
) : (
|
||||
!showEmptyPools &&
|
||||
allPoolNames.length !== shownPoolNames.length && (
|
||||
<Alert
|
||||
title="Hiding pools with no matching targets"
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{poolName}</Text>
|
||||
<Group gap="xs">
|
||||
<Text c="gray.6">
|
||||
{pool.upCount} / {pool.count} up
|
||||
</Text>
|
||||
<RingProgress
|
||||
size={25}
|
||||
thickness={5}
|
||||
sections={
|
||||
pool.count === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: (pool.upCount / pool.count) * 100,
|
||||
color: "green.4",
|
||||
},
|
||||
{
|
||||
value: (pool.unknownCount / pool.count) * 100,
|
||||
color: "gray.4",
|
||||
},
|
||||
{
|
||||
value: (pool.downCount / pool.count) * 100,
|
||||
color: "red.5",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
Hiding {allPoolNames.length - shownPoolNames.length} empty pools
|
||||
due to filters or no targets.
|
||||
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyPools(true)}>
|
||||
Show empty pools
|
||||
</Anchor>
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
{showLimitAlert && (
|
||||
<Alert
|
||||
title="Found many pools, showing only one"
|
||||
icon={<IconInfoCircle />}
|
||||
withCloseButton
|
||||
onClose={() => dispatch(setShowLimitAlert(false))}
|
||||
>
|
||||
There are more than {targetPoolDisplayLimit} scrape pools. Showing
|
||||
only the first one. Use the dropdown to select a different pool.
|
||||
</Alert>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
variant="separated"
|
||||
value={allPoolNames.filter((p) => !collapsedPools.includes(p))}
|
||||
onChange={(value) =>
|
||||
dispatch(
|
||||
setCollapsedPools(allPoolNames.filter((p) => !value.includes(p)))
|
||||
)
|
||||
}
|
||||
>
|
||||
{shownPoolNames.map((poolName) => {
|
||||
const pool = allPools[poolName];
|
||||
return (
|
||||
<Accordion.Item
|
||||
key={poolName}
|
||||
value={poolName}
|
||||
className={poolPanelHealthClass(pool)}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{poolName}</Text>
|
||||
<Group gap="xs">
|
||||
<Text c="gray.6">
|
||||
{pool.upCount} / {pool.count} up
|
||||
</Text>
|
||||
<RingProgress
|
||||
size={25}
|
||||
thickness={5}
|
||||
sections={
|
||||
pool.count === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: (pool.upCount / pool.count) * 100,
|
||||
color: "green.4",
|
||||
},
|
||||
{
|
||||
value: (pool.unknownCount / pool.count) * 100,
|
||||
color: "gray.4",
|
||||
},
|
||||
{
|
||||
value: (pool.downCount / pool.count) * 100,
|
||||
color: "red.5",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{pool.count === 0 ? (
|
||||
<Alert title="No targets" icon={<IconInfoCircle />}>
|
||||
No active targets in this scrape pool.
|
||||
<Anchor
|
||||
ml="md"
|
||||
fz="1em"
|
||||
onClick={() => setShowEmptyPools(false)}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{pool.count === 0 ? (
|
||||
<Alert title="No targets" icon={<IconInfoCircle />}>
|
||||
No active targets in this scrape pool.
|
||||
<Anchor
|
||||
ml="md"
|
||||
fz="1em"
|
||||
onClick={() => setShowEmptyPools(false)}
|
||||
>
|
||||
Hide empty pools
|
||||
</Anchor>
|
||||
</Alert>
|
||||
) : pool.targets.length === 0 ? (
|
||||
<Alert
|
||||
title="No matching targets"
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
Hide empty pools
|
||||
</Anchor>
|
||||
</Alert>
|
||||
) : pool.targets.length === 0 ? (
|
||||
<Alert title="No matching targets" icon={<IconInfoCircle />}>
|
||||
No targets in this pool match your filter criteria (omitted{" "}
|
||||
{pool.count} filtered targets).
|
||||
<Anchor
|
||||
ml="md"
|
||||
fz="1em"
|
||||
onClick={() => setShowEmptyPools(false)}
|
||||
>
|
||||
Hide empty pools
|
||||
</Anchor>
|
||||
</Alert>
|
||||
) : (
|
||||
<CustomInfiniteScroll
|
||||
allItems={pool.targets}
|
||||
child={({ items }) => (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w="25%">Endpoint</Table.Th>
|
||||
<Table.Th>Labels</Table.Th>
|
||||
<Table.Th w={230}>Last scrape</Table.Th>
|
||||
<Table.Th w={100}>State</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((target, i) => (
|
||||
// TODO: Find a stable and definitely unique key.
|
||||
<React.Fragment key={i}>
|
||||
<Table.Tr
|
||||
style={{
|
||||
borderBottom: target.lastError
|
||||
? "none"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Table.Td valign="top">
|
||||
<EndpointLink
|
||||
endpoint={target.scrapeUrl}
|
||||
globalUrl={target.globalUrl}
|
||||
/>
|
||||
</Table.Td>
|
||||
No targets in this pool match your filter criteria
|
||||
(omitted {pool.count} filtered targets).
|
||||
<Anchor
|
||||
ml="md"
|
||||
fz="1em"
|
||||
onClick={() => setShowEmptyPools(false)}
|
||||
>
|
||||
Hide empty pools
|
||||
</Anchor>
|
||||
</Alert>
|
||||
) : (
|
||||
<CustomInfiniteScroll
|
||||
allItems={pool.targets}
|
||||
child={({ items }) => (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w="25%">Endpoint</Table.Th>
|
||||
<Table.Th>Labels</Table.Th>
|
||||
<Table.Th w={230}>Last scrape</Table.Th>
|
||||
<Table.Th w={100}>State</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((target, i) => (
|
||||
// TODO: Find a stable and definitely unique key.
|
||||
<React.Fragment key={i}>
|
||||
<Table.Tr
|
||||
style={{
|
||||
borderBottom: target.lastError
|
||||
? "none"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Table.Td valign="top">
|
||||
<EndpointLink
|
||||
endpoint={target.scrapeUrl}
|
||||
globalUrl={target.globalUrl}
|
||||
/>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td valign="top">
|
||||
<TargetLabels
|
||||
labels={target.labels}
|
||||
discoveredLabels={target.discoveredLabels}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td valign="top">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Tooltip
|
||||
label="Last target scrape"
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{
|
||||
label: { textTransform: "none" },
|
||||
}}
|
||||
leftSection={
|
||||
<IconRefresh style={badgeIconStyle} />
|
||||
}
|
||||
<Table.Td valign="top">
|
||||
<TargetLabels
|
||||
labels={target.labels}
|
||||
discoveredLabels={target.discoveredLabels}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td valign="top">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Tooltip
|
||||
label="Last target scrape"
|
||||
withArrow
|
||||
>
|
||||
{humanizeDurationRelative(
|
||||
target.lastScrape,
|
||||
now()
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{
|
||||
label: { textTransform: "none" },
|
||||
}}
|
||||
leftSection={
|
||||
<IconRefresh
|
||||
style={badgeIconStyle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{humanizeDurationRelative(
|
||||
target.lastScrape,
|
||||
now()
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label="Duration of last target scrape"
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{
|
||||
label: { textTransform: "none" },
|
||||
}}
|
||||
leftSection={
|
||||
<IconHourglass
|
||||
style={badgeIconStyle}
|
||||
/>
|
||||
}
|
||||
<Tooltip
|
||||
label="Duration of last target scrape"
|
||||
withArrow
|
||||
>
|
||||
{humanizeDuration(
|
||||
target.lastScrapeDuration * 1000
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td valign="top">
|
||||
<Badge
|
||||
className={healthBadgeClass(target.health)}
|
||||
>
|
||||
{target.health}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{target.lastError && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5} valign="top">
|
||||
<Alert
|
||||
color="red"
|
||||
mb="sm"
|
||||
icon={<IconAlertTriangle />}
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{
|
||||
label: { textTransform: "none" },
|
||||
}}
|
||||
leftSection={
|
||||
<IconHourglass
|
||||
style={badgeIconStyle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{humanizeDuration(
|
||||
target.lastScrapeDuration * 1000
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td valign="top">
|
||||
<Badge
|
||||
className={healthBadgeClass(
|
||||
target.health
|
||||
)}
|
||||
>
|
||||
<strong>Error scraping target:</strong>{" "}
|
||||
{target.lastError}
|
||||
</Alert>
|
||||
{target.health}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
{target.lastError && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5} valign="top">
|
||||
<Alert
|
||||
color="red"
|
||||
mb="sm"
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
<strong>Error scraping target:</strong>{" "}
|
||||
{target.lastError}
|
||||
</Alert>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ScrapePoolList;
|
||||
|
@ -30,9 +30,16 @@ import ScrapePoolList from "./ScrapePoolsList";
|
||||
import { useSuspenseAPIQuery } from "../../api/api";
|
||||
import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools";
|
||||
import { expandIconStyle, inputIconStyle } from "../../styles";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
|
||||
export const targetPoolDisplayLimit = 20;
|
||||
|
||||
// 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 emptyHealthFilter: string[] = [];
|
||||
|
||||
export default function TargetsPage() {
|
||||
// Load the list of all available scrape pools.
|
||||
const {
|
||||
@ -48,12 +55,13 @@ export default function TargetsPage() {
|
||||
const [scrapePool, setScrapePool] = useQueryParam("pool", StringParam);
|
||||
const [healthFilter, setHealthFilter] = useQueryParam(
|
||||
"health",
|
||||
withDefault(ArrayParam, [])
|
||||
withDefault(ArrayParam, emptyHealthFilter)
|
||||
);
|
||||
const [searchFilter, setSearchFilter] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, "")
|
||||
);
|
||||
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
|
||||
|
||||
const { collapsedPools, showLimitAlert } = useAppSelector(
|
||||
(state) => state.targetsPage
|
||||
@ -147,7 +155,7 @@ export default function TargetsPage() {
|
||||
poolNames={scrapePools}
|
||||
selectedPool={(limited && scrapePools[0]) || scrapePool || null}
|
||||
healthFilter={healthFilter as string[]}
|
||||
searchFilter={searchFilter}
|
||||
searchFilter={debouncedSearch}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
Loading…
Reference in New Issue
Block a user