Add initial Service Discovery page

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-08-28 14:56:35 +02:00
parent 73a328f37b
commit d22e721d39
9 changed files with 573 additions and 11 deletions

View file

@ -46,7 +46,6 @@ import QueryPage from "./pages/query/QueryPage";
import AlertsPage from "./pages/AlertsPage"; import AlertsPage from "./pages/AlertsPage";
import RulesPage from "./pages/RulesPage"; import RulesPage from "./pages/RulesPage";
import TargetsPage from "./pages/targets/TargetsPage"; import TargetsPage from "./pages/targets/TargetsPage";
import ServiceDiscoveryPage from "./pages/ServiceDiscoveryPage";
import StatusPage from "./pages/StatusPage"; import StatusPage from "./pages/StatusPage";
import TSDBStatusPage from "./pages/TSDBStatusPage"; import TSDBStatusPage from "./pages/TSDBStatusPage";
import FlagsPage from "./pages/FlagsPage"; import FlagsPage from "./pages/FlagsPage";
@ -62,6 +61,7 @@ import SettingsMenu from "./components/SettingsMenu";
import ReadinessWrapper from "./components/ReadinessWrapper"; import ReadinessWrapper from "./components/ReadinessWrapper";
import { QueryParamProvider } from "use-query-params"; import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6"; import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
import ServiceDiscoveryPage from "./pages/service-discovery/ServiceDiscoveryPage";
const queryClient = new QueryClient(); const queryClient = new QueryClient();

View file

