From 70221fc4a09910c9880f498b2bc4f37591c545c7 Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Wed, 3 Apr 2024 14:43:03 +0200 Subject: [PATCH] Build initial targets page Signed-off-by: Julius Volz --- web/ui/mantine-ui/package.json | 1 + .../mantine-ui/src/CustomInfiniteScroll.tsx | 50 +++ web/ui/mantine-ui/src/EndpointLink.tsx | 58 ++++ web/ui/mantine-ui/src/LabelBadges.tsx | 36 ++ web/ui/mantine-ui/src/StateMultiSelect.tsx | 140 ++++++++ .../src/api/responseTypes/scrapePools.ts | 1 + .../src/api/responseTypes/targets.ts | 27 ++ web/ui/mantine-ui/src/pages/TargetsPage.tsx | 317 +++++++++++++++++- .../src/state/initializeFromLocalStorage.ts | 14 + .../src/state/localStorageMiddleware.ts | 33 ++ web/ui/mantine-ui/src/state/store.ts | 9 +- .../mantine-ui/src/state/targetsPageSlice.ts | 50 +++ web/ui/package-lock.json | 20 ++ 13 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 web/ui/mantine-ui/src/CustomInfiniteScroll.tsx create mode 100644 web/ui/mantine-ui/src/EndpointLink.tsx create mode 100644 web/ui/mantine-ui/src/LabelBadges.tsx create mode 100644 web/ui/mantine-ui/src/StateMultiSelect.tsx create mode 100644 web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts create mode 100644 web/ui/mantine-ui/src/api/responseTypes/targets.ts create mode 100644 web/ui/mantine-ui/src/state/initializeFromLocalStorage.ts create mode 100644 web/ui/mantine-ui/src/state/localStorageMiddleware.ts create mode 100644 web/ui/mantine-ui/src/state/targetsPageSlice.ts diff --git a/web/ui/mantine-ui/package.json b/web/ui/mantine-ui/package.json index a6138ca3ff..7d50376700 100644 --- a/web/ui/mantine-ui/package.json +++ b/web/ui/mantine-ui/package.json @@ -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" }, diff --git a/web/ui/mantine-ui/src/CustomInfiniteScroll.tsx b/web/ui/mantine-ui/src/CustomInfiniteScroll.tsx new file mode 100644 index 0000000000..a4e0575d33 --- /dev/null +++ b/web/ui/mantine-ui/src/CustomInfiniteScroll.tsx @@ -0,0 +1,50 @@ +import { ComponentType, useEffect, useState } from 'react'; +import InfiniteScroll from 'react-infinite-scroll-component'; + +const initialNumberOfItemsDisplayed = 50; + +export interface InfiniteScrollItemsProps { + items: T[]; +} + +interface CustomInfiniteScrollProps { + allItems: T[]; + child: ComponentType>; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +const CustomInfiniteScroll = ({ allItems, child }: CustomInfiniteScrollProps) => { + const [items, setItems] = useState(allItems.slice(0, 50)); + const [index, setIndex] = useState(initialNumberOfItemsDisplayed); + const [hasMore, setHasMore] = useState(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 ( + loading...} + dataLength={items.length} + height={items.length > 25 ? '75vh' : ''} + > + + + ); +}; + +export default CustomInfiniteScroll; diff --git a/web/ui/mantine-ui/src/EndpointLink.tsx b/web/ui/mantine-ui/src/EndpointLink.tsx new file mode 100644 index 0000000000..051431bbcf --- /dev/null +++ b/web/ui/mantine-ui/src/EndpointLink.tsx @@ -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 = ({ 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 ( + <> + + {displayLink} + + {params.length > 0 && ( + + {params.map(([labelName, labelValue]: [string, string]) => { + return ( + + {`${labelName}="${labelValue}"`} + + ); + })} + + )} + + ); +}; + +export default EndpointLink; diff --git a/web/ui/mantine-ui/src/LabelBadges.tsx b/web/ui/mantine-ui/src/LabelBadges.tsx new file mode 100644 index 0000000000..c4a7b54c7a --- /dev/null +++ b/web/ui/mantine-ui/src/LabelBadges.tsx @@ -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; + variant?: BadgeVariant; + color?: MantineColor; +} + +export const LabelBadges: FC = ({ + labels, + variant, + color, +}) => ( + + {Object.entries(labels).map(([k, v]) => { + return ( + + {k}="{escapeString(v)}" + + ); + })} + +); diff --git a/web/ui/mantine-ui/src/StateMultiSelect.tsx b/web/ui/mantine-ui/src/StateMultiSelect.tsx new file mode 100644 index 0000000000..cecd53db3a --- /dev/null +++ b/web/ui/mantine-ui/src/StateMultiSelect.tsx @@ -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 ( + + {value} + + ); +} + +interface StateMultiSelectProps { + options: string[]; + optionClass: (option: string) => string; + placeholder: string; + values: string[]; + onChange: (values: string[]) => void; +} + +export const StateMultiSelect: FC = ({ + 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) => ( + handleValueRemove(item)} + key={item} + /> + )); + + return ( + + + combobox.toggleDropdown()} + miw={200} + leftSection={} + rightSection={ + values.length > 0 ? ( + onChange([])} /> + ) : ( + + ) + } + > + + {renderedValues.length > 0 ? ( + renderedValues + ) : ( + + )} + + + combobox.closeDropdown()} + onKeyDown={(event) => { + if (event.key === "Backspace") { + event.preventDefault(); + handleValueRemove(values[values.length - 1]); + } + }} + /> + + + + + + + + {options.map((value) => { + return ( + + + {values.includes(value) ? ( + + ) : null} + + + + ); + })} + + + + ); +}; diff --git a/web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts b/web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts new file mode 100644 index 0000000000..aca8b55afb --- /dev/null +++ b/web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts @@ -0,0 +1 @@ +export type ScrapePoolsResult = { scrapePools: string[] }; diff --git a/web/ui/mantine-ui/src/api/responseTypes/targets.ts b/web/ui/mantine-ui/src/api/responseTypes/targets.ts new file mode 100644 index 0000000000..ea34aa132e --- /dev/null +++ b/web/ui/mantine-ui/src/api/responseTypes/targets.ts @@ -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; +}; diff --git a/web/ui/mantine-ui/src/pages/TargetsPage.tsx b/web/ui/mantine-ui/src/pages/TargetsPage.tsx index 05ab5cdaba..6773142e84 100644 --- a/web/ui/mantine-ui/src/pages/TargetsPage.tsx +++ b/web/ui/mantine-ui/src/pages/TargetsPage.tsx @@ -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({ + path: `/scrape_pools`, + }); + + const { + data: { + data: { activeTargets }, + }, + } = useSuspenseAPIQuery({ + 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 ( + <> + + } + placeholder="Filter by endpoint or labels" + > + + dispatch( + setCollapsedPools(collapsedPools.length > 0 ? [] : allPoolNames) + ) + } + > + {collapsedPools.length > 0 ? ( + + ) : ( + + )} + + + + {allPoolNames.length === 0 && ( + } + > + No targets found that match your filter criteria. + + )} + !collapsedPools.includes(p))} + onChange={(value) => + dispatch( + setCollapsedPools(allPoolNames.filter((p) => !value.includes(p))) + ) + } + > + {allPoolNames.map((poolName) => { + const pool = allPools[poolName]; + return ( + + + + {poolName} + + + {pool.upCount} / {pool.targets.length} up + + + + + + + + filters.health.length === 0 || + filters.health.includes(t.health.toLowerCase()) + )} + child={({ items }) => ( + + + + Endpoint + State + Labels + Last scrape + Scrape duration + + + + {items.map((target, i) => ( + // TODO: Find a stable and definitely unique key. + + + + {/* TODO: Process target URL like in old UI */} + + + + + {target.health} + + + + + + + {humanizeDurationRelative( + target.lastScrape, + now() + )} + + + {humanizeDuration( + target.lastScrapeDuration * 1000 + )} + + + {target.lastError && ( + + + } + > + Error scraping target:{" "} + {target.lastError} + + + + )} + + ))} + +
+ )} + /> +
+
+ ); + })} +
+
+ + ); } diff --git a/web/ui/mantine-ui/src/state/initializeFromLocalStorage.ts b/web/ui/mantine-ui/src/state/initializeFromLocalStorage.ts new file mode 100644 index 0000000000..6dfac4302a --- /dev/null +++ b/web/ui/mantine-ui/src/state/initializeFromLocalStorage.ts @@ -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 = ( + key: string, + defaultValue: T +): T => { + const value = localStorage.getItem(key); + if (value === null) { + return defaultValue; + } + return JSON.parse(value); +}; diff --git a/web/ui/mantine-ui/src/state/localStorageMiddleware.ts b/web/ui/mantine-ui/src/state/localStorageMiddleware.ts new file mode 100644 index 0000000000..fe335fc7cc --- /dev/null +++ b/web/ui/mantine-ui/src/state/localStorageMiddleware.ts @@ -0,0 +1,33 @@ +import { createListenerMiddleware } from "@reduxjs/toolkit"; +import { AppDispatch, RootState } from "./store"; +import { + localStorageKeyCollapsedPools, + localStorageKeyTargetFilters, + setCollapsedPools, + updateTargetFilters, +} from "./targetsPageSlice"; + +const persistToLocalStorage = (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); + }, +}); diff --git a/web/ui/mantine-ui/src/state/store.ts b/web/ui/mantine-ui/src/state/store.ts index d2d2dcb68b..35ea12802b 100644 --- a/web/ui/mantine-ui/src/state/store.ts +++ b/web/ui/mantine-ui/src/state/store.ts @@ -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 diff --git a/web/ui/mantine-ui/src/state/targetsPageSlice.ts b/web/ui/mantine-ui/src/state/targetsPageSlice.ts new file mode 100644 index 0000000000..598cdab59b --- /dev/null +++ b/web/ui/mantine-ui/src/state/targetsPageSlice.ts @@ -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( + localStorageKeyTargetFilters, + { + scrapePool: null, + health: [], + } + ), + collapsedPools: initializeFromLocalStorage( + localStorageKeyCollapsedPools, + [] + ), +}; + +export const targetsPageSlice = createSlice({ + name: "targetsPage", + initialState, + reducers: { + updateTargetFilters: ( + state, + { payload }: PayloadAction> + ) => { + Object.assign(state.filters, payload); + }, + setCollapsedPools: (state, { payload }: PayloadAction) => { + state.collapsedPools = payload; + }, + }, +}); + +export const { updateTargetFilters, setCollapsedPools } = + targetsPageSlice.actions; + +export default targetsPageSlice.reducer; diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index dffee20813..adfa65741d 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -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",