Repository: prometheus/prometheus (language: go)

Issue:
'Unhealthy' Targets filter not filtering out all 'Healthy' Targets
<!--

    Please do *NOT* ask usage questions in Github issues.

    If your issue is not a feature request or bug report use:
    https://groups.google.com/forum/#!forum/prometheus-users. If
    you are unsure whether you hit a bug, search and ask in the
    mailing list first.

    You can find more information at: https://prometheus.io/community/

-->
## Proposal
**Use case. Why is this important?**
When selecting 'Unhealthy' on the Targets Page, Jobs are only hidden if all the Targets within that Job are 'Healthy'. If a Job contains many Targets but only contains one 'Unhealthy' Target, all the 'Healthy' Targets will still be visible. 
It would be more manageable and user friendly, in particular when there are many Targets, to only show the 'Unhealthy' Targets.

This was touched upon in #4319.

I have coded a fix for this, if seen as beneficial, I will submit a Pull Request for review.

## Bug Report
**What did you do?**
Filter the Targets by 'Unhealthy' on the Targets Page.

![all](https://user-images.githubusercontent.com/47104231/52793586-f3b3fc80-3065-11e9-8ee7-fa8b2b5fe75a.png)

**What did you expect to see?**
Only 'Unhealthy' Targets visible (Prometheus job gets hidden).

![hidinghealthy](https://user-images.githubusercontent.com/47104231/52793661-1f36e700-3066-11e9-91f6-a14c8d3de07e.png)

**What did you see instead? Under which circumstances?**
All the other 'Healthy' Targets within a Job that contains 'Unhealthy' Targets (Prometheus job is hidden, but still the 'Healthy' Targets in example-random are visible).

![nothidinghealthy](https://user-images.githubusercontent.com/47104231/52793751-4beafe80-3066-11e9-8b1e-e688e1ee96c0.png)

**Environment**

* System information:
```
Linux 3.10.0-957.5.1.el7.x86_64 x86_64
```
* Prometheus version:
```
prometheus, version 2.7.1 (branch: master, revision: fd964426a77a5fc64db02b2ff5b757045b80be7a)
build date:       20190205-13:26:52
go version:       go1.11.4
```
* Prometheus configuration file:
```

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['localhost:9090']

  - job_name: 'node_exporter'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['localhost:9100']


  - job_name: 'example-random'

    # Override the global default and scrape targets from this job every 5 seconds.
    scrape_interval: 5s

    static_configs:
        - targets: ['localhost:8080', 'localhost:8081', 'localhost:8082', 'localhost:8083']
          labels:
            group: 'production'

        - targets: ['localhost:8084', 'localhost:8085', 'localhost:8086', 'localhost:8087']
          labels:
            group: 'canary'

```


Discussion / hints:
@smarch2 this looks good. If you have the patch ready, can you go ahead and open a PR?

Fix patch:
diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json
index 7bce79f1914..7267da90278 100644
--- a/web/ui/package-lock.json
+++ b/web/ui/package-lock.json
@@ -1592,6 +1592,19 @@
         "@lezer/common": "^0.15.0"
       }
     },
+    "node_modules/@nexucis/fuzzy": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@nexucis/fuzzy/-/fuzzy-0.3.0.tgz",
+      "integrity": "sha512-Z1+ADKY0fxdBE28REraWhUCNy+Bp5UmpK3Tc/5wdCDpY+6fXh8l2csMtbPGaqEBsyGLxJz9wUYGCf+CW9unyvQ=="
+    },
+    "node_modules/@nexucis/kvsearch": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@nexucis/kvsearch/-/kvsearch-0.3.0.tgz",
+      "integrity": "sha512-tHIH6W/mRUZZ0ZQyRbgp2uhat+2O1c1jX1EC6NHv7/8OIeHx1HBZ5ZZb0KSUVWl4jkNzYw6AO39OoTELtrjaQw==",
+      "dependencies": {
+        "@nexucis/fuzzy": "^0.3.0"
+      }
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "dev": true,
@@ -5952,6 +5965,17 @@
         "react": "17.0.2"
       }
     },
