Better target table formatting, store filters in URL

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-08-10 20:24:56 +02:00
parent b2a8657d58
commit 648751568d
8 changed files with 295 additions and 226 deletions

View file

@ -39,7 +39,8 @@
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-router-dom": "^6.22.1", "react-router-dom": "^6.22.1",
"uplot": "^1.6.30", "uplot": "^1.6.30",
"uplot-react": "^1.2.2" "uplot-react": "^1.2.2",
"use-query-params": "^2.2.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.55", "@types/react": "^18.2.55",

View file

@ -60,6 +60,8 @@ import { useAppDispatch } from "./state/hooks";
import { updateSettings, useSettings } from "./state/settingsSlice"; import { updateSettings, useSettings } from "./state/settingsSlice";
import SettingsMenu from "./components/SettingsMenu"; import SettingsMenu from "./components/SettingsMenu";
import ReadinessWrapper from "./components/ReadinessWrapper"; import ReadinessWrapper from "./components/ReadinessWrapper";
import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -295,134 +297,136 @@ function App() {
return ( return (
<BrowserRouter basename={pathPrefix}> <BrowserRouter basename={pathPrefix}>
<MantineProvider defaultColorScheme="auto" theme={theme}> <QueryParamProvider adapter={ReactRouter6Adapter}>
<Notifications position="top-right" /> <MantineProvider defaultColorScheme="auto" theme={theme}>
<Notifications position="top-right" />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppShell <AppShell
header={{ height: 56 }} header={{ height: 56 }}
navbar={{ navbar={{
width: 300, width: 300,
// TODO: On pages with a long title like "/status", the navbar // TODO: On pages with a long title like "/status", the navbar
// breaks in an ugly way for narrow windows. Fix this. // breaks in an ugly way for narrow windows. Fix this.
breakpoint: "sm", breakpoint: "sm",
collapsed: { desktop: true, mobile: !opened }, collapsed: { desktop: true, mobile: !opened },
}} }}
padding="md" padding="md"
> >
<AppShell.Header bg="rgb(65, 73, 81)" c="#fff"> <AppShell.Header bg="rgb(65, 73, 81)" c="#fff">
<Group h="100%" px="md"> <Group h="100%" px="md">
<Group style={{ flex: 1 }} justify="space-between"> <Group style={{ flex: 1 }} justify="space-between">
<Group gap={65}> <Group gap={65}>
<Link <Link
to="/" to="/"
style={{ textDecoration: "none", color: "white" }} style={{ textDecoration: "none", color: "white" }}
> >
<Group gap={10}> <Group gap={10}>
<img src={PrometheusLogo} height={30} /> <img src={PrometheusLogo} height={30} />
<Text fz={20}>Prometheus{agentMode && " Agent"}</Text> <Text fz={20}>Prometheus{agentMode && " Agent"}</Text>
</Group>
</Link>
<Group gap={12} visibleFrom="sm">
{navLinks}
</Group> </Group>
</Link> </Group>
<Group gap={12} visibleFrom="sm"> <Group visibleFrom="xs">
{navLinks} <ThemeSelector />
<SettingsMenu />
</Group> </Group>
</Group> </Group>
<Group visibleFrom="xs"> <Burger
<ThemeSelector /> opened={opened}
<SettingsMenu /> onClick={toggle}
</Group> hiddenFrom="sm"
size="sm"
color="gray.2"
/>
</Group> </Group>
<Burger </AppShell.Header>
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
color="gray.2"
/>
</Group>
</AppShell.Header>
<AppShell.Navbar py="md" px={4} bg="rgb(65, 73, 81)" c="#fff"> <AppShell.Navbar py="md" px={4} bg="rgb(65, 73, 81)" c="#fff">
{navLinks} {navLinks}
<Group mt="md" hiddenFrom="xs" justify="center"> <Group mt="md" hiddenFrom="xs" justify="center">
<ThemeSelector /> <ThemeSelector />
<SettingsMenu /> <SettingsMenu />
</Group> </Group>
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main>
<ErrorBoundary key={location.pathname}> <ErrorBoundary key={location.pathname}>
<Suspense <Suspense
fallback={ fallback={
<Box mt="lg"> <Box mt="lg">
{Array.from(Array(10), (_, i) => ( {Array.from(Array(10), (_, i) => (
<Skeleton <Skeleton
key={i} key={i}
height={40} height={40}
mb={15} mb={15}
width={1000} width={1000}
mx="auto" mx="auto"
/>
))}
</Box>
}
>
<Routes>
<Route
path="/"
element={
<Navigate
to={agentMode ? "/agent" : "/query"}
replace
/>
}
/>
{agentMode ? (
<Route
path="/agent"
element={
<ReadinessWrapper>
<AgentPage />
</ReadinessWrapper>
}
/>
) : (
<>
<Route
path="/query"
element={
<ReadinessWrapper>
<QueryPage />
</ReadinessWrapper>
}
/>
<Route
path="/alerts"
element={
<ReadinessWrapper>
<AlertsPage />
</ReadinessWrapper>
}
/>
</>
)}
{allStatusPages.map((p) => (
<Route
key={p.path}
path={p.path}
element={
<ReadinessWrapper>{p.element}</ReadinessWrapper>
}
/> />
))} ))}
</Box> </Routes>
} </Suspense>
> </ErrorBoundary>
<Routes> </AppShell.Main>
<Route </AppShell>
path="/" {/* <ReactQueryDevtools initialIsOpen={false} /> */}
element={ </QueryClientProvider>
<Navigate </MantineProvider>
to={agentMode ? "/agent" : "/query"} </QueryParamProvider>
replace
/>
}
/>
{agentMode ? (
<Route
path="/agent"
element={
<ReadinessWrapper>
<AgentPage />
</ReadinessWrapper>
}
/>
) : (
<>
<Route
path="/query"
element={
<ReadinessWrapper>
<QueryPage />
</ReadinessWrapper>
}
/>
<Route
path="/alerts"
element={
<ReadinessWrapper>
<AlertsPage />
</ReadinessWrapper>
}
/>
</>
)}
{allStatusPages.map((p) => (
<Route
key={p.path}
path={p.path}
element={
<ReadinessWrapper>{p.element}</ReadinessWrapper>
}
/>
))}
</Routes>
</Suspense>
</ErrorBoundary>
</AppShell.Main>
</AppShell>
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider>
</MantineProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View file

@ -8,26 +8,37 @@ import {
Stack, Stack,
Table, Table,
Text, Text,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { KVSearch } from "@nexucis/kvsearch"; import { KVSearch } from "@nexucis/kvsearch";
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; import {
IconAlertTriangle,
IconHourglass,
IconInfoCircle,
IconRefresh,
} from "@tabler/icons-react";
import { useSuspenseAPIQuery } from "../../api/api"; import { useSuspenseAPIQuery } from "../../api/api";
import { Target, TargetsResult } from "../../api/responseTypes/targets"; import { Target, TargetsResult } from "../../api/responseTypes/targets";
import React, { FC, useState } from "react"; import React, { FC } from "react";
import { import {
humanizeDurationRelative, humanizeDurationRelative,
humanizeDuration, humanizeDuration,
now, now,
} from "../../lib/formatTime"; } from "../../lib/formatTime";
import { LabelBadges } from "../../components/LabelBadges";
import { useAppDispatch, useAppSelector } from "../../state/hooks"; import { useAppDispatch, useAppSelector } from "../../state/hooks";
import { setCollapsedPools } from "../../state/targetsPageSlice"; import {
setCollapsedPools,
setShowLimitAlert,
} from "../../state/targetsPageSlice";
import EndpointLink from "../../components/EndpointLink"; import EndpointLink from "../../components/EndpointLink";
import CustomInfiniteScroll from "../../components/CustomInfiniteScroll"; import CustomInfiniteScroll from "../../components/CustomInfiniteScroll";
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 TargetLabels from "./TargetLabels"; import TargetLabels from "./TargetLabels";
import { useDebouncedValue } from "@mantine/hooks";
import { targetPoolDisplayLimit } from "./TargetsPage";
import { BooleanParam, useQueryParam, withDefault } from "use-query-params";
type ScrapePool = { type ScrapePool = {
targets: Target[]; targets: Target[];
@ -119,16 +130,21 @@ const groupTargets = (
type ScrapePoolListProp = { type ScrapePoolListProp = {
poolNames: string[]; poolNames: string[];
selectedPool: string | null; selectedPool: string | null;
limited: boolean; healthFilter: string[];
searchFilter: string;
}; };
const ScrapePoolList: FC<ScrapePoolListProp> = ({ const ScrapePoolList: FC<ScrapePoolListProp> = ({
poolNames, poolNames,
selectedPool, selectedPool,
limited, healthFilter,
searchFilter,
}) => { }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [showEmptyPools, setShowEmptyPools] = useState(true); const [showEmptyPools, setShowEmptyPools] = useQueryParam(
"showEmptyPools",
withDefault(BooleanParam, true)
);
// Based on the selected pool (if any), load the list of targets. // Based on the selected pool (if any), load the list of targets.
const { const {
@ -143,11 +159,14 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
}, },
}); });
const { healthFilter, searchFilter, collapsedPools } = useAppSelector( const { collapsedPools, showLimitAlert } = useAppSelector(
(state) => state.targetsPage (state) => state.targetsPage
); );
const search = searchFilter.trim(); const [debouncedSearch] = useDebouncedValue<string>(searchFilter, 250);
// TODO: Memoize all this computation, especially groupTargets().
const search = debouncedSearch.trim();
const healthFilteredTargets = activeTargets.filter( const healthFilteredTargets = activeTargets.filter(
(target) => (target) =>
healthFilter.length === 0 || healthFilter.length === 0 ||
@ -157,7 +176,7 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
search === "" search === ""
? healthFilteredTargets ? healthFilteredTargets
: kvSearch : kvSearch
.filter(searchFilter, healthFilteredTargets) .filter(search, healthFilteredTargets)
.map((value) => value.original); .map((value) => value.original);
const allPools = groupTargets( const allPools = groupTargets(
@ -194,13 +213,15 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
</Alert> </Alert>
) )
)} )}
{limited && ( {showLimitAlert && (
<Alert <Alert
title="Found many pools, showing only one" title="Found many pools, showing only one"
icon={<IconInfoCircle size={14} />} icon={<IconInfoCircle size={14} />}
withCloseButton
onClose={() => dispatch(setShowLimitAlert(false))}
> >
There are more than 20 scrape pools. Showing only the first one. Use There are more than {targetPoolDisplayLimit} scrape pools. Showing
the dropdown to select a different pool. only the first one. Use the dropdown to select a different pool.
</Alert> </Alert>
)} )}
<Accordion <Accordion
@ -283,10 +304,10 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th w="30%">Endpoint</Table.Th> <Table.Th w="30%">Endpoint</Table.Th>
<Table.Th w={100}>State</Table.Th>
<Table.Th>Labels</Table.Th> <Table.Th>Labels</Table.Th>
<Table.Th w="10%">Last scrape</Table.Th> <Table.Th w="10%">Last scrape</Table.Th>
<Table.Th w="10%">Scrape duration</Table.Th> {/* <Table.Th w="10%">Scrape duration</Table.Th> */}
<Table.Th w={100}>State</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@ -307,13 +328,7 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
globalUrl={target.globalUrl} globalUrl={target.globalUrl}
/> />
</Table.Td> </Table.Td>
<Table.Td>
<Badge
className={healthBadgeClass(target.health)}
>
{target.health}
</Badge>
</Table.Td>
<Table.Td> <Table.Td>
<TargetLabels <TargetLabels
labels={target.labels} labels={target.labels}
@ -321,15 +336,53 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{humanizeDurationRelative( <Group gap="xs" wrap="wrap">
target.lastScrape, <Tooltip
now() label="Last target scrape"
)} withArrow
>
<Badge
variant="light"
className={badgeClasses.statsBadge}
styles={{
label: { textTransform: "none" },
}}
leftSection={<IconRefresh size={12} />}
>
{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 size={12} />
}
>
{humanizeDuration(
target.lastScrapeDuration * 1000
)}
</Badge>
</Tooltip>
</Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{humanizeDuration( <Badge
target.lastScrapeDuration * 1000 className={healthBadgeClass(target.health)}
)} >
{target.health}
</Badge>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
{target.lastError && ( {target.lastError && (

View file

@ -1,14 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { Labels } from "../../api/responseTypes/targets"; import { Labels } from "../../api/responseTypes/targets";
import { LabelBadges } from "../../components/LabelBadges"; import { LabelBadges } from "../../components/LabelBadges";
import { import { ActionIcon, Collapse, Group, Stack, Text } from "@mantine/core";
ActionIcon,
Collapse,
Divider,
Group,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";

View file

@ -12,21 +12,25 @@ import {
IconSearch, IconSearch,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { StateMultiSelect } from "../../components/StateMultiSelect"; import { StateMultiSelect } from "../../components/StateMultiSelect";
import { useSuspenseAPIQuery } from "../../api/api"; import { Suspense } from "react";
import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools";
import { Suspense, useEffect } from "react";
import badgeClasses from "../../Badge.module.css"; import badgeClasses from "../../Badge.module.css";
import { useAppDispatch, useAppSelector } from "../../state/hooks"; import { useAppDispatch, useAppSelector } from "../../state/hooks";
import { import {
setCollapsedPools, setCollapsedPools,
setHealthFilter, setShowLimitAlert,
setSearchFilter,
setSelectedPool,
} from "../../state/targetsPageSlice"; } from "../../state/targetsPageSlice";
import ScrapePoolList from "./ScrapePoolsList"; import {
ArrayParam,
StringParam,
useQueryParam,
withDefault,
} from "use-query-params";
import ErrorBoundary from "../../components/ErrorBoundary"; import ErrorBoundary from "../../components/ErrorBoundary";
import ScrapePoolList from "./ScrapePoolsList";
import { useSuspenseAPIQuery } from "../../api/api";
import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools";
const scrapePoolQueryParam = "scrapePool"; export const targetPoolDisplayLimit = 3;
export default function TargetsPage() { export default function TargetsPage() {
// Load the list of all available scrape pools. // Load the list of all available scrape pools.
@ -40,25 +44,33 @@ export default function TargetsPage() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
// If there is a selected pool in the URL, extract it on initial load. const [scrapePool, setScrapePool] = useQueryParam("scrapePool", StringParam);
useEffect(() => { const [healthFilter, setHealthFilter] = useQueryParam(
const uriPool = new URLSearchParams(window.location.search).get( "healthFilter",
scrapePoolQueryParam withDefault(ArrayParam, [])
); );
if (uriPool !== null) { const [searchFilter, setSearchFilter] = useQueryParam(
dispatch(setSelectedPool(uriPool)); "searchFilter",
} withDefault(StringParam, "")
}, [dispatch]); );
const { selectedPool, healthFilter, searchFilter, collapsedPools } = const { collapsedPools, showLimitAlert } = useAppSelector(
useAppSelector((state) => state.targetsPage); (state) => state.targetsPage
);
let poolToShow = selectedPool; // When we have more than X targets, we want to limit the display by selecting the first
let limitedDueToManyPools = false; // scrape pool and reflecting that in the URL as well. We also want to show an alert
// about the fact that we are limiting the display, but the tricky bit is that this
if (poolToShow === null && scrapePools.length > 20) { // alert should only be shown once, upon the first "redirect" that causes the limiting,
poolToShow = scrapePools[0]; // not again when the page is reloaded with the same URL parameters. That's why we remember
limitedDueToManyPools = true; // `showLimitAlert` in Redux (just useState() doesn't work properly, because the component
// for some Suspense-related reasons seems to be mounted/unmounted multiple times, so the
// state cell would get initialized multiple times as well).
const limited =
scrapePools.length > targetPoolDisplayLimit && scrapePool === undefined;
if (limited) {
setScrapePool(scrapePools[0]);
dispatch(setShowLimitAlert(true));
} }
return ( return (
@ -67,8 +79,11 @@ export default function TargetsPage() {
<Select <Select
placeholder="Select scrape pool" placeholder="Select scrape pool"
data={[{ label: "All pools", value: "" }, ...scrapePools]} data={[{ label: "All pools", value: "" }, ...scrapePools]}
value={selectedPool} value={(limited && scrapePools[0]) || scrapePool || null}
onChange={(value) => dispatch(setSelectedPool(value || null))} onChange={(value) => {
setScrapePool(value);
showLimitAlert && dispatch(setShowLimitAlert(false));
}}
searchable searchable
/> />
<StateMultiSelect <StateMultiSelect
@ -81,17 +96,15 @@ export default function TargetsPage() {
: badgeClasses.healthErr : badgeClasses.healthErr
} }
placeholder="Filter by target state" placeholder="Filter by target state"
values={healthFilter} values={(healthFilter?.filter((v) => v !== null) as string[]) || []}
onChange={(values) => dispatch(setHealthFilter(values))} onChange={(values) => setHealthFilter(values)}
/> />
<TextInput <TextInput
flex={1} flex={1}
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
placeholder="Filter by endpoint or labels" placeholder="Filter by endpoint or labels"
value={searchFilter} value={searchFilter || ""}
onChange={(event) => onChange={(event) => setSearchFilter(event.currentTarget.value)}
dispatch(setSearchFilter(event.currentTarget.value))
}
></TextInput> ></TextInput>
<ActionIcon <ActionIcon
size="input-sm" size="input-sm"
@ -127,8 +140,9 @@ export default function TargetsPage() {
> >
<ScrapePoolList <ScrapePoolList
poolNames={scrapePools} poolNames={scrapePools}
selectedPool={poolToShow} selectedPool={(limited && scrapePools[0]) || scrapePool || null}
limited={limitedDueToManyPools} healthFilter={healthFilter as string[]}
searchFilter={searchFilter}
/> />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>

View file

@ -2,9 +2,7 @@ import { createListenerMiddleware } from "@reduxjs/toolkit";
import { AppDispatch, RootState } from "./store"; import { AppDispatch, RootState } from "./store";
import { import {
localStorageKeyCollapsedPools, localStorageKeyCollapsedPools,
localStorageKeyTargetHealthFilter,
setCollapsedPools, setCollapsedPools,
setHealthFilter,
} from "./targetsPageSlice"; } from "./targetsPageSlice";
import { updateSettings } from "./settingsSlice"; import { updateSettings } from "./settingsSlice";
@ -26,13 +24,6 @@ startAppListening({
}, },
}); });
startAppListening({
actionCreator: setHealthFilter,
effect: ({ payload }) => {
persistToLocalStorage(localStorageKeyTargetHealthFilter, payload);
},
});
startAppListening({ startAppListening({
actionCreator: updateSettings, actionCreator: updateSettings,
effect: ({ payload }) => { effect: ({ payload }) => {

View file

@ -5,49 +5,32 @@ export const localStorageKeyCollapsedPools = "targetsPage.collapsedPools";
export const localStorageKeyTargetHealthFilter = "targetsPage.healthFilter"; export const localStorageKeyTargetHealthFilter = "targetsPage.healthFilter";
interface TargetsPage { interface TargetsPage {
selectedPool: string | null;
healthFilter: string[];
searchFilter: string;
collapsedPools: string[]; collapsedPools: string[];
showLimitAlert: boolean;
} }
const initialState: TargetsPage = { const initialState: TargetsPage = {
selectedPool: null,
healthFilter: initializeFromLocalStorage<string[]>(
localStorageKeyTargetHealthFilter,
[]
),
searchFilter: "",
collapsedPools: initializeFromLocalStorage<string[]>( collapsedPools: initializeFromLocalStorage<string[]>(
localStorageKeyCollapsedPools, localStorageKeyCollapsedPools,
[] []
), ),
showLimitAlert: false,
}; };
export const targetsPageSlice = createSlice({ export const targetsPageSlice = createSlice({
name: "targetsPage", name: "targetsPage",
initialState, initialState,
reducers: { reducers: {
setSelectedPool: (state, { payload }: PayloadAction<string | null>) => {
state.selectedPool = payload;
},
setHealthFilter: (state, { payload }: PayloadAction<string[]>) => {
state.healthFilter = payload;
},
setSearchFilter: (state, { payload }: PayloadAction<string>) => {
state.searchFilter = payload;
},
setCollapsedPools: (state, { payload }: PayloadAction<string[]>) => { setCollapsedPools: (state, { payload }: PayloadAction<string[]>) => {
state.collapsedPools = payload; state.collapsedPools = payload;
}, },
setShowLimitAlert: (state, { payload }: PayloadAction<boolean>) => {
state.showLimitAlert = payload;
},
}, },
}); });
export const { export const { setCollapsedPools, setShowLimitAlert } =
setSelectedPool, targetsPageSlice.actions;
setHealthFilter,
setSearchFilter,
setCollapsedPools,
} = targetsPageSlice.actions;
export default targetsPageSlice.reducer; export default targetsPageSlice.reducer;

View file

@ -136,7 +136,8 @@
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-router-dom": "^6.22.1", "react-router-dom": "^6.22.1",
"uplot": "^1.6.30", "uplot": "^1.6.30",
"uplot-react": "^1.2.2" "uplot-react": "^1.2.2",
"use-query-params": "^2.2.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.55", "@types/react": "^18.2.55",
@ -313,6 +314,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"mantine-ui/node_modules/use-query-params": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-2.2.1.tgz",
"integrity": "sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q==",
"license": "ISC",
"dependencies": {
"serialize-query-params": "^2.0.2"
},
"peerDependencies": {
"@reach/router": "^1.2.1",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"react-router-dom": ">=5"
},
"peerDependenciesMeta": {
"@reach/router": {
"optional": true
},
"react-router-dom": {
"optional": true
}
}
},
"module/codemirror-promql": { "module/codemirror-promql": {
"name": "@prometheus-io/codemirror-promql", "name": "@prometheus-io/codemirror-promql",
"version": "0.53.1", "version": "0.53.1",
@ -6879,6 +6903,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/serialize-query-params": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-2.0.2.tgz",
"integrity": "sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",