@ -1,4 +1,4 @@
import { Badge, BadgeVariant, Group, MantineColor } from "@mantine/core"; import { Badge, BadgeVariant, Group, MantineColor, Stack } from "@mantine/core";
import { FC } from "react"; import { FC } from "react";
import { escapeString } from "../lib/escapeString"; import { escapeString } from "../lib/escapeString";
import badgeClasses from "../Badge.module.css"; import badgeClasses from "../Badge.module.css";
@ -7,14 +7,16 @@ export interface LabelBadgesProps {
labels: Record<string, string>; labels: Record<string, string>;
variant?: BadgeVariant; variant?: BadgeVariant;
color?: MantineColor; color?: MantineColor;
wrapper?: typeof Group | typeof Stack;
} }
export const LabelBadges: FC<LabelBadgesProps> = ({ export const LabelBadges: FC<LabelBadgesProps> = ({
labels, labels,
variant, variant,
color, color,
wrapper: Wrapper = Group,
}) => ( }) => (
<Group gap="xs"> <Wrapper gap="xs">
{Object.entries(labels).map(([k, v]) => { {Object.entries(labels).map(([k, v]) => {
return ( return (
<Badge <Badge
@ -32,5 +34,5 @@ export const LabelBadges: FC<LabelBadgesProps> = ({
</Badge> </Badge>
); );
})} })}
</Group> </Wrapper>
); );

View file

@ -1,3 +0,0 @@
export default function ServiceDiscoveryPage() {
return <>ServiceDiscovery page</>;
}

View file

@ -0,0 +1,127 @@
import {
ActionIcon,
Box,
Group,
Select,
Skeleton,
TextInput,
} from "@mantine/core";
import {
IconLayoutNavbarCollapse,
IconLayoutNavbarExpand,
IconSearch,
} from "@tabler/icons-react";
import { Suspense } from "react";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import { StringParam, useQueryParam, withDefault } from "use-query-params";
import ErrorBoundary from "../../components/ErrorBoundary";
import ScrapePoolList from "./ServiceDiscoveryPoolsList";
import { useSuspenseAPIQuery } from "../../api/api";
import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools";
import {
setCollapsedPools,
setShowLimitAlert,
} from "../../state/serviceDiscoveryPageSlice";
export const targetPoolDisplayLimit = 20;
export default function ServiceDiscoveryPage() {
// Load the list of all available scrape pools.
const {
data: {
data: { scrapePools },
},
} = useSuspenseAPIQuery<ScrapePoolsResult>({
path: `/scrape_pools`,
});
const dispatch = useAppDispatch();
const [scrapePool, setScrapePool] = useQueryParam("scrapePool", StringParam);
const [searchFilter, setSearchFilter] = useQueryParam(
"searchFilter",
withDefault(StringParam, "")
);
const { collapsedPools, showLimitAlert } = useAppSelector(
(state) => state.serviceDiscoveryPage
);
// 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 (
<>
<Group mb="md" mt="xs">
<Select
placeholder="Select scrape pool"
data={[{ label: "All pools", value: "" }, ...scrapePools]}
value={(limited && scrapePools[0]) || scrapePool || null}
onChange={(value) => {
setScrapePool(value);
showLimitAlert && dispatch(setShowLimitAlert(false));
}}
searchable
/>
<TextInput
flex={1}
leftSection={<IconSearch size={14} />}
placeholder="Filter by labels"
value={searchFilter || ""}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
></TextInput>
<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={(limited && scrapePools[0]) || scrapePool || null}
searchFilter={searchFilter}
/>
</Suspense>
</ErrorBoundary>
</>
);
}

View file

@ -0,0 +1,334 @@
import {
Accordion,
Alert,
Anchor,
Group,
RingProgress,
Stack,
Table,
Text,
} from "@mantine/core";
import { KVSearch } from "@nexucis/kvsearch";
import { IconInfoCircle } from "@tabler/icons-react";
import { useSuspenseAPIQuery } from "../../api/api";
import {
DroppedTarget,
Labels,
Target,
TargetsResult,
} from "../../api/responseTypes/targets";
import { FC } from "react";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import {
setCollapsedPools,
setShowLimitAlert,
} from "../../state/serviceDiscoveryPageSlice";
import CustomInfiniteScroll from "../../components/CustomInfiniteScroll";
import TargetLabels from "./TargetLabels";
import { useDebouncedValue } from "@mantine/hooks";
import { targetPoolDisplayLimit } from "./ServiceDiscoveryPage";
import { BooleanParam, useQueryParam, withDefault } from "use-query-params";
import { LabelBadges } from "../../components/LabelBadges";
type TargetLabels = {
discoveredLabels: Labels;
labels: Labels;
isDropped: boolean;
};
type ScrapePool = {
targets: TargetLabels[];
active: number;
total: number;
};
type ScrapePools = {
[scrapePool: string]: ScrapePool;
};
const activeTargetKVSearch = new KVSearch<Target>({
shouldSort: true,
indexedKeys: [
"labels",
"discoveredLabels",
["discoveredLabels", /.*/],
["labels", /.*/],
],
});
const droppedTargetKVSearch = new KVSearch<DroppedTarget>({
shouldSort: true,
indexedKeys: ["discoveredLabels", ["discoveredLabels", /.*/]],
});
const groupTargets = (
poolNames: string[],
activeTargets: Target[],
droppedTargets: DroppedTarget[]
): ScrapePools => {
const pools: ScrapePools = {};
for (const pn of poolNames) {
pools[pn] = {
targets: [],
active: 0,
total: 0,
};
}
for (const target of activeTargets) {
const pool = pools[target.scrapePool];
if (!pool) {
// 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."
);
}
pool.active++;
pool.total++;
pool.targets.push({
discoveredLabels: target.discoveredLabels,
labels: target.labels,
isDropped: false,
});
}
for (const target of droppedTargets) {
const { job: poolName } = target.discoveredLabels;
const pool = pools[poolName];
if (!pool) {
// 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."
);
}
pool.total++;
pool.targets.push({
discoveredLabels: target.discoveredLabels,
isDropped: true,
labels: {},
});
}
// for (const target of shownTargets) {
// pools[target.scrapePool].targets.push(target);
// }
return pools;
};
type ScrapePoolListProp = {
poolNames: string[];
selectedPool: string | null;
searchFilter: string;
};
const ScrapePoolList: FC<ScrapePoolListProp> = ({
poolNames,
selectedPool,
searchFilter,
}) => {
const dispatch = useAppDispatch();
const [showEmptyPools, setShowEmptyPools] = useQueryParam(
"showEmptyPools",
withDefault(BooleanParam, true)
);
// Based on the selected pool (if any), load the list of targets.
const {
data: {
data: { activeTargets, droppedTargets },
},
} = useSuspenseAPIQuery<TargetsResult>({
path: `/targets`,
params: {
scrapePool: selectedPool === null ? "" : selectedPool,
},
});
const { collapsedPools, showLimitAlert } = useAppSelector(
(state) => state.serviceDiscoveryPage
);
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 ||
// healthFilter.includes(target.health.toLowerCase())
// );
// const filteredTargets =
// search === ""
// ? healthFilteredTargets
// : kvSearch
// .filter(search, healthFilteredTargets)
// .map((value) => value.original);
const allPools = groupTargets(
selectedPool ? [selectedPool] : poolNames,
activeTargets,
droppedTargets
);
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 size={14} />}
>
No scrape pools found.
</Alert>
) : (
!showEmptyPools &&
allPoolNames.length !== shownPoolNames.length && (
<Alert
title="Hiding pools with no matching targets"
icon={<IconInfoCircle size={14} />}
>
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 size={14} />}
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}>
<Accordion.Control>
<Group wrap="nowrap" justify="space-between" mr="lg">
<Text>{poolName}</Text>
<Group gap="xs">
<Text c="gray.6">
{pool.active} / {pool.total}
</Text>
<RingProgress
size={25}
thickness={5}
sections={[
{
value: (pool.active / pool.total) * 100,
color: "green.4",
},
{
value:
((pool.total - pool.active) / pool.total) * 100,
color: "grape.4",
},
]}
/>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
{pool.total === 0 ? (
<Alert title="No targets" icon={<IconInfoCircle />}>
No 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 />}>
No targets in this pool match your filter criteria (omitted{" "}
{pool.total} 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="50%">Discovered labels</Table.Th>
<Table.Th w="50%">Target labels</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((target, i) => (
// TODO: Find a stable and definitely unique key.
<Table.Tr key={i}>
<Table.Td py="lg" valign="top">
<LabelBadges
labels={target.discoveredLabels}
wrapper={Stack}
/>
</Table.Td>
<Table.Td
py="lg"
valign={target.isDropped ? "middle" : "top"}
>
{target.isDropped ? (
<Text c="blue.6" fw="bold">
dropped due to relabeling rules
</Text>
) : (
<LabelBadges
labels={target.labels}
wrapper={Stack}
/>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
/>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</Stack>
);
};
export default ScrapePoolList;

View file

@ -0,0 +1,49 @@
import { FC } from "react";
import { Labels } from "../../api/responseTypes/targets";
import { LabelBadges } from "../../components/LabelBadges";
import { ActionIcon, Collapse, Group, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
type TargetLabelsProps = {
labels: Labels;
discoveredLabels: Labels;
};
const TargetLabels: FC<TargetLabelsProps> = ({ discoveredLabels, labels }) => {
const [showDiscovered, { toggle: toggleDiscovered }] = useDisclosure(false);
return (
<Stack my={showDiscovered ? "sm" : undefined}>
<Group wrap="nowrap" align="flex-start">
<LabelBadges labels={labels} />
<ActionIcon
size="xs"
color="gray"
variant="light"
onClick={toggleDiscovered}
title={`${showDiscovered ? "Hide" : "Show"} discovered (pre-relabeling) labels`}
>
{showDiscovered ? (
<IconChevronUp
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
) : (
<IconChevronDown style={{ width: "70%", height: "70%" }} />
)}
</ActionIcon>
</Group>
<Collapse in={showDiscovered}>
<Text fw={700} size="1em" my="lg" c="gray.7">
Discovered labels:
</Text>
<LabelBadges color="blue" labels={discoveredLabels} />
</Collapse>
</Stack>
);
};
export default TargetLabels;

View file

@ -1,9 +1,13 @@
import { createListenerMiddleware } from "@reduxjs/toolkit"; import { createListenerMiddleware } from "@reduxjs/toolkit";
import { AppDispatch, RootState } from "./store"; import { AppDispatch, RootState } from "./store";
import { import {
localStorageKeyCollapsedPools, localStorageKeyCollapsedPools as localStorageKeyTargetsPageCollapsedPools,
setCollapsedPools, setCollapsedPools as targetsPageSetCollapsedPools,
} from "./targetsPageSlice"; } from "./targetsPageSlice";
import {
localStorageKeyCollapsedPools as localStorageKeyServiceDiscoveryPageCollapsedPools,
setCollapsedPools as ServiceDiscoveryPageSetCollapsedPools,
} from "./serviceDiscoveryPageSlice";
import { updateSettings } from "./settingsSlice"; import { updateSettings } from "./settingsSlice";
const persistToLocalStorage = <T>(key: string, value: T) => { const persistToLocalStorage = <T>(key: string, value: T) => {
@ -18,9 +22,19 @@ const startAppListening = localStorageMiddleware.startListening.withTypes<
>(); >();
startAppListening({ startAppListening({
actionCreator: setCollapsedPools, actionCreator: targetsPageSetCollapsedPools,
effect: ({ payload }) => { effect: ({ payload }) => {
persistToLocalStorage(localStorageKeyCollapsedPools, payload); persistToLocalStorage(localStorageKeyTargetsPageCollapsedPools, payload);
},
});
startAppListening({
actionCreator: ServiceDiscoveryPageSetCollapsedPools,
effect: ({ payload }) => {
persistToLocalStorage(
localStorageKeyServiceDiscoveryPageCollapsedPools,
payload
);
}, },
}); });

View file

@ -0,0 +1,37 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { initializeFromLocalStorage } from "./initializeFromLocalStorage";
export const localStorageKeyCollapsedPools = "serviceDiscovery.collapsedPools";
export const localStorageKeyTargetHealthFilter =
"serviceDiscovery.healthFilter";
interface ServiceDiscoveryPage {
collapsedPools: string[];
showLimitAlert: boolean;
}
const initialState: ServiceDiscoveryPage = {
collapsedPools: initializeFromLocalStorage<string[]>(
localStorageKeyCollapsedPools,
[]
),
showLimitAlert: false,
};
export const serviceDiscoveryPageSlice = createSlice({
name: "serviceDiscoveryPage",
initialState,
reducers: {
setCollapsedPools: (state, { payload }: PayloadAction<string[]>) => {
state.collapsedPools = payload;
},
setShowLimitAlert: (state, { payload }: PayloadAction<boolean>) => {
state.showLimitAlert = payload;
},
},
});
export const { setCollapsedPools, setShowLimitAlert } =
serviceDiscoveryPageSlice.actions;
export default serviceDiscoveryPageSlice.reducer;

View file

@ -3,12 +3,14 @@ import queryPageSlice from "./queryPageSlice";
import settingsSlice from "./settingsSlice"; import settingsSlice from "./settingsSlice";
import targetsPageSlice from "./targetsPageSlice"; import targetsPageSlice from "./targetsPageSlice";
import { localStorageMiddleware } from "./localStorageMiddleware"; import { localStorageMiddleware } from "./localStorageMiddleware";
import serviceDiscoveryPageSlice from "./serviceDiscoveryPageSlice";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
settings: settingsSlice, settings: settingsSlice,
queryPage: queryPageSlice, queryPage: queryPageSlice,
targetsPage: targetsPageSlice, targetsPage: targetsPageSlice,
serviceDiscoveryPage: serviceDiscoveryPageSlice,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(localStorageMiddleware.middleware), getDefaultMiddleware().prepend(localStorageMiddleware.middleware),