+    "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": "17.0.2",
       "license": "MIT"
@@ -6603,6 +6627,14 @@
       "dev": true,
       "license": "MIT"
     },
+    "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/to-fast-properties": {
       "version": "2.0.0",
       "dev": true,
@@ -7240,6 +7272,7 @@
         "@fortawesome/free-solid-svg-icons": "^5.7.2",
         "@fortawesome/react-fontawesome": "^0.1.16",
         "@nexucis/fuzzy": "^0.3.0",
+        "@nexucis/kvsearch": "^0.3.0",
         "bootstrap": "^4.6.1",
         "codemirror-promql": "0.19.0",
         "css.escape": "^1.5.1",
@@ -7252,6 +7285,7 @@
         "react": "^17.0.2",
         "react-copy-to-clipboard": "^5.0.4",
         "react-dom": "^17.0.2",
+        "react-infinite-scroll-component": "^6.1.0",
         "react-resize-detector": "^6.7.6",
         "react-router-dom": "^5.2.1",
         "react-test-renderer": "^17.0.2",
@@ -9920,10 +9954,6 @@
         "node": ">=8"
       }
     },
-    "react-app/node_modules/@nexucis/fuzzy": {
-      "version": "0.3.0",
-      "license": "MIT"
-    },
     "react-app/node_modules/@npmcli/fs": {
       "version": "1.0.0",
       "dev": true,
@@ -27660,6 +27690,19 @@
         "@lezer/common": "^0.15.0"
       }
     },
+    "@nexucis/fuzzy": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@nexucis/fuzzy/-/fuzzy-0.3.0.tgz",
+      "integrity": "sha512-Z1+ADKY0fxdBE28REraWhUCNy+Bp5UmpK3Tc/5wdCDpY+6fXh8l2csMtbPGaqEBsyGLxJz9wUYGCf+CW9unyvQ=="
+    },
+    "@nexucis/kvsearch": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@nexucis/kvsearch/-/kvsearch-0.3.0.tgz",
+      "integrity": "sha512-tHIH6W/mRUZZ0ZQyRbgp2uhat+2O1c1jX1EC6NHv7/8OIeHx1HBZ5ZZb0KSUVWl4jkNzYw6AO39OoTELtrjaQw==",
+      "requires": {
+        "@nexucis/fuzzy": "^0.3.0"
+      }
+    },
     "@nodelib/fs.scandir": {
       "version": "2.1.5",
       "dev": true,
@@ -29682,6 +29725,7 @@
         "@fortawesome/free-solid-svg-icons": "^5.7.2",
         "@fortawesome/react-fontawesome": "^0.1.16",
         "@nexucis/fuzzy": "^0.3.0",
+        "@nexucis/kvsearch": "^0.3.0",
         "@testing-library/react-hooks": "^7.0.1",
         "@types/enzyme": "^3.10.10",
         "@types/flot": "0.0.32",
@@ -29718,6 +29762,7 @@
         "react": "^17.0.2",
         "react-copy-to-clipboard": "^5.0.4",
         "react-dom": "^17.0.2",
+        "react-infinite-scroll-component": "^6.1.0",
         "react-resize-detector": "^6.7.6",
         "react-router-dom": "^5.2.1",
         "react-scripts": "4.0.3",
@@ -31395,9 +31440,6 @@
             }
           }
         },
-        "@nexucis/fuzzy": {
-          "version": "0.3.0"
-        },
         "@npmcli/fs": {
           "version": "1.0.0",
           "dev": true,
@@ -44490,6 +44532,14 @@
         "scheduler": "^0.20.2"
       }
     },
+    "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==",
+      "requires": {
+        "throttle-debounce": "^2.1.0"
+      }
+    },
     "react-is": {
       "version": "17.0.2"
     },
@@ -44937,6 +44987,11 @@
       "version": "0.2.0",
       "dev": true
     },
