diff --git a/web/ui/mantine-ui/src/App.tsx b/web/ui/mantine-ui/src/App.tsx index 2a336a6031..6a4ef964dd 100644 --- a/web/ui/mantine-ui/src/App.tsx +++ b/web/ui/mantine-ui/src/App.tsx @@ -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"; diff --git a/web/ui/mantine-ui/src/components/ErrorBoundary.tsx b/web/ui/mantine-ui/src/components/ErrorBoundary.tsx index 4bd96c0a16..45ffc1023a 100644 --- a/web/ui/mantine-ui/src/components/ErrorBoundary.tsx +++ b/web/ui/mantine-ui/src/components/ErrorBoundary.tsx @@ -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 { return ( } maw={500} mx="auto" diff --git a/web/ui/mantine-ui/src/pages/targets/ScrapePoolsList.tsx b/web/ui/mantine-ui/src/pages/targets/ScrapePoolsList.tsx new file mode 100644 index 0000000000..86c1cd99bc --- /dev/null +++ b/web/ui/mantine-ui/src/pages/targets/ScrapePoolsList.tsx @@ -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 = ({ + poolNames, + selectedPool, + limited, +}) => { + const dispatch = useAppDispatch(); + + // Based on the selected pool (if any), load the list of targets. + const { + data: { + data: { activeTargets }, + }, + } = useSuspenseAPIQuery({ + 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 ( + + {allPoolNames.length === 0 && ( + }> + No targets found that match your filter criteria. + + )} + {limited && ( + } + > + There are more than 20 scrape pools. Showing only the first one. Use + the dropdown to select a different pool. + + )} + !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.count} up + + + + + + + {pool.count === 0 ? ( + }> + No targets in this scrape pool. + + ) : pool.targets.length === 0 ? ( + }> + No targets in this pool match your filter criteria (omitted{" "} + {pool.count} filtered targets). + + ) : ( + ( + + + + 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} + + + + )} + + ))} + +
+ )} + /> + )} +
+
+ ); + })} +
+
+ ); +}; + +export default ScrapePoolList; diff --git a/web/ui/mantine-ui/src/pages/targets/TargetsPage.tsx b/web/ui/mantine-ui/src/pages/targets/TargetsPage.tsx new file mode 100644 index 0000000000..38d5e218bb --- /dev/null +++ b/web/ui/mantine-ui/src/pages/targets/TargetsPage.tsx @@ -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({ + 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 ( + <> + + } + placeholder="Filter by endpoint or labels" + > + 0 + ? "Expand all pools" + : "Collapse all pools" + } + variant="light" + onClick={() => + dispatch( + setCollapsedPools(collapsedPools.length > 0 ? [] : scrapePools) + ) + } + > + {collapsedPools.length > 0 ? ( + + ) : ( + + )} + + + + + + {Array.from(Array(10), (_, i) => ( + + ))} + + } + > + + + + + ); +} diff --git a/web/ui/mantine-ui/src/pages/TargetsPage.tsx b/web/ui/mantine-ui/src/pages/targets/TargetsPage.tsx.copy similarity index 80% rename from web/ui/mantine-ui/src/pages/TargetsPage.tsx rename to web/ui/mantine-ui/src/pages/targets/TargetsPage.tsx.copy index a93b594dff..3b7bf2d656 100644 --- a/web/ui/mantine-ui/src/pages/TargetsPage.tsx +++ b/web/ui/mantine-ui/src/pages/targets/TargetsPage.tsx.copy @@ -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() {