Add filtering, limiting, and more to targets page

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-07-27 16:29:18 +02:00
parent 6321267996
commit b06bb78543
7 changed files with 515 additions and 44 deletions

View File

@ -48,7 +48,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import QueryPage from "./pages/query/QueryPage";
import AlertsPage from "./pages/AlertsPage";
import RulesPage from "./pages/RulesPage";
import TargetsPage from "./pages/TargetsPage";
import TargetsPage from "./pages/targets/TargetsPage";
import ServiceDiscoveryPage from "./pages/ServiceDiscoveryPage";
import StatusPage from "./pages/StatusPage";
import TSDBStatusPage from "./pages/TSDBStatusPage";

View File

@ -5,6 +5,7 @@ import { useLocation } from "react-router-dom";
interface Props {
children?: ReactNode;
title?: string;
}
interface State {
@ -30,7 +31,7 @@ class ErrorBoundary extends Component<Props, State> {
return (
<Alert
color="red"
title="Error querying page data"
title={this.props.title || "Error querying page data"}
icon={<IconAlertTriangle size={14} />}
maw={500}
mx="auto"

View File

@ -0,0 +1,302 @@
import {
Accordion,
Alert,
Badge,
Group,
RingProgress,
Stack,
Table,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
import { useSuspenseAPIQuery } from "../../api/api";
import { Target, TargetsResult } from "../../api/responseTypes/targets";
import React, { FC } from "react";
import badgeClasses from "../../Badge.module.css";
import {
humanizeDurationRelative,
humanizeDuration,
now,
} from "../../lib/formatTime";
import { LabelBadges } from "../../components/LabelBadges";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import { setCollapsedPools } from "../../state/targetsPageSlice";
import EndpointLink from "../../components/EndpointLink";
import CustomInfiniteScroll from "../../components/CustomInfiniteScroll";
type ScrapePool = {
targets: Target[];
count: number;
upCount: number;
downCount: number;
unknownCount: number;
};
type ScrapePools = {
[scrapePool: string]: ScrapePool;
};
const healthBadgeClass = (state: string) => {
switch (state.toLowerCase()) {
case "up":
return badgeClasses.healthOk;
case "down":
return badgeClasses.healthErr;
case "unknown":
return badgeClasses.healthUnknown;
default:
return badgeClasses.warn;
}
};
const groupTargets = (
poolNames: string[],
targets: Target[],
healthFilter: string[]
): ScrapePools => {
const pools: ScrapePools = {};
for (const pn of poolNames) {
pools[pn] = {
targets: [],
count: 0,
upCount: 0,
downCount: 0,
unknownCount: 0,
};
}
for (const target of targets) {
if (!pools[target.scrapePool]) {
// TODO: Should we do better here?
throw new Error(
"Received target information for an unknown scrape pool, likely the list of scrape pools has changed. Please reload the page."
);
}
if (
healthFilter.length === 0 ||
healthFilter.includes(target.health.toLowerCase())
) {
pools[target.scrapePool].targets.push(target);
}
pools[target.scrapePool].count++;
switch (target.health.toLowerCase()) {
case "up":
pools[target.scrapePool].upCount++;
break;
case "down":
pools[target.scrapePool].downCount++;
break;
case "unknown":
pools[target.scrapePool].unknownCount++;
break;
}
}
return pools;
};
type ScrapePoolListProp = {
poolNames: string[];
selectedPool: string | null;
limited: boolean;
};
const ScrapePoolList: FC<ScrapePoolListProp> = ({
poolNames,
selectedPool,
limited,
}) => {
const dispatch = useAppDispatch();
// 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 { healthFilter, collapsedPools } = useAppSelector(
(state) => state.targetsPage
);
const allPools = groupTargets(
selectedPool ? [selectedPool] : poolNames,
activeTargets,
healthFilter
);
const allPoolNames = Object.keys(allPools);
return (
<Stack>
{allPoolNames.length === 0 && (
<Alert title="No matching targets" icon={<IconInfoCircle size={14} />}>
No targets found that match your filter criteria.
</Alert>
)}
{limited && (
<Alert
title="Found many pools, showing only one"
icon={<IconInfoCircle size={14} />}
>
There are more than 20 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)))
)
}
>
{allPoolNames.map((poolName) => {
const pool = allPools[poolName];
return (
<Accordion.Item
key={poolName}
value={poolName}
style={{
borderLeft:
pool.upCount === 0
? "5px solid var(--mantine-color-red-4)"
: pool.upCount !== pool.count
? "5px solid var(--mantine-color-orange-5)"
: "5px solid var(--mantine-color-green-4)",
}}
>
<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={[
{
value: (pool.upCount / pool.count) * 100,
color: "green.4",
},
{
value: (pool.downCount / pool.count) * 100,
color: "red.5",
},
{
value: (pool.unknownCount / pool.count) * 100,
color: "gray.4",
},
]}
/>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
{pool.count === 0 ? (
<Alert title="No targets" icon={<IconInfoCircle />}>
No targets in this scrape pool.
</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).
</Alert>
) : (
<CustomInfiniteScroll
allItems={pool.targets}
child={({ items }) => (
<Table>
<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.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>
{/* TODO: Process target URL like in old UI */}
<EndpointLink
endpoint={target.scrapeUrl}
globalUrl={target.globalUrl}
/>
</Table.Td>
<Table.Td>
<Badge
className={healthBadgeClass(target.health)}
>
{target.health}
</Badge>
</Table.Td>
<Table.Td>
<LabelBadges labels={target.labels} />
</Table.Td>
<Table.Td>
{humanizeDurationRelative(
target.lastScrape,
now()
)}
</Table.Td>
<Table.Td>
{humanizeDuration(
target.lastScrapeDuration * 1000
)}
</Table.Td>
</Table.Tr>
{target.lastError && (
<Table.Tr>
<Table.Td colSpan={5}>
<Alert
color="red"
mb="sm"
icon={<IconAlertTriangle size={14} />}
>
<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;

View File

@ -0,0 +1,134 @@
import {
ActionIcon,
Box,
Checkbox,
Group,
Input,
Select,
Skeleton,
} from "@mantine/core";
import {
IconLayoutNavbarCollapse,
IconLayoutNavbarExpand,
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 badgeClasses from "../../Badge.module.css";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import {
setCollapsedPools,
setHealthFilter,
setSelectedPool,
} from "../../state/targetsPageSlice";
import ScrapePoolList from "./ScrapePoolsList";
import ErrorBoundary from "../../components/ErrorBoundary";
const scrapePoolQueryParam = "scrapePool";
export default function TargetsPage() {
// Load the list of all available scrape pools.
const {
data: {
data: { scrapePools },
},
} = useSuspenseAPIQuery<ScrapePoolsResult>({
path: `/scrape_pools`,
});
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 { selectedPool, healthFilter, collapsedPools } = useAppSelector(
(state) => state.targetsPage
);
let poolToShow = selectedPool;
let limitedDueToManyPools = false;
if (poolToShow === null && scrapePools.length > 20) {
poolToShow = scrapePools[0];
limitedDueToManyPools = true;
}
return (
<>
<Group mb="md" mt="xs">
<Select
placeholder="Select scrape pool"
data={[{ label: "All pools", value: "" }, ...scrapePools]}
value={selectedPool}
onChange={(value) => dispatch(setSelectedPool(value || null))}
searchable
/>
<StateMultiSelect
options={["unknown", "up", "down"]}
optionClass={(o) =>
o === "unknown"
? badgeClasses.healthUnknown
: o === "up"
? badgeClasses.healthOk
: badgeClasses.healthErr
}
placeholder="Filter by target state"
values={healthFilter}
onChange={(values) => dispatch(setHealthFilter(values))}
/>
<Input
flex={1}
leftSection={<IconSearch size={14} />}
placeholder="Filter by endpoint or labels"
></Input>
<ActionIcon
size="input-sm"
title={
collapsedPools.length > 0
? "Expand all pools"
: "Collapse all pools"
}
variant="light"
onClick={() =>
dispatch(
setCollapsedPools(collapsedPools.length > 0 ? [] : scrapePools)
)
}
>
{collapsedPools.length > 0 ? (
<IconLayoutNavbarExpand size={16} />
) : (
<IconLayoutNavbarCollapse size={16} />
)}
</ActionIcon>
</Group>
<ErrorBoundary key={location.pathname} title="Error showing target pools">
<Suspense
fallback={
<Box mt="lg">
{Array.from(Array(10), (_, i) => (
<Skeleton key={i} height={40} mb={15} width={1000} mx="auto" />
))}
</Box>
}
>
<ScrapePoolList
poolNames={scrapePools}
selectedPool={poolToShow}
limited={limitedDueToManyPools}
/>
</Suspense>
</ErrorBoundary>
</>
);
}

View File

@ -18,25 +18,26 @@ import {
IconLayoutNavbarExpand,
IconSearch,
} from "@tabler/icons-react";
import { StateMultiSelect } from "../components/StateMultiSelect";
import { useSuspenseAPIQuery } from "../api/api";
import { ScrapePoolsResult } from "../api/responseTypes/scrapePools";
import { Target, TargetsResult } from "../api/responseTypes/targets";
import React from "react";
import badgeClasses from "../Badge.module.css";
import { StateMultiSelect } from "../../components/StateMultiSelect";
import { useSuspenseAPIQuery } from "../../api/api";
import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools";
import { Target, TargetsResult } from "../../api/responseTypes/targets";
import React, { useEffect } from "react";
import badgeClasses from "../../Badge.module.css";
import {
humanizeDurationRelative,
humanizeDuration,
now,
} from "../lib/formatTime";
import { LabelBadges } from "../components/LabelBadges";
import { useAppDispatch, useAppSelector } from "../state/hooks";
} from "../../lib/formatTime";
import { LabelBadges } from "../../components/LabelBadges";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import {
setCollapsedPools,
updateTargetFilters,
} from "../state/targetsPageSlice";
import EndpointLink from "../components/EndpointLink";
import CustomInfiniteScroll from "../components/CustomInfiniteScroll";
} from "../../state/targetsPageSlice";
import EndpointLink from "../../components/EndpointLink";
import CustomInfiniteScroll from "../../components/CustomInfiniteScroll";
import { filter } from "lodash";
type ScrapePool = {
targets: Target[];
@ -89,7 +90,10 @@ const groupTargets = (targets: Target[]): ScrapePools => {
return pools;
};
const scrapePoolQueryParam = "scrapePool";
export default function TargetsPage() {
// Load the list of all available scrape pools.
const {
data: {
data: { scrapePools },
@ -98,6 +102,29 @@ export default function TargetsPage() {
path: `/scrape_pools`,
});
const dispatch = useAppDispatch();
// If there is a selected pool in the URL, extract it on initial load.
useEffect(() => {
const selectedPool = new URLSearchParams(window.location.search).get(
scrapePoolQueryParam
);
if (selectedPool !== null) {
dispatch(updateTargetFilters({ scrapePool: selectedPool }));
}
}, [dispatch]);
const filters = useAppSelector((state) => state.targetsPage.filters);
let poolToShow = filters.scrapePool;
let limitedDueToManyPools = false;
if (poolToShow === null && scrapePools.length > 20) {
poolToShow = scrapePools[0];
limitedDueToManyPools = true;
}
// Based on the selected pool (if any), load the list of targets.
const {
data: {
data: { activeTargets },
@ -106,11 +133,10 @@ export default function TargetsPage() {
path: `/targets`,
params: {
state: "active",
scrapePool: poolToShow === null ? "" : poolToShow,
},
});
const dispatch = useAppDispatch();
const filters = useAppSelector((state) => state.targetsPage.filters);
const collapsedPools = useAppSelector(
(state) => state.targetsPage.collapsedPools
);
@ -123,7 +149,12 @@ export default function TargetsPage() {
<Group mb="md" mt="xs">
<Select
placeholder="Select scrape pool"
data={["All pools", ...scrapePools]}
data={[{ label: "All pools", value: "" }, ...scrapePools]}
value={filters.scrapePool}
onChange={(value) =>
dispatch(updateTargetFilters({ scrapePool: value || null }))
}
searchable
/>
<StateMultiSelect
options={["unknown", "up", "down"]}
@ -131,8 +162,8 @@ export default function TargetsPage() {
o === "unknown"
? badgeClasses.healthUnknown
: o === "up"
? badgeClasses.healthOk
: badgeClasses.healthErr
? badgeClasses.healthOk
: badgeClasses.healthErr
}
placeholder="Filter by target state"
values={filters.health}
@ -171,6 +202,15 @@ export default function TargetsPage() {
No targets found that match your filter criteria.
</Alert>
)}
{limitedDueToManyPools && (
<Alert
title="Found many pools, showing only one"
icon={<IconInfoCircle size={14} />}
>
There are many scrape pools configured. Showing only the first one.
Use the dropdown to select a different pool.
</Alert>
)}
<Accordion
multiple
variant="separated"
@ -192,8 +232,8 @@ export default function TargetsPage() {
pool.upCount === 0
? "5px solid var(--mantine-color-red-4)"
: pool.upCount !== pool.targets.length
? "5px solid var(--mantine-color-orange-5)"
: "5px solid var(--mantine-color-green-4)",
? "5px solid var(--mantine-color-orange-5)"
: "5px solid var(--mantine-color-green-4)",
}}
>
<Accordion.Control>

View File

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

View File

@ -2,25 +2,19 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { initializeFromLocalStorage } from "./initializeFromLocalStorage";
export const localStorageKeyCollapsedPools = "targetsPage.collapsedPools";
export const localStorageKeyTargetFilters = "targetsPage.filters";
interface TargetFilters {
scrapePool: string | null;
health: string[];
}
export const localStorageKeyTargetHealthFilter = "targetsPage.healthFilter";
interface TargetsPage {
filters: TargetFilters;
selectedPool: string | null;
healthFilter: string[];
collapsedPools: string[];
}
const initialState: TargetsPage = {
filters: initializeFromLocalStorage<TargetFilters>(
localStorageKeyTargetFilters,
{
scrapePool: null,
health: [],
}
selectedPool: null,
healthFilter: initializeFromLocalStorage<string[]>(
localStorageKeyTargetHealthFilter,
[]
),
collapsedPools: initializeFromLocalStorage<string[]>(
localStorageKeyCollapsedPools,
@ -32,11 +26,11 @@ export const targetsPageSlice = createSlice({
name: "targetsPage",
initialState,
reducers: {
updateTargetFilters: (
state,
{ payload }: PayloadAction<Partial<TargetFilters>>
) => {
Object.assign(state.filters, payload);
setSelectedPool: (state, { payload }: PayloadAction<string | null>) => {
state.selectedPool = payload;
},
setHealthFilter: (state, { payload }: PayloadAction<string[]>) => {
state.healthFilter = payload;
},
setCollapsedPools: (state, { payload }: PayloadAction<string[]>) => {
state.collapsedPools = payload;
@ -44,7 +38,7 @@ export const targetsPageSlice = createSlice({
},
});
export const { updateTargetFilters, setCollapsedPools } =
export const { setSelectedPool, setHealthFilter, setCollapsedPools } =
targetsPageSlice.actions;
export default targetsPageSlice.reducer;