+    "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=="
+    },
     "to-fast-properties": {
       "version": "2.0.0",
       "dev": true
diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json
index 3369b93325e..5eed84df1dd 100644
--- a/web/ui/react-app/package.json
+++ b/web/ui/react-app/package.json
@@ -20,6 +20,7 @@
     "@fortawesome/free-solid-svg-icons": "^5.7.2",
     "@fortawesome/react-fontawesome": "^0.1.16",
     "@nexucis/fuzzy": "^0.3.0",
+    "@nexucis/kvsearch": "^0.3.0",
     "bootstrap": "^4.6.1",
     "codemirror-promql": "0.19.0",
     "css.escape": "^1.5.1",
@@ -32,6 +33,7 @@
     "react": "^17.0.2",
     "react-copy-to-clipboard": "^5.0.4",
     "react-dom": "^17.0.2",
+    "react-infinite-scroll-component": "^6.1.0",
     "react-resize-detector": "^6.7.6",
     "react-router-dom": "^5.2.1",
     "react-test-renderer": "^17.0.2",
diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolContent.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolContent.tsx
new file mode 100644
index 00000000000..8a55a09b164
--- /dev/null
+++ b/web/ui/react-app/src/pages/targets/ScrapePoolContent.tsx
@@ -0,0 +1,91 @@
+import React, { FC, useEffect, useState } from 'react';
+import { getColor, Target } from './target';
+import InfiniteScroll from 'react-infinite-scroll-component';
+import { Badge, Table } from 'reactstrap';
+import TargetLabels from './TargetLabels';
+import styles from './ScrapePoolPanel.module.css';
+import { formatRelative } from '../../utils';
+import { now } from 'moment';
+import TargetScrapeDuration from './TargetScrapeDuration';
+import EndpointLink from './EndpointLink';
+
+const columns = ['Endpoint', 'State', 'Labels', 'Last Scrape', 'Scrape Duration', 'Error'];
+const initialNumberOfTargetsDisplayed = 50;
+
+interface ScrapePoolContentProps {
+  targets: Target[];
+}
+
+export const ScrapePoolContent: FC<ScrapePoolContentProps> = ({ targets }) => {
+  const [items, setItems] = useState<Target[]>(targets.slice(0, 50));
+  const [index, setIndex] = useState<number>(initialNumberOfTargetsDisplayed);
+  const [hasMore, setHasMore] = useState<boolean>(targets.length > initialNumberOfTargetsDisplayed);
+
+  useEffect(() => {
+    setItems(targets.slice(0, initialNumberOfTargetsDisplayed));
+    setHasMore(targets.length > initialNumberOfTargetsDisplayed);
+  }, [targets]);
+
+  const fetchMoreData = () => {
+    if (items.length === targets.length) {
+      setHasMore(false);
+    } else {
+      const newIndex = index + initialNumberOfTargetsDisplayed;
+      setIndex(newIndex);
+      setItems(targets.slice(0, newIndex));
+    }
+  };
+
+  return (
+    <InfiniteScroll
+      next={fetchMoreData}
+      hasMore={hasMore}
+      loader={<h4>loading...</h4>}
+      dataLength={items.length}
+      height={items.length > 25 ? '75vh' : ''}
+    >
+      <Table className={styles.table} size="sm" bordered hover striped>
+        <thead>
+          <tr key="header">
+            {columns.map((column) => (
+              <th key={column}>{column}</th>
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {items.map((target, index) => (
+            <tr key={index}>
+              <td className={styles.endpoint}>
+                <EndpointLink endpoint={target.scrapeUrl} globalUrl={target.globalUrl} />
+              </td>
+              <td className={styles.state}>
+                <Badge color={getColor(target.health)}>{target.health.toUpperCase()}</Badge>
+              </td>
+              <td className={styles.labels}>
+                <TargetLabels
+                  discoveredLabels={target.discoveredLabels}
+                  labels={target.labels}
+                  scrapePool={target.scrapePool}
+                  idx={index}
+                />
+              </td>
+              <td className={styles['last-scrape']}>{formatRelative(target.lastScrape, now())}</td>
+              <td className={styles['scrape-duration']}>
+                <TargetScrapeDuration
+                  duration={target.lastScrapeDuration}
+                  scrapePool={target.scrapePool}
+                  idx={index}
+                  interval={target.scrapeInterval}
+                  timeout={target.scrapeTimeout}
+                />
+              </td>
+              <td className={styles.errors}>
+                {target.lastError ? <span className="text-danger">{target.lastError}</span> : null}
+              </td>
+            </tr>
+          ))}
+        </tbody>
+      </Table>
+    </InfiniteScroll>
+  );
+};
diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx
index a5f2183632b..f3f42da75a3 100644
--- a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx
+++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx
@@ -1,26 +1,69 @@
-import React, { FC } from 'react';
-import Filter, { Expanded, FilterData } from './Filter';
-import { useFetch } from '../../hooks/useFetch';
-import { groupTargets, Target } from './target';
-import ScrapePoolPanel from './ScrapePoolPanel';
-import { withStatusIndicator } from '../../components/withStatusIndicator';
+import { KVSearch } from '@nexucis/kvsearch';
 import { usePathPrefix } from '../../contexts/PathPrefixContext';
+import { useFetch } from '../../hooks/useFetch';
 import { API_PATH } from '../../constants/constants';
+import { groupTargets, ScrapePool, ScrapePools, Target } from './target';
+import { withStatusIndicator } from '../../components/withStatusIndicator';
+import { ChangeEvent, FC, useEffect, useState } from 'react';
+import { Col, Collapse, Input, InputGroup, InputGroupAddon, InputGroupText, Row } from 'reactstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faSearch } from '@fortawesome/free-solid-svg-icons';
+import { ScrapePoolContent } from './ScrapePoolContent';
+import Filter, { Expanded, FilterData } from './Filter';
 import { useLocalStorage } from '../../hooks/useLocalStorage';
+import styles from './ScrapePoolPanel.module.css';
+import { ToggleMoreLess } from '../../components/ToggleMoreLess';
 
 interface ScrapePoolListProps {
   activeTargets: Target[];
 }
 
-export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ activeTargets }) => {
-  const targetGroups = groupTargets(activeTargets);
+const kvSearch = new KVSearch({
+  shouldSort: true,
+  indexedKeys: ['labels', 'scrapePool', ['labels', /.*/]],
+});
+
+interface PanelProps {
+  scrapePool: string;
+  targetGroup: ScrapePool;
+  expanded: boolean;
+  toggleExpanded: () => void;
+}
+
+export const ScrapePoolPanel: FC<PanelProps> = (props: PanelProps) => {
+  const modifier = props.targetGroup.upCount < props.targetGroup.targets.length ? 'danger' : 'normal';
+  const id = `pool-${props.scrapePool}`;
+  const anchorProps = {
+    href: `#${id}`,
+    id,
+  };
+  return (
+    <div>
+      <ToggleMoreLess event={props.toggleExpanded} showMore={props.expanded}>
+        <a className={styles[modifier]} {...anchorProps}>
+          {`${props.scrapePool} (${props.targetGroup.upCount}/${props.targetGroup.targets.length} up)`}
+        </a>
+      </ToggleMoreLess>
+      <Collapse isOpen={props.expanded}>
+        <ScrapePoolContent targets={props.targetGroup.targets} />
+      </Collapse>
+    </div>
+  );
+};
+
+// ScrapePoolListContent is taking care of every possible filter
+const ScrapePoolListContent: FC<ScrapePoolListProps> = ({ activeTargets }) => {
+  const initialPoolList = groupTargets(activeTargets);
+  const [poolList, setPoolList] = useState<ScrapePools>(initialPoolList);
+  const [targetList, setTargetList] = useState(activeTargets);
+
   const initialFilter: FilterData = {
     showHealthy: true,
     showUnhealthy: true,
   };
   const [filter, setFilter] = useLocalStorage('targets-page-filter', initialFilter);
 
-  const initialExpanded: Expanded = Object.keys(targetGroups).reduce(
+  const initialExpanded: Expanded = Object.keys(initialPoolList).reduce(
     (acc: { [scrapePool: string]: boolean }, scrapePool: string) => ({
       ...acc,
       [scrapePool]: true,
@@ -28,14 +71,44 @@ export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ activeTargets }) =>
     {}
   );
   const [expanded, setExpanded] = useLocalStorage('targets-page-expansion-state', initialExpanded);
-
   const { showHealthy, showUnhealthy } = filter;
+
+  const handleSearchChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
+    if (e.target.value !== '') {
+      const result = kvSearch.filter(e.target.value.trim(), activeTargets);
+      setTargetList(
+        result.map((value) => {
+          return value.original as unknown as Target;
+        })
+      );
+    } else {
+      setTargetList(activeTargets);
+    }
+  };
+
+  useEffect(() => {
+    const list = targetList.filter((t) => showHealthy || t.health.toLowerCase() !== 'up');
+    setPoolList(groupTargets(list));
+  }, [showHealthy, targetList]);
+
   return (
     <>
-      <Filter filter={filter} setFilter={setFilter} expanded={expanded} setExpanded={setExpanded} />
-      {Object.keys(targetGroups)
+      <Row xs="4" className="align-items-center">
+        <Col>
+          <Filter filter={filter} setFilter={setFilter} expanded={expanded} setExpanded={setExpanded} />
+        </Col>
+        <Col xs="6">
+          <InputGroup>
+            <InputGroupAddon addonType="prepend">
+              <InputGroupText>{<FontAwesomeIcon icon={faSearch} />}</InputGroupText>
+            </InputGroupAddon>
+            <Input autoFocus onChange={handleSearchChange} placeholder="Filter by endpoint or labels" />
+          </InputGroup>
+        </Col>
+      </Row>
+      {Object.keys(poolList)
         .filter((scrapePool) => {
-          const targetGroup = targetGroups[scrapePool];
+          const targetGroup = poolList[scrapePool];
           const isHealthy = targetGroup.upCount === targetGroup.targets.length;
           return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy);
         })
@@ -43,7 +116,7 @@ export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ activeTargets }) =>
           <ScrapePoolPanel
             key={scrapePool}
             scrapePool={scrapePool}
-            targetGroup={targetGroups[scrapePool]}
+            targetGroup={poolList[scrapePool]}
             expanded={expanded[scrapePool]}
             toggleExpanded={(): void => setExpanded({ ...expanded, [scrapePool]: !expanded[scrapePool] })}
           />
@@ -51,11 +124,10 @@ export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ activeTargets }) =>
     </>
   );
 };
