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-router-dom": "^6.22.1",
"uplot": "^1.6.30",
"uplot-react": "^1.2.2"
"uplot-react": "^1.2.2",
"use-query-params": "^2.2.1"
},
"devDependencies": {
"@types/react": "^18.2.55",

View file

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

View file

@ -8,26 +8,37 @@ import {
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
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 { Target, TargetsResult } from "../../api/responseTypes/targets";
import React, { FC, useState } from "react";
import React, { FC } from "react";
import {
humanizeDurationRelative,
humanizeDuration,
now,
} from "../../lib/formatTime";
import { LabelBadges } from "../../components/LabelBadges";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import { setCollapsedPools } from "../../state/targetsPageSlice";
import {
setCollapsedPools,
setShowLimitAlert,
} from "../../state/targetsPageSlice";
import EndpointLink from "../../components/EndpointLink";
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 { BooleanParam, useQueryParam, withDefault } from "use-query-params";
type ScrapePool = {
targets: Target[];
@ -119,16 +130,21 @@ const groupTargets = (
type ScrapePoolListProp = {
poolNames: string[];
selectedPool: string | null;
limited: boolean;
healthFilter: string[];
searchFilter: string;
};
const ScrapePoolList: FC<ScrapePoolListProp> = ({
poolNames,
selectedPool,
limited,
healthFilter,
searchFilter,
}) => {
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.
const {
@ -143,11 +159,14 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
},
});
const { healthFilter, searchFilter, collapsedPools } = useAppSelector(
const { collapsedPools, showLimitAlert } = useAppSelector(
(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(
(target) =>
healthFilter.length === 0 ||
@ -157,7 +176,7 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
search === ""
? healthFilteredTargets
: kvSearch
.filter(searchFilter, healthFilteredTargets)
.filter(search, healthFilteredTargets)
.map((value) => value.original);
const allPools = groupTargets(
@ -194,13 +213,15 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
</Alert>
)
)}
{limited && (
{showLimitAlert && (
<Alert
title="Found many pools, showing only one"
icon={<IconInfoCircle size={14} />}
withCloseButton
onClose={() => dispatch(setShowLimitAlert(false))}
>
There are more than 20 scrape pools. Showing only the first one. Use
the dropdown to select a different pool.
There are more than {targetPoolDisplayLimit} scrape pools. Showing
only the first one. Use the dropdown to select a different pool.
</Alert>
)}
<Accordion
@ -283,10 +304,10 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
<Table.Thead>
<Table.Tr>
<Table.Th w="30%">Endpoint</Table.Th>
<Table.Th w={100}>State</Table.Th>
<Table.Th>Labels</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.Thead>
<Table.Tbody>
@ -307,13 +328,7 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
globalUrl={target.globalUrl}
/>
</Table.Td>
<Table.Td>
<Badge
className={healthBadgeClass(target.health)}
>
{target.health}
</Badge>
</Table.Td>
<Table.Td>
<TargetLabels
labels={target.labels}
@ -321,15 +336,53 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
/>
</Table.Td>
<Table.Td>
{humanizeDurationRelative(
target.lastScrape,
now()
)}
<Group gap="xs" wrap="wrap">
<Tooltip
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>
{humanizeDuration(
target.lastScrapeDuration * 1000
)}
<Badge
className={healthBadgeClass(target.health)}
>
{target.health}
</Badge>
</Table.Td>
</Table.Tr>
{target.lastError && (

View file

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

View file

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

View file

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

View file

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

View file

@ -136,7 +136,8 @@
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1",
"uplot": "^1.6.30",
"uplot-react": "^1.2.2"
"uplot-react": "^1.2.2",
"use-query-params": "^2.2.1"
},
"devDependencies": {
"@types/react": "^18.2.55",
@ -313,6 +314,29 @@
"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": {
"name": "@prometheus-io/codemirror-promql",
"version": "0.53.1",
@ -6879,6 +6903,12 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",