Build initial targets page

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-04-03 14:43:03 +02:00
parent a699cbefae
commit 70221fc4a0
13 changed files with 754 additions and 2 deletions

View file

@ -31,6 +31,7 @@
"dayjs": "^1.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-infinite-scroll-component": "^6.1.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1"
},

View file

@ -0,0 +1,50 @@
import { ComponentType, useEffect, useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
const initialNumberOfItemsDisplayed = 50;
export interface InfiniteScrollItemsProps<T> {
items: T[];
}
interface CustomInfiniteScrollProps<T> {
allItems: T[];
child: ComponentType<InfiniteScrollItemsProps<T>>;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
const CustomInfiniteScroll = <T,>({ allItems, child }: CustomInfiniteScrollProps<T>) => {
const [items, setItems] = useState<T[]>(allItems.slice(0, 50));
const [index, setIndex] = useState<number>(initialNumberOfItemsDisplayed);
const [hasMore, setHasMore] = useState<boolean>(allItems.length > initialNumberOfItemsDisplayed);
const Child = child;
useEffect(() => {
setItems(allItems.slice(0, initialNumberOfItemsDisplayed));
setHasMore(allItems.length > initialNumberOfItemsDisplayed);
}, [allItems]);
const fetchMoreData = () => {
if (items.length === allItems.length) {
setHasMore(false);
} else {
const newIndex = index + initialNumberOfItemsDisplayed;
setIndex(newIndex);
setItems(allItems.slice(0, newIndex));
}
};
return (
<InfiniteScroll
next={fetchMoreData}
hasMore={hasMore}
loader={<h4>loading...</h4>}
dataLength={items.length}
height={items.length > 25 ? '75vh' : ''}
>
<Child items={items} />
</InfiniteScroll>
);
};
export default CustomInfiniteScroll;

View file

@ -0,0 +1,58 @@
import { Anchor, Badge, Group } from "@mantine/core";
import React, { FC } from "react";
export interface EndpointLinkProps {
endpoint: string;
globalUrl: string;
}
const EndpointLink: FC<EndpointLinkProps> = ({ endpoint, globalUrl }) => {
let url: URL;
let search = "";
let invalidURL = false;
try {
url = new URL(endpoint);
} catch (err: unknown) {
// In cases of IPv6 addresses with a Zone ID, URL may not be parseable.
// See https://github.com/prometheus/prometheus/issues/9760
// In this case, we attempt to prepare a synthetic URL with the
// same query parameters, for rendering purposes.
invalidURL = true;
if (endpoint.indexOf("?") > -1) {
search = endpoint.substring(endpoint.indexOf("?"));
}
url = new URL("http://0.0.0.0" + search);
}
const { host, pathname, protocol, searchParams }: URL = url;
const params = Array.from(searchParams.entries());
const displayLink = invalidURL
? endpoint.replace(search, "")
: `${protocol}//${host}${pathname}`;
return (
<>
<Anchor size="sm" href={globalUrl}>
{displayLink}
</Anchor>
{params.length > 0 && (
<Group gap="xs" my="md">
{params.map(([labelName, labelValue]: [string, string]) => {
return (
<Badge
size="sm"
variant="light"
color="gray.9"
key={`${labelName}/${labelValue}`}
style={{ textTransform: "none" }}
>
{`${labelName}="${labelValue}"`}
</Badge>
);
})}
</Group>
)}
</>
);
};
export default EndpointLink;

View file

@ -0,0 +1,36 @@
import { Badge, BadgeVariant, Group, MantineColor } from "@mantine/core";
import { FC } from "react";
import { escapeString } from "./lib/escapeString";
import badgeClasses from "./Badge.module.css";
export interface LabelBadgesProps {
labels: Record<string, string>;
variant?: BadgeVariant;
color?: MantineColor;
}
export const LabelBadges: FC<LabelBadgesProps> = ({
labels,
variant,
color,
}) => (
<Group gap="xs">
{Object.entries(labels).map(([k, v]) => {
return (
<Badge
variant={variant ? variant : "light"}
color={color ? color : undefined}
className={badgeClasses.labelBadge}
styles={{
label: {
textTransform: "none",
},
}}
key={k}
>
{k}="{escapeString(v)}"
</Badge>
);
})}
</Group>
);

View file

@ -0,0 +1,140 @@
import { FC, useState } from "react";
import {
Badge,
CheckIcon,
CloseButton,
Combobox,
ComboboxChevron,
ComboboxClearButton,
Group,
Input,
MantineColor,
Pill,
PillGroup,
PillsInput,
useCombobox,
} from "@mantine/core";
import { IconActivity, IconFilter } from "@tabler/icons-react";
interface StatePillProps extends React.ComponentPropsWithoutRef<"div"> {
value: string;
onRemove?: () => void;
}
export function StatePill({ value, onRemove, ...others }: StatePillProps) {
return (
<Pill
fw={600}
style={{ textTransform: "uppercase", fontWeight: 600 }}
onRemove={onRemove}
{...others}
withRemoveButton={!!onRemove}
>
{value}
</Pill>
);
}
interface StateMultiSelectProps {
options: string[];
optionClass: (option: string) => string;
placeholder: string;
values: string[];
onChange: (values: string[]) => void;
}
export const StateMultiSelect: FC<StateMultiSelectProps> = ({
options,
optionClass,
placeholder,
values,
onChange,
}) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const handleValueSelect = (val: string) =>
onChange(
values.includes(val) ? values.filter((v) => v !== val) : [...values, val]
);
const handleValueRemove = (val: string) =>
onChange(values.filter((v) => v !== val));
const renderedValues = values.map((item) => (
<StatePill
value={item}
className={optionClass(item)}
onRemove={() => handleValueRemove(item)}
key={item}
/>
));
return (
<Combobox
store={combobox}
onOptionSubmit={handleValueSelect}
withinPortal={false}
>
<Combobox.DropdownTarget>
<PillsInput
pointer
onClick={() => combobox.toggleDropdown()}
miw={200}
leftSection={<IconActivity size={14} />}
rightSection={
values.length > 0 ? (
<ComboboxClearButton onClear={() => onChange([])} />
) : (
<ComboboxChevron />
)
}
>
<Pill.Group>
{renderedValues.length > 0 ? (
renderedValues
) : (
<PillsInput.Field placeholder={placeholder} mt={1} />
)}
<Combobox.EventsTarget>
<PillsInput.Field
type="hidden"
onBlur={() => combobox.closeDropdown()}
onKeyDown={(event) => {
if (event.key === "Backspace") {
event.preventDefault();
handleValueRemove(values[values.length - 1]);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>
{options.map((value) => {
return (
<Combobox.Option
value={value}
key={value}
active={values.includes(value)}
>
<Group gap="sm">
{values.includes(value) ? (
<CheckIcon size={12} color="gray" />
) : null}
<StatePill value={value} className={optionClass(value)} />
</Group>
</Combobox.Option>
);
})}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};

View file

@ -0,0 +1 @@
export type ScrapePoolsResult = { scrapePools: string[] };

View file

@ -0,0 +1,27 @@
export interface Labels {
[key: string]: string;
}
export type Target = {
discoveredLabels: Labels;
labels: Labels;
scrapePool: string;
scrapeUrl: string;
globalUrl: string;
lastError: string;
lastScrape: string;
lastScrapeDuration: number;
health: string;
scrapeInterval: string;
scrapeTimeout: string;
};
export interface DroppedTarget {
discoveredLabels: Labels;
}
export type TargetsResult = {
activeTargets: Target[];
droppedTargets: DroppedTarget[];
droppedTargetCounts: Record<string, number>;
};

View file

@ -1,3 +1,318 @@
import {
Accordion,
ActionIcon,
Alert,
Anchor,
Badge,
Card,
Group,
Input,
RingProgress,
Select,
Stack,
Table,
Text,
} from "@mantine/core";
import {
IconAlertTriangle,
IconInfoCircle,
IconLayoutNavbarCollapse,
IconLayoutNavbarExpand,
IconSearch,
} from "@tabler/icons-react";
import { StateMultiSelect } from "../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 {
formatPrometheusDuration,
humanizeDurationRelative,
humanizeDuration,
now,
} from "../lib/formatTime";
import { LabelBadges } from "../LabelBadges";
import { useAppDispatch, useAppSelector } from "../state/hooks";
import {
setCollapsedPools,
updateTargetFilters,
} from "../state/targetsPageSlice";
import EndpointLink from "../EndpointLink";
import CustomInfiniteScroll from "../CustomInfiniteScroll";
type ScrapePool = {
targets: Target[];
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 = (targets: Target[]): ScrapePools => {
const pools: ScrapePools = {};
targets.forEach((target) => {
if (!pools[target.scrapePool]) {
pools[target.scrapePool] = {
targets: [],
upCount: 0,
downCount: 0,
unknownCount: 0,
};
}
pools[target.scrapePool].targets.push(target);
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;
};
export default function TargetsPage() {
return <>Targets page</>;
const {
data: {
data: { scrapePools },
},
} = useSuspenseAPIQuery<ScrapePoolsResult>({
path: `/scrape_pools`,
});
const {
data: {
data: { activeTargets },
},
} = useSuspenseAPIQuery<TargetsResult>({
path: `/targets`,
params: {
state: "active",
},
});
const dispatch = useAppDispatch();
const filters = useAppSelector((state) => state.targetsPage.filters);
const collapsedPools = useAppSelector(
(state) => state.targetsPage.collapsedPools
);
const allPools = groupTargets(activeTargets);
const allPoolNames = Object.keys(allPools);
return (
<>
<Group mb="md" mt="xs">
<Select
placeholder="Select scrape pool"
data={["All pools", ...scrapePools]}
/>
<StateMultiSelect
options={["unknown", "up", "down"]}
optionClass={(o) =>
o === "unknown"
? badgeClasses.healthUnknown
: o === "up"
? badgeClasses.healthOk
: badgeClasses.healthErr
}
placeholder="Filter by target state"
values={filters.health}
onChange={(values) =>
dispatch(updateTargetFilters({ health: values }))
}
/>
<Input
flex={1}
leftSection={<IconSearch size={14} />}
placeholder="Filter by endpoint or labels"
></Input>
<ActionIcon
size="input-sm"
title="Expand all pools"
variant="light"
onClick={() =>
dispatch(
setCollapsedPools(collapsedPools.length > 0 ? [] : allPoolNames)
)
}
>
{collapsedPools.length > 0 ? (
<IconLayoutNavbarExpand size={16} />
) : (
<IconLayoutNavbarCollapse size={16} />
)}
</ActionIcon>
</Group>
<Stack>
{allPoolNames.length === 0 && (
<Alert
title="No matching targets"
icon={<IconInfoCircle size={14} />}
>
No targets found that match your filter criteria.
</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.targets.length
? "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.targets.length} up
</Text>
<RingProgress
size={25}
thickness={5}
sections={[
{
value: (pool.upCount / pool.targets.length) * 100,
// value: pool.upCount,
color: "green.4",
},
{
value: (pool.downCount / pool.targets.length) * 100,
color: "red.5",
},
{
value:
(pool.unknownCount / pool.targets.length) * 100,
color: "gray.4",
},
]}
/>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<CustomInfiniteScroll
allItems={pool.targets.filter(
(t) =>
filters.health.length === 0 ||
filters.health.includes(t.health.toLowerCase())
)}
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>
</>
);
}

View file

@ -0,0 +1,14 @@
// This has to live in its own file since including it from
// localStorageMiddleware.ts causes startup issues, as the
// listener setup there accesses an action creator before Redux
// has been initialized.
export const initializeFromLocalStorage = <T>(
key: string,
defaultValue: T
): T => {
const value = localStorage.getItem(key);
if (value === null) {
return defaultValue;
}
return JSON.parse(value);
};

View file

@ -0,0 +1,33 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import { AppDispatch, RootState } from "./store";
import {
localStorageKeyCollapsedPools,
localStorageKeyTargetFilters,
setCollapsedPools,
updateTargetFilters,
} from "./targetsPageSlice";
const persistToLocalStorage = <T>(key: string, value: T) => {
localStorage.setItem(key, JSON.stringify(value));
};
export const localStorageMiddleware = createListenerMiddleware();
const startAppListening = localStorageMiddleware.startListening.withTypes<
RootState,
AppDispatch
>();
startAppListening({
actionCreator: setCollapsedPools,
effect: ({ payload }) => {
persistToLocalStorage(localStorageKeyCollapsedPools, payload);
},
});
startAppListening({
actionCreator: updateTargetFilters,
effect: ({ payload }) => {
persistToLocalStorage(localStorageKeyTargetFilters, payload);
},
});

View file

@ -2,15 +2,22 @@ import { configureStore } from "@reduxjs/toolkit";
import queryPageSlice from "./queryPageSlice";
import { prometheusApi } from "./api";
import settingsSlice from "./settingsSlice";
import targetsPageSlice from "./targetsPageSlice";
import alertsPageSlice from "./alertsPageSlice";
import { localStorageMiddleware } from "./localStorageMiddleware";
const store = configureStore({
reducer: {
settings: settingsSlice,
queryPage: queryPageSlice,
targetsPage: targetsPageSlice,
alertsPage: alertsPageSlice,
[prometheusApi.reducerPath]: prometheusApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(prometheusApi.middleware),
getDefaultMiddleware()
.prepend(localStorageMiddleware.middleware)
.concat(prometheusApi.middleware),
});
// Infer the `RootState` and `AppDispatch` types from the store itself

View file

@ -0,0 +1,50 @@
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[];
}
interface TargetsPage {
filters: TargetFilters;
collapsedPools: string[];
}
const initialState: TargetsPage = {
filters: initializeFromLocalStorage<TargetFilters>(
localStorageKeyTargetFilters,
{
scrapePool: null,
health: [],
}
),
collapsedPools: initializeFromLocalStorage<string[]>(
localStorageKeyCollapsedPools,
[]
),
};
export const targetsPageSlice = createSlice({
name: "targetsPage",
initialState,
reducers: {
updateTargetFilters: (
state,
{ payload }: PayloadAction<Partial<TargetFilters>>
) => {
Object.assign(state.filters, payload);
},
setCollapsedPools: (state, { payload }: PayloadAction<string[]>) => {
state.collapsedPools = payload;
},
},
});
export const { updateTargetFilters, setCollapsedPools } =
targetsPageSlice.actions;
export default targetsPageSlice.reducer;

View file

@ -128,6 +128,7 @@
"dayjs": "^1.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-infinite-scroll-component": "^6.1.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1"
},
@ -6150,6 +6151,17 @@
"react": "^18.2.0"
}
},
"node_modules/react-infinite-scroll-component": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz",
"integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==",
"dependencies": {
"throttle-debounce": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -6795,6 +6807,14 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"node_modules/throttle-debounce": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz",
"integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",