-ScrapePoolContent.displayName = 'ScrapePoolContent';
 
-const ScrapePoolListWithStatusIndicator = withStatusIndicator(ScrapePoolContent);
+const ScrapePoolListWithStatusIndicator = withStatusIndicator(ScrapePoolListContent);
 
-const ScrapePoolList: FC = () => {
+export const ScrapePoolList: FC = () => {
   const pathPrefix = usePathPrefix();
   const { response, error, isLoading } = useFetch<ScrapePoolListProps>(`${pathPrefix}/${API_PATH}/targets?state=active`);
   const { status: responseStatus } = response;
diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx
deleted file mode 100644
index 35ff99eb268..00000000000
--- a/web/ui/react-app/src/pages/targets/ScrapePoolPanel.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import React, { FC } from 'react';
-import { ScrapePool, getColor } from './target';
-import { Collapse, Table, Badge } from 'reactstrap';
-import styles from './ScrapePoolPanel.module.css';
-import { Target } from './target';
-import EndpointLink from './EndpointLink';
-import TargetLabels from './TargetLabels';
-import TargetScrapeDuration from './TargetScrapeDuration';
-import { now } from 'moment';
-import { ToggleMoreLess } from '../../components/ToggleMoreLess';
-import { formatRelative } from '../../utils';
-
-interface PanelProps {
-  scrapePool: string;
-  targetGroup: ScrapePool;
-  expanded: boolean;
-  toggleExpanded: () => void;
-}
-
-export const columns = ['Endpoint', 'State', 'Labels', 'Last Scrape', 'Scrape Duration', 'Error'];
-
-const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup, expanded, toggleExpanded }) => {
-  const modifier = targetGroup.upCount < targetGroup.targets.length ? 'danger' : 'normal';
-  const id = `pool-${scrapePool}`;
-  const anchorProps = {
-    href: `#${id}`,
-    id,
-  };
-
-  return (
-    <div className={styles.container}>
-      <ToggleMoreLess event={toggleExpanded} showMore={expanded}>
-        <a className={styles[modifier]} {...anchorProps}>
-          {`${scrapePool} (${targetGroup.upCount}/${targetGroup.targets.length} up)`}
-        </a>
-      </ToggleMoreLess>
-      <Collapse isOpen={expanded}>
-        <Table className={styles.table} size="sm" bordered hover striped>
-          <thead>
-            <tr key="header">
-              {columns.map((column) => (
-                <th key={column}>{column}</th>
-              ))}
-            </tr>
-          </thead>
-          <tbody>
-            {targetGroup.targets.map((target: Target, idx: number) => {
-              const {
-                discoveredLabels,
-                labels,
-                scrapePool,
-                scrapeUrl,
-                globalUrl,
-                lastError,
-                lastScrape,
-                lastScrapeDuration,
-                health,
-                scrapeInterval,
-                scrapeTimeout,
-              } = target;
-              const color = getColor(health);
-
-              return (
-                <tr key={scrapeUrl}>
-                  <td className={styles.endpoint}>
-                    <EndpointLink endpoint={scrapeUrl} globalUrl={globalUrl} />
-                  </td>
-                  <td className={styles.state}>
-                    <Badge color={color}>{health.toUpperCase()}</Badge>
-                  </td>
-                  <td className={styles.labels}>
-                    <TargetLabels discoveredLabels={discoveredLabels} labels={labels} scrapePool={scrapePool} idx={idx} />
-                  </td>
-                  <td className={styles['last-scrape']}>{formatRelative(lastScrape, now())}</td>
-                  <td className={styles['scrape-duration']}>
-                    <TargetScrapeDuration
-                      duration={lastScrapeDuration}
-                      scrapePool={scrapePool}
-                      idx={idx}
-                      interval={scrapeInterval}
-                      timeout={scrapeTimeout}
-                    />
-                  </td>
-                  <td className={styles.errors}>{lastError ? <span className="text-danger">{lastError}</span> : null}</td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </Table>
-      </Collapse>
-    </div>
-  );
-};
-
-export default ScrapePoolPanel;
diff --git a/web/ui/react-app/src/pages/targets/TargetLabels.tsx b/web/ui/react-app/src/pages/targets/TargetLabels.tsx
index 2664cda96a3..d85c58304e2 100644
--- a/web/ui/react-app/src/pages/targets/TargetLabels.tsx
+++ b/web/ui/react-app/src/pages/targets/TargetLabels.tsx
@@ -33,10 +33,16 @@ const TargetLabels: FC<TargetLabelsProps> = ({ discoveredLabels, labels, idx, sc
           );
         })}
       </div>
-      <Tooltip isOpen={tooltipOpen} target={CSS.escape(id)} toggle={toggle} style={{ maxWidth: 'none', textAlign: 'left' }}>
+      <Tooltip
+        isOpen={tooltipOpen}
+        target={CSS.escape(id)}
+        toggle={toggle}
+        placement={'right-end'}
+        style={{ maxWidth: 'none', textAlign: 'left' }}
+      >
         <b>Before relabeling:</b>
-        {formatLabels(discoveredLabels).map((s: string, idx: number) => (
-          <Fragment key={idx}>
+        {formatLabels(discoveredLabels).map((s: string, labelIndex: number) => (
+          <Fragment key={labelIndex}>
             <br />
             <span className={styles.discovered}>{s}</span>
           </Fragment>
diff --git a/web/ui/react-app/src/pages/targets/target.ts b/web/ui/react-app/src/pages/targets/target.ts
index faa89bb1801..a3ca48ce1f8 100644
--- a/web/ui/react-app/src/pages/targets/target.ts
+++ b/web/ui/react-app/src/pages/targets/target.ts
@@ -2,7 +2,7 @@ export interface Labels {
   [key: string]: string;
 }
 
-export interface Target {
+export type Target = {
   discoveredLabels: Labels;
   labels: Labels;
   scrapePool: string;
@@ -14,7 +14,7 @@ export interface Target {
   health: string;
   scrapeInterval: string;
   scrapeTimeout: string;
-}
+};
 
 export interface DroppedTarget {
   discoveredLabels: Labels;


Test patch:
diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx
index dc400de9948..867d1d3bed7 100644
--- a/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx
+++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx
@@ -3,8 +3,7 @@ import { mount, ReactWrapper } from 'enzyme';
 import { act } from 'react-dom/test-utils';
 import { Alert } from 'reactstrap';
 import { sampleApiResponse } from './__testdata__/testdata';
-import ScrapePoolList from './ScrapePoolList';
-import ScrapePoolPanel from './ScrapePoolPanel';
+import ScrapePoolList, { ScrapePoolPanel } from './ScrapePoolList';
 import { Target } from './target';
 import { FetchMock } from 'jest-fetch-mock/types';
 import { PathPrefixContext } from '../../contexts/PathPrefixContext';
@@ -48,7 +47,7 @@ describe('ScrapePoolList', () => {
       });
       const panels = scrapePoolList.find(ScrapePoolPanel);
       expect(panels).toHaveLength(3);
-      const activeTargets: Target[] = sampleApiResponse.data.activeTargets as Target[];
+      const activeTargets: Target[] = sampleApiResponse.data.activeTargets as unknown as Target[];
       activeTargets.forEach(({ scrapePool }: Target) => {
         const panel = scrapePoolList.find(ScrapePoolPanel).filterWhere((panel) => panel.prop('scrapePool') === scrapePool);
         expect(panel).toHaveLength(1);
diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolPanel.test.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolPanel.test.tsx
deleted file mode 100644
index 5facf7b76e4..00000000000
--- a/web/ui/react-app/src/pages/targets/ScrapePoolPanel.test.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import React from 'react';
-import { mount, shallow } from 'enzyme';
-import { targetGroups } from './__testdata__/testdata';
-import ScrapePoolPanel, { columns } from './ScrapePoolPanel';
-import { Button, Collapse, Table, Badge } from 'reactstrap';
-import { Target, getColor } from './target';
-import EndpointLink from './EndpointLink';
-import TargetLabels from './TargetLabels';
-import sinon from 'sinon';
-
-describe('ScrapePoolPanel', () => {
-  const defaultProps = {
-    scrapePool: 'blackbox',
-    targetGroup: targetGroups.blackbox,
-    expanded: true,
-    toggleExpanded: sinon.spy(),
-  };
-  const scrapePoolPanel = shallow(<ScrapePoolPanel {...defaultProps} />);
-
-  it('renders a container', () => {
-    const div = scrapePoolPanel.find('div').filterWhere((elem) => elem.hasClass('container'));
-    expect(div).toHaveLength(1);
-  });
-
-  describe('Header', () => {
-    it('renders an anchor with up count and danger color if upCount < targetsCount', () => {
-      const anchor = scrapePoolPanel.find('a');
-      expect(anchor).toHaveLength(1);
-      expect(anchor.prop('id')).toEqual('pool-blackbox');
-      expect(anchor.prop('href')).toEqual('#pool-blackbox');
-      expect(anchor.text()).toEqual('blackbox (2/3 up)');
-      expect(anchor.prop('className')).toEqual('danger');
-    });
-
-    it('renders an anchor with up count and normal color if upCount == targetsCount', () => {
-      const props = {
-        ...defaultProps,
-        scrapePool: 'prometheus',
-        targetGroup: targetGroups.prometheus,
-      };
-      const scrapePoolPanel = shallow(<ScrapePoolPanel {...props} />);
-      const anchor = scrapePoolPanel.find('a');
-      expect(anchor).toHaveLength(1);
-      expect(anchor.prop('id')).toEqual('pool-prometheus');
-      expect(anchor.prop('href')).toEqual('#pool-prometheus');
-      expect(anchor.text()).toEqual('prometheus (1/1 up)');
-      expect(anchor.prop('className')).toEqual('normal');
-    });
-
-    it('renders a show more btn if collapsed', () => {
-      const props = {
-        ...defaultProps,
-        scrapePool: 'prometheus',
-        targetGroup: targetGroups.prometheus,
-        toggleExpanded: sinon.spy(),
-      };
-      const div = document.createElement('div');
-      div.id = `series-labels-prometheus-0`;
-      document.body.appendChild(div);
-      const div2 = document.createElement('div');
-      div2.id = `scrape-duration-prometheus-0`;
-      document.body.appendChild(div2);
-      const scrapePoolPanel = mount(<ScrapePoolPanel {...props} />);
-
-      const btn = scrapePoolPanel.find(Button);
-      btn.simulate('click');
-      expect(props.toggleExpanded.calledOnce).toBe(true);
-    });
-  });
-
-  it('renders a Collapse component', () => {
-    const collapse = scrapePoolPanel.find(Collapse);
-    expect(collapse.prop('isOpen')).toBe(true);
-  });
-
-  describe('Table', () => {
-    it('renders a table', () => {
-      const table = scrapePoolPanel.find(Table);
-      const headers = table.find('th');
-      expect(table).toHaveLength(1);
-      expect(headers).toHaveLength(6);
-      columns.forEach((col) => {
-        expect(headers.contains(col));
-      });
-    });
-
-    describe('for each target', () => {
-      const table = scrapePoolPanel.find(Table);
-      defaultProps.targetGroup.targets.forEach(
-        ({ discoveredLabels, labels, scrapeUrl, lastError, health }: Target, idx: number) => {
-          const row = table.find('tr').at(idx + 1);
-
-          it('renders an EndpointLink with the scrapeUrl', () => {
-            const link = row.find(EndpointLink);
-            expect(link).toHaveLength(1);
-            expect(link.prop('endpoint')).toEqual(scrapeUrl);
-          });
-
-          it('renders a badge for health', () => {
-            const td = row.find('td').filterWhere((elem) => Boolean(elem.hasClass('state')));
-            const badge = td.find(Badge);
-            expect(badge).toHaveLength(1);
-            expect(badge.prop('color')).toEqual(getColor(health));
-            expect(badge.children().text()).toEqual(health.toUpperCase());
-          });
-
-          it('renders series labels', () => {
-            const targetLabels = row.find(TargetLabels);
-            expect(targetLabels).toHaveLength(1);
-            expect(targetLabels.prop('discoveredLabels')).toEqual(discoveredLabels);
-            expect(targetLabels.prop('labels')).toEqual(labels);
-          });
-
-          it('renders last scrape time', () => {
-            const lastScrapeCell = row.find('td').filterWhere((elem) => Boolean(elem.hasClass('last-scrape')));
-            expect(lastScrapeCell).toHaveLength(1);
-          });
-
-          it('renders last scrape duration', () => {
-            const lastScrapeCell = row.find('td').filterWhere((elem) => Boolean(elem.hasClass('scrape-duration')));
-            expect(lastScrapeCell).toHaveLength(1);
-          });
-
-          it('renders a badge for Errors', () => {
-            const td = row.find('td').filterWhere((elem) => Boolean(elem.hasClass('errors')));
-            const badge = td.find(Badge);
-            expect(badge).toHaveLength(lastError ? 1 : 0);
-            if (lastError) {
-              expect(badge.prop('color')).toEqual('danger');
-              expect(badge.children().text()).toEqual(lastError);
-            }
-          });
-        }
-      );
-    });
-  });
-});
diff --git a/web/ui/react-app/src/pages/targets/__snapshots__/TargetLabels.test.tsx.snap b/web/ui/react-app/src/pages/targets/__snapshots__/TargetLabels.test.tsx.snap
index 76c139feb20..3c5c856f002 100644
--- a/web/ui/react-app/src/pages/targets/__snapshots__/TargetLabels.test.tsx.snap
+++ b/web/ui/react-app/src/pages/targets/__snapshots__/TargetLabels.test.tsx.snap
@@ -37,7 +37,7 @@ exports[`targetLabels renders discovered labels 1`] = `
   <Tooltip
     autohide={true}
     isOpen={false}
-    placement="top"
+    placement="right-end"
     placementPrefix="bs-tooltip"
     style={
       Object {