Repository: prometheus/prometheus
Pull request #6281 (2019-11-06 03:26:46)

React UI: Implement /tsdb-status page
This PR implements the `/tsdb-status` page in React. 

juliusv: @sharadgaur Awesome, thanks! I'll be at PromCon for the next days, so might only be able to give this a thorough review in a couple of days, given the other PRs in the pipeline.

In the meantime, could you also add documentation for the new API endpoint in https://github.com/prometheus/prometheus/blob/master/docs/querying/api.md?
juliusv: Thanks, I think it looks good now besides the last couple of nit comments :)
juliusv: Looks :+1:, will merge when the relevant tests are green :) Thanks again!
sharadgaur: Thank you @juliusv  and @csmarchbanks 

Patch:
diff --git a/docs/querying/api.md b/docs/querying/api.md
index 76e9e231f..fe8b723a6 100644
--- a/docs/querying/api.md
+++ b/docs/querying/api.md
@@ -797,6 +797,67 @@ $ curl http://localhost:9090/api/v1/status/buildinfo
 
 **NOTE**: The exact returned build properties may change without notice between Prometheus versions.
 
+### TSDB Stats
+
+The following endpoint returns various cardinality statistics about the Prometheus TSDB:
+
+```
+GET /api/v1/status/tsdb
+```
+- **seriesCountByMetricName:**  This will provide a list of metrics names and their series count.
+- **labelValueCountByLabelName:** This will provide a list of the label names and their value count.
+- **memoryInBytesByLabelName** This will provide a list of the label names and memory used in bytes. Memory usage is calculated by adding the length of all values for a given label name.
+- **seriesCountByLabelPair** This will provide a list of label value pairs and their series count.
+
+```json
+$ curl http://localhost:9090/api/v1/status/tsdb
+{
+  "status": "success",
+  "data": {
+    "seriesCountByMetricName": [
+      {
+        "name": "net_conntrack_dialer_conn_failed_total",
+        "value": 20
+      },
+      {
+        "name": "prometheus_http_request_duration_seconds_bucket",
+        "value": 20
+      }
+    ],
+    "labelValueCountByLabelName": [
+      {
+        "name": "__name__",
+        "value": 211
+      },
+      {
+        "name": "event",
+        "value": 3
+      }
+    ],
+    "memoryInBytesByLabelName": [
+      {
+        "name": "__name__",
+        "value": 8266
+      },
+      {
+        "name": "instance",
+        "value": 28
+      }
+    ],
+    "seriesCountByLabelValuePair": [
+      {
+        "name": "job=prometheus",
+        "value": 425
+      },
+      {
+        "name": "instance=localhost:9090",
+        "value": 425
+      }
+    ]
+  }
+}
+```
+
 *New in v2.14*
 
 ## TSDB Admin APIs
diff --git a/tsdb/db.go b/tsdb/db.go
index 4fdd3fb14..08d041e4d 100644
--- a/tsdb/db.go
+++ b/tsdb/db.go
@@ -933,7 +933,11 @@ func (db *DB) beyondSizeRetention(blocks []*Block) (deleteable map[ulid.ULID]*Bl
 	}
 
 	deleteable = make(map[ulid.ULID]*Block)
-	blocksSize := int64(0)
+
+	walSize, _ := db.Head().wal.Size()
+	// Initializing size counter with WAL size,
+	// as that is part of the retention strategy.
+	blocksSize := walSize
 	for i, block := range blocks {
 		blocksSize += block.Size()
 		if blocksSize > db.opts.MaxBytes {
diff --git a/tsdb/fileutil/dir.go b/tsdb/fileutil/dir.go
new file mode 100644
index 000000000..e6ac4ec98
--- /dev/null
+++ b/tsdb/fileutil/dir.go
@@ -0,0 +1,33 @@
+// Copyright 2019 The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package fileutil
+
+import (
+	"os"
+	"path/filepath"
+)
+
+func DirSize(dir string) (int64, error) {
+	var size int64
+	err := filepath.Walk(dir, func(filePath string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if !info.IsDir() {
+			size += info.Size()
+		}
+		return nil
+	})
+	return size, err
+}
diff --git a/tsdb/wal/wal.go b/tsdb/wal/wal.go
index 16f2e8794..b0b693174 100644
--- a/tsdb/wal/wal.go
+++ b/tsdb/wal/wal.go
@@ -876,3 +876,9 @@ func (r *segmentBufReader) Read(b []byte) (n int, err error) {
 	r.buf.Reset(r.segs[r.cur])
 	return n, nil
 }
+
+// Computing size of the WAL.
+// We do this by adding the sizes of all the files under the WAL dir.
+func (w *WAL) Size() (int64, error) {
+	return fileutil.DirSize(w.Dir())
+}
diff --git a/web/api/v1/api.go b/web/api/v1/api.go
index 0f33d33b6..735dbc526 100644
--- a/web/api/v1/api.go
+++ b/web/api/v1/api.go
@@ -36,6 +36,8 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/common/model"
 	"github.com/prometheus/common/route"
+	"github.com/prometheus/prometheus/tsdb"
+	"github.com/prometheus/prometheus/tsdb/index"
 	tsdbLabels "github.com/prometheus/prometheus/tsdb/labels"
 
 	"github.com/prometheus/prometheus/config"
@@ -158,6 +160,7 @@ type TSDBAdmin interface {
 	Delete(mint, maxt int64, ms ...tsdbLabels.Matcher) error
 	Dir() string
 	Snapshot(dir string, withHead bool) error
+	Head() *tsdb.Head
 }
 
 // API can register a set of endpoints in a router and handle
@@ -278,6 +281,7 @@ func (api *API) Register(r *route.Router) {
 	r.Get("/status/runtimeinfo", wrap(api.serveRuntimeInfo))
 	r.Get("/status/buildinfo", wrap(api.serveBuildInfo))
 	r.Get("/status/flags", wrap(api.serveFlags))
+	r.Get("/status/tsdb", wrap(api.serveTSDBStatus))
 	r.Post("/read", api.ready(http.HandlerFunc(api.remoteRead)))
 
 	r.Get("/alerts", wrap(api.alerts))
@@ -915,6 +919,45 @@ func (api *API) serveFlags(r *http.Request) apiFuncResult {
 	return apiFuncResult{api.flagsMap, nil, nil, nil}
 }
 
+// stat holds the information about individual cardinality.
+type stat struct {
+	Name  string `json:"name"`
+	Value uint64 `json:"value"`
+}
+
+// tsdbStatus has information of cardinality statistics from postings.
+type tsdbStatus struct {
+	SeriesCountByMetricName     []stat `json:"seriesCountByMetricName"`
+	LabelValueCountByLabelName  []stat `json:"labelValueCountByLabelName"`
+	MemoryInBytesByLabelName    []stat `json:"memoryInBytesByLabelName"`
+	SeriesCountByLabelValuePair []stat `json:"seriesCountByLabelValuePair"`
+}
+
+func (api *API) serveTSDBStatus(r *http.Request) apiFuncResult {
+	db := api.db()
+	if db == nil {
+		return apiFuncResult{nil, &apiError{errorUnavailable, errors.New("TSDB not ready")}, nil, nil}
+	}
+	convert := func(stats []index.Stat) []stat {
+		result := make([]stat, 0, len(stats))
+		for _, item := range stats {
+			item := stat{Name: item.Name, Value: item.Count}
+			result = append(result, item)
+		}
+		return result
+	}
+
+	posting := db.Head().PostingsCardinalityStats(model.MetricNameLabel)
+	response := tsdbStatus{
+		SeriesCountByMetricName:     convert(posting.CardinalityMetricsStats),
+		LabelValueCountByLabelName:  convert(posting.CardinalityLabelStats),
+		MemoryInBytesByLabelName:    convert(posting.LabelValueStats),
+		SeriesCountByLabelValuePair: convert(posting.LabelValuePairsStats),
+	}
+
+	return apiFuncResult{response, nil, nil, nil}
+}
+
 func (api *API) remoteRead(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	if err := api.remoteReadGate.Start(ctx); err != nil {
diff --git a/web/ui/react-app/src/App.tsx b/web/ui/react-app/src/App.tsx
index f4f2ada1c..2666adef9 100755
--- a/web/ui/react-app/src/App.tsx
+++ b/web/ui/react-app/src/App.tsx
@@ -4,7 +4,7 @@ import { Container } from 'reactstrap';
 
 import './App.css';
 import { Router, Redirect } from '@reach/router';
-import { Alerts, Config, Flags, Rules, Services, Status, Targets, PanelList } from './pages';
+import { Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList } from './pages';
 import PathPrefixProps from './PathPrefixProps';
 
 const App: FC<PathPrefixProps> = ({ pathPrefix }) => {
@@ -22,6 +22,7 @@ const App: FC<PathPrefixProps> = ({ pathPrefix }) => {
           <Rules path="/rules" pathPrefix={pathPrefix} />
           <Services path="/service-discovery" pathPrefix={pathPrefix} />
           <Status path="/status" pathPrefix={pathPrefix} />
+          <TSDBStatus path="/tsdb-status" pathPrefix={pathPrefix} />
           <Targets path="/targets" pathPrefix={pathPrefix} />
         </Router>
       </Container>
diff --git a/web/ui/react-app/src/ExpressionInput.tsx b/web/ui/react-app/src/ExpressionInput.tsx
index d36020b63..b6fbe39a4 100644
--- a/web/ui/react-app/src/ExpressionInput.tsx
+++ b/web/ui/react-app/src/ExpressionInput.tsx
@@ -42,19 +42,20 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
   };
 
   handleInput = () => {
-    this.setState(
-      {
-        height: 'auto',
-        value: this.exprInputRef.current!.value,
-      },
-      this.setHeight
-    );
+    this.setValue(this.exprInputRef.current!.value);
   };
 
-  handleDropdownSelection = (value: string) => {
+  setValue = (value: string) => {
     this.setState({ value, height: 'auto' }, this.setHeight);
   };
 
+  componentDidUpdate(prevProps: ExpressionInputProps) {
+    const { value } = this.props;
+    if (value !== prevProps.value) {
+      this.setValue(value);
+    }
+  }
+
   handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
     if (event.key === 'Enter' && !event.shiftKey) {
       this.executeQuery();
@@ -126,7 +127,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
   render() {
     const { value, height } = this.state;
     return (
-      <Downshift onSelect={this.handleDropdownSelection}>
+      <Downshift onSelect={this.setValue}>
         {downshift => (
           <div>
             <InputGroup className="expression-input">
diff --git a/web/ui/react-app/src/Navbar.tsx b/web/ui/react-app/src/Navbar.tsx
index ed945dd0d..606a806c6 100644
--- a/web/ui/react-app/src/Navbar.tsx
+++ b/web/ui/react-app/src/Navbar.tsx
@@ -43,6 +43,9 @@ const Navigation: FC<PathPrefixProps> = ({ pathPrefix }) => {
               <DropdownItem tag={Link} to={`${pathPrefix}/new/status`}>
                 Runtime & Build Information
               </DropdownItem>
+              <DropdownItem tag={Link} to={`${pathPrefix}/new/tsdb-status`}>
+                TSDB Status
+              </DropdownItem>
               <DropdownItem tag={Link} to={`${pathPrefix}/new/flags`}>
                 Command-Line Flags
               </DropdownItem>
diff --git a/web/ui/react-app/src/Panel.tsx b/web/ui/react-app/src/Panel.tsx
index 78d651c31..2c6299ff6 100644
--- a/web/ui/react-app/src/Panel.tsx
+++ b/web/ui/react-app/src/Panel.tsx
@@ -79,7 +79,8 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
       prevOpts.type !== opts.type ||
       prevOpts.range !== opts.range ||
       prevOpts.endTime !== opts.endTime ||
-      prevOpts.resolution !== opts.resolution
+      prevOpts.resolution !== opts.resolution ||
+      prevOpts.expr !== opts.expr
     ) {
       if (prevOpts.type !== opts.type) {
         // If the other options change, we still want to show the old data until the new
diff --git a/web/ui/react-app/src/pages/TSDBStatus.tsx b/web/ui/react-app/src/pages/TSDBStatus.tsx
new file mode 100644
index 000000000..826350793
--- /dev/null
+++ b/web/ui/react-app/src/pages/TSDBStatus.tsx
@@ -0,0 +1,87 @@
+import React, { FC, Fragment } from 'react';
+import { RouteComponentProps } from '@reach/router';
+import { Alert, Table } from 'reactstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faSpinner } from '@fortawesome/free-solid-svg-icons';
+import { useFetch } from '../utils/useFetch';
+import PathPrefixProps from '../PathPrefixProps';
+
+export interface Stats {
+  name: string;
+  value: number;
+}
+
+export interface TSDBMap {
+  seriesCountByMetricName: Array<Stats>;
+  labelValueCountByLabelName: Array<Stats>;
+  memoryInBytesByLabelName: Array<Stats>;
+  seriesCountByLabelValuePair: Array<Stats>;
+}
+
+const paddingStyle = {
+  padding: '10px',
+};
+
+function createTable(title: string, unit: string, stats: Array<Stats>) {
+  return (
+    <div style={paddingStyle}>
+      <h3>{title}</h3>
+      <Table bordered={true} size="sm" striped={true}>
+        <thead>
+          <tr>
+            <th>Name</th>
+            <th>{unit}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {stats.map((element: Stats, i: number) => {
+            return (
+              <Fragment key={i}>
+                <tr>
+                  <td>{element.name}</td>
+                  <td>{element.value}</td>
+                </tr>
+              </Fragment>
+            );
+          })}
+        </tbody>
+      </Table>
+    </div>
+  );
+}
+
+const TSDBStatus: FC<RouteComponentProps & PathPrefixProps> = ({ pathPrefix }) => {
+  const { response, error } = useFetch(`${pathPrefix}/api/v1/status/tsdb`);
+  const headStats = () => {
+    const stats: TSDBMap = response && response.data;
+    if (error) {
+      return (
+        <Alert color="danger">
+          <strong>Error:</strong> Error fetching TSDB Status: {error.message}
+        </Alert>
+      );
+    } else if (stats) {
+      return (
+        <div>
+          <div style={paddingStyle}>
+            <h3>Head Cardinality Stats</h3>
+          </div>
+          {createTable('Top 10 label names with value count', 'Count', stats.labelValueCountByLabelName)}
+          {createTable('Top 10 series count by metric names', 'Count', stats.seriesCountByMetricName)}
+          {createTable('Top 10 label names with high memory usage', 'Bytes', stats.memoryInBytesByLabelName)}
+          {createTable('Top 10 series count by label value pairs', 'Count', stats.seriesCountByLabelValuePair)}
+        </div>
+      );
+    }
+    return <FontAwesomeIcon icon={faSpinner} spin />;
+  };
+
+  return (
+    <div>
+      <h2>TSDB Status</h2>
+      {headStats()}
+    </div>
+  );
+};
+
+export default TSDBStatus;
diff --git a/web/ui/react-app/src/pages/index.ts b/web/ui/react-app/src/pages/index.ts
index b9dd5e9cc..dba245f10 100644
--- a/web/ui/react-app/src/pages/index.ts
+++ b/web/ui/react-app/src/pages/index.ts
@@ -6,5 +6,6 @@ import Services from './Services';
 import Status from './Status';
 import Targets from './targets/Targets';
 import PanelList from './PanelList';
+import TSDBStatus from './TSDBStatus';
 
-export { Alerts, Config, Flags, Rules, Services, Status, Targets, PanelList };
+export { Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList };


Test patch:
diff --git a/tsdb/block_test.go b/tsdb/block_test.go
index a7a159483..86f07a198 100644
--- a/tsdb/block_test.go
+++ b/tsdb/block_test.go
@@ -17,6 +17,8 @@ import (
 	"context"
 	"encoding/binary"
 
+	"github.com/prometheus/prometheus/tsdb/fileutil"
+
 	"errors"
 	"io/ioutil"
 	"math/rand"
@@ -175,7 +177,8 @@ func TestBlockSize(t *testing.T) {
 			testutil.Ok(t, blockInit.Close())
 		}()
 		expSizeInit = blockInit.Size()
-		actSizeInit := testutil.DirSize(t, blockInit.Dir())
+		actSizeInit, err := fileutil.DirSize(blockInit.Dir())
+		testutil.Ok(t, err)
 		testutil.Equals(t, expSizeInit, actSizeInit)
 	}
 
@@ -184,7 +187,7 @@ func TestBlockSize(t *testing.T) {
 		testutil.Ok(t, blockInit.Delete(1, 10, labels.NewMustRegexpMatcher("", ".*")))
 		expAfterDelete := blockInit.Size()
 		testutil.Assert(t, expAfterDelete > expSizeInit, "after a delete the block size should be bigger as the tombstone file should grow %v > %v", expAfterDelete, expSizeInit)
-		actAfterDelete := testutil.DirSize(t, blockDirInit)
+		actAfterDelete, err := fileutil.DirSize(blockDirInit)
 		testutil.Ok(t, err)
 		testutil.Equals(t, expAfterDelete, actAfterDelete, "after a delete reported block size doesn't match actual disk size")
 
@@ -198,7 +201,8 @@ func TestBlockSize(t *testing.T) {
 			testutil.Ok(t, blockAfterCompact.Close())
 		}()
 		expAfterCompact := blockAfterCompact.Size()
-		actAfterCompact := testutil.DirSize(t, blockAfterCompact.Dir())
+		actAfterCompact, err := fileutil.DirSize(blockAfterCompact.Dir())
+		testutil.Ok(t, err)
 		testutil.Assert(t, actAfterDelete > actAfterCompact, "after a delete and compaction the block size should be smaller %v,%v", actAfterDelete, actAfterCompact)
 		testutil.Equals(t, expAfterCompact, actAfterCompact, "after a delete and compaction reported block size doesn't match actual disk size")
 	}
diff --git a/tsdb/db_test.go b/tsdb/db_test.go
index e1a3f4afd..4c44c70f4 100644
--- a/tsdb/db_test.go
+++ b/tsdb/db_test.go
@@ -25,6 +25,8 @@ import (
 	"testing"
 	"time"
 
+	"github.com/prometheus/prometheus/tsdb/fileutil"
+
 	"github.com/go-kit/kit/log"
 	"github.com/oklog/ulid"
 	"github.com/pkg/errors"
@@ -1111,11 +1113,49 @@ func TestSizeRetention(t *testing.T) {
 		createBlock(t, db.Dir(), genSeries(100, 10, m.MinTime, m.MaxTime))
 	}
 
+	headBlocks := []*BlockMeta{
+		{MinTime: 700, MaxTime: 800},
+	}
+
+	// Add some data to the WAL.
+	headApp := db.Head().Appender()
+	for _, m := range headBlocks {
+		series := genSeries(100, 10, m.MinTime, m.MaxTime)
+		for _, s := range series {
+			it := s.Iterator()
+			for it.Next() {
+				tim, v := it.At()
+				_, err := headApp.Add(s.Labels(), tim, v)
+				testutil.Ok(t, err)
+			}
+			testutil.Ok(t, it.Err())
+		}
+	}
+	testutil.Ok(t, headApp.Commit())
+
 	// Test that registered size matches the actual disk size.
-	testutil.Ok(t, db.reload())                                       // Reload the db to register the new db size.
-	testutil.Equals(t, len(blocks), len(db.Blocks()))                 // Ensure all blocks are registered.
-	expSize := int64(prom_testutil.ToFloat64(db.metrics.blocksBytes)) // Use the actual internal metrics.
-	actSize := testutil.DirSize(t, db.Dir())
+	testutil.Ok(t, db.reload())                                         // Reload the db to register the new db size.
+	testutil.Equals(t, len(blocks), len(db.Blocks()))                   // Ensure all blocks are registered.
+	blockSize := int64(prom_testutil.ToFloat64(db.metrics.blocksBytes)) // Use the the actual internal metrics.
+	walSize, err := db.Head().wal.Size()
+	testutil.Ok(t, err)
+	// Expected size should take into account block size + WAL size
+	expSize := blockSize + walSize
+	actSize, err := fileutil.DirSize(db.Dir())
+	testutil.Ok(t, err)
+	testutil.Equals(t, expSize, actSize, "registered size doesn't match actual disk size")
+
+	// Create a WAL checkpoint, and compare sizes.
+	first, last, err := db.Head().wal.Segments()
+	testutil.Ok(t, err)
+	_, err = wal.Checkpoint(db.Head().wal, first, last-1, func(x uint64) bool { return false }, 0)
+	testutil.Ok(t, err)
+	blockSize = int64(prom_testutil.ToFloat64(db.metrics.blocksBytes)) // Use the the actual internal metrics.
+	walSize, err = db.Head().wal.Size()
+	testutil.Ok(t, err)
+	expSize = blockSize + walSize
+	actSize, err = fileutil.DirSize(db.Dir())
+	testutil.Ok(t, err)
 	testutil.Equals(t, expSize, actSize, "registered size doesn't match actual disk size")
 
 	// Decrease the max bytes limit so that a delete is triggered.
@@ -1127,9 +1167,14 @@ func TestSizeRetention(t *testing.T) {
 
 	expBlocks := blocks[1:]
 	actBlocks := db.Blocks()
-	expSize = int64(prom_testutil.ToFloat64(db.metrics.blocksBytes))
+	blockSize = int64(prom_testutil.ToFloat64(db.metrics.blocksBytes))
+	walSize, err = db.Head().wal.Size()
+	testutil.Ok(t, err)
+	// Expected size should take into account block size + WAL size
+	expSize = blockSize + walSize
 	actRetentCount := int(prom_testutil.ToFloat64(db.metrics.sizeRetentionCount))
-	actSize = testutil.DirSize(t, db.Dir())
+	actSize, err = fileutil.DirSize(db.Dir())
+	testutil.Ok(t, err)
 
 	testutil.Equals(t, 1, actRetentCount, "metric retention count mismatch")
 	testutil.Equals(t, actSize, expSize, "metric db size doesn't match actual disk size")
@@ -1137,7 +1182,6 @@ func TestSizeRetention(t *testing.T) {
 	testutil.Equals(t, len(blocks)-1, len(actBlocks), "new block count should be decreased from:%v to:%v", len(blocks), len(blocks)-1)
 	testutil.Equals(t, expBlocks[0].MaxTime, actBlocks[0].meta.MaxTime, "maxT mismatch of the first block")
 	testutil.Equals(t, expBlocks[len(expBlocks)-1].MaxTime, actBlocks[len(actBlocks)-1].meta.MaxTime, "maxT mismatch of the last block")
-
 }
 
 func TestSizeRetentionMetric(t *testing.T) {
@@ -2298,7 +2342,8 @@ func TestDBReadOnly(t *testing.T) {
 		testutil.Ok(t, err)
 		dbWritable.DisableCompactions()
 
-		dbSizeBeforeAppend := testutil.DirSize(t, dbWritable.Dir())
+		dbSizeBeforeAppend, err := fileutil.DirSize(dbWritable.Dir())
+		testutil.Ok(t, err)
 		app := dbWritable.Appender()
 		_, err = app.Add(labels.FromStrings("foo", "bar"), dbWritable.Head().MaxTime()+1, 0)
 		testutil.Ok(t, err)
@@ -2306,7 +2351,8 @@ func TestDBReadOnly(t *testing.T) {
 		expSeriesCount++
 
 		expBlocks = dbWritable.Blocks()
-		expDbSize := testutil.DirSize(t, dbWritable.Dir())
+		expDbSize, err := fileutil.DirSize(dbWritable.Dir())
+		testutil.Ok(t, err)
 		testutil.Assert(t, expDbSize > dbSizeBeforeAppend, "db size didn't increase after an append")
 
 		q, err := dbWritable.Querier(math.MinInt64, math.MaxInt64)
diff --git a/tsdb/wal/wal_test.go b/tsdb/wal/wal_test.go
index 8a6b23f6f..cc2537a20 100644
--- a/tsdb/wal/wal_test.go
+++ b/tsdb/wal/wal_test.go
@@ -23,6 +23,8 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/prometheus/prometheus/tsdb/fileutil"
+
 	client_testutil "github.com/prometheus/client_golang/prometheus/testutil"
 	"github.com/prometheus/prometheus/util/testutil"
 )
@@ -408,8 +410,10 @@ func TestCompression(t *testing.T) {
 		testutil.Ok(t, os.RemoveAll(dirUnCompressed))
 	}()
 
-	uncompressedSize := testutil.DirSize(t, dirUnCompressed)
-	compressedSize := testutil.DirSize(t, dirCompressed)
+	uncompressedSize, err := fileutil.DirSize(dirUnCompressed)
+	testutil.Ok(t, err)
+	compressedSize, err := fileutil.DirSize(dirCompressed)
+	testutil.Ok(t, err)
 
 	testutil.Assert(t, float64(uncompressedSize)*0.75 > float64(compressedSize), "Compressing zeroes should save at least 25%% space - uncompressedSize: %d, compressedSize: %d", uncompressedSize, compressedSize)
 }
diff --git a/util/testutil/directory.go b/util/testutil/directory.go
index 5f1c31554..3cc43d220 100644
--- a/util/testutil/directory.go
+++ b/util/testutil/directory.go
@@ -133,20 +133,6 @@ func NewTemporaryDirectory(name string, t T) (handler TemporaryDirectory) {
 	return
 }
 
-// DirSize returns the size in bytes of all files in a directory.
-func DirSize(t *testing.T, path string) int64 {
-	var size int64
-	err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
-		Ok(t, err)
-		if !info.IsDir() {
-			size += info.Size()
-		}
-		return nil
-	})
-	Ok(t, err)
-	return size
-}
-
 // DirHash returns a hash of all files attribites and their content within a directory.
 func DirHash(t *testing.T, path string) []byte {
 	hash := sha256.New()
diff --git a/web/api/v1/api_test.go b/web/api/v1/api_test.go
index 78160e388..63ab63e87 100644
--- a/web/api/v1/api_test.go
+++ b/web/api/v1/api_test.go
@@ -39,6 +39,7 @@ import (
 	"github.com/prometheus/common/model"
 	"github.com/prometheus/common/promlog"
 	"github.com/prometheus/common/route"
+	"github.com/prometheus/prometheus/tsdb"
 	tsdbLabels "github.com/prometheus/prometheus/tsdb/labels"
 
 	"github.com/prometheus/prometheus/config"
@@ -1332,6 +1333,10 @@ func (f *fakeDB) Dir() string {
 	return dir
 }
 func (f *fakeDB) Snapshot(dir string, withHead bool) error { return f.err }
+func (f *fakeDB) Head() *tsdb.Head {
+	h, _ := tsdb.NewHead(nil, nil, nil, 1000)
+	return h
+}
 
 func TestAdminEndpoints(t *testing.T) {
 	tsdb, tsdbWithError := &fakeDB{}, &fakeDB{err: errors.New("some error")}
@@ -1842,6 +1847,47 @@ func TestRespond(t *testing.T) {
 	}
 }
 
+func TestTSDBStatus(t *testing.T) {
+	tsdb := &fakeDB{}
+	tsdbStatusAPI := func(api *API) apiFunc { return api.serveTSDBStatus }
+
+	for i, tc := range []struct {
+		db       *fakeDB
+		endpoint func(api *API) apiFunc
+		method   string
+		values   url.Values
+
+		errType errorType
+	}{
+		// Tests for the TSDB Status endpoint.
+		{
+			db:       tsdb,
+			endpoint: tsdbStatusAPI,
+
+			errType: errorNone,
+		},
+	} {
+		tc := tc
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			api := &API{
+				db: func() TSDBAdmin {
+					if tc.db != nil {
+						return tc.db
+					}
+					return nil
+				},
+			}
+			endpoint := tc.endpoint(api)
+			req, err := http.NewRequest(tc.method, fmt.Sprintf("?%s", tc.values.Encode()), nil)
+			if err != nil {
+				t.Fatalf("Error when creating test request: %s", err)
+			}
+			res := endpoint(req)
+			assertAPIError(t, res.err, tc.errType)
+		})
+	}
+}
+
 // This is a global to avoid the benchmark being optimized away.
 var testResponseWriter = httptest.ResponseRecorder{}
 
diff --git a/web/ui/react-app/src/App.test.tsx b/web/ui/react-app/src/App.test.tsx
index a1f99a6f5..813dbb0b6 100755
--- a/web/ui/react-app/src/App.test.tsx
+++ b/web/ui/react-app/src/App.test.tsx
@@ -4,7 +4,7 @@ import App from './App';
 import Navigation from './Navbar';
 import { Container } from 'reactstrap';
 import { Router } from '@reach/router';
-import { Alerts, Config, Flags, Rules, Services, Status, Targets, PanelList } from './pages';
+import { Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList } from './pages';
 
 describe('App', () => {
   const app = shallow(<App pathPrefix="/path/prefix" />);
@@ -13,7 +13,7 @@ describe('App', () => {
     expect(app.find(Navigation)).toHaveLength(1);
   });
   it('routes', () => {
-    [Alerts, Config, Flags, Rules, Services, Status, Targets, PanelList].forEach(component => {
+    [Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList].forEach(component => {
       const c = app.find(component);
       expect(c).toHaveLength(1);
       expect(c.prop('pathPrefix')).toBe('/path/prefix');
diff --git a/web/ui/react-app/src/ExpressionInput.test.tsx b/web/ui/react-app/src/ExpressionInput.test.tsx
index 66b16f24d..44b992676 100644
--- a/web/ui/react-app/src/ExpressionInput.test.tsx
+++ b/web/ui/react-app/src/ExpressionInput.test.tsx
@@ -96,11 +96,11 @@ describe('ExpressionInput', () => {
     });
   });
 
-  describe('handleDropdownSelection', () => {
+  describe('onSelect', () => {
     it('should call setState with selected value', () => {
       const instance: any = expressionInput.instance();
       const stateSpy = jest.spyOn(instance, 'setState');
-      instance.handleDropdownSelection('foo');
+      instance.setValue('foo');
       expect(stateSpy).toHaveBeenCalledWith({ value: 'foo', height: 'auto' }, expect.anything());
     });
   });
diff --git a/web/ui/react-app/src/pages/TSDBStatus.test.tsx b/web/ui/react-app/src/pages/TSDBStatus.test.tsx
new file mode 100644
index 000000000..cefdcd375
--- /dev/null
+++ b/web/ui/react-app/src/pages/TSDBStatus.test.tsx
@@ -0,0 +1,125 @@
+import * as React from 'react';
+import { mount, shallow, ReactWrapper } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { Alert, Table, Badge } from 'reactstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faSpinner } from '@fortawesome/free-solid-svg-icons';
+import { TSDBStatus } from '.';
+import { TSDBMap, Stats } from './TSDBStatus';
+
+const fakeTSDBStatusResponse: {
+  status: string;
+  data: TSDBMap;
+} = {
+  status: 'success',
+  data: {
+    labelValueCountByLabelName: [
+      {
+        name: '__name__',
+        value: 5,
+      },
+    ],
+    seriesCountByMetricName: [
+      {
+        name: 'scrape_duration_seconds',
+        value: 1,
+      },
+      {
+        name: 'scrape_samples_scraped',
+        value: 1,
+      },
+    ],
+    memoryInBytesByLabelName: [
+      {
+        name: '__name__',
+        value: 103,
+      },
+    ],
+    seriesCountByLabelValuePair: [
+      {
+        name: 'instance=localhost:9100',
+        value: 5,
+      },
+    ],
+  },
+};
+
+describe('TSDB Stats', () => {
+  beforeEach(() => {
+    fetch.resetMocks();
+  });
+
+  it('before data is returned', () => {
+    const tsdbStatus = shallow(<TSDBStatus />);
+    const icon = tsdbStatus.find(FontAwesomeIcon);
+    expect(icon.prop('icon')).toEqual(faSpinner);
+    expect(icon.prop('spin')).toBeTruthy();
+  });
+
+  describe('when an error is returned', () => {
+    it('displays an alert', async () => {
+      const mock = fetch.mockReject(new Error('error loading tsdb status'));
+
+      let page: ReactWrapper;
+      await act(async () => {
+        page = mount(<TSDBStatus pathPrefix="/path/prefix" />);
+      });
+      page.update();
+
+      expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/status/tsdb', undefined);
+      const alert = page.find(Alert);
+      expect(alert.prop('color')).toBe('danger');
+      expect(alert.text()).toContain('error loading tsdb status');
+    });
+  });
+
+  describe('Table Data Validation', () => {
+    it('Table Test', async () => {
+      const tables = [
+        {
+          data: fakeTSDBStatusResponse.data.labelValueCountByLabelName,
+          table_index: 0,
+        },
+        {
+          data: fakeTSDBStatusResponse.data.seriesCountByMetricName,
+          table_index: 1,
+        },
+        {
+          data: fakeTSDBStatusResponse.data.memoryInBytesByLabelName,
+          table_index: 2,
+        },
+        {
+          data: fakeTSDBStatusResponse.data.seriesCountByLabelValuePair,
+          table_index: 3,
+        },
+      ];
+
+      const mock = fetch.mockResponse(JSON.stringify(fakeTSDBStatusResponse));
+      let page: ReactWrapper;
+      await act(async () => {
+        page = mount(<TSDBStatus pathPrefix="/path/prefix" />);
+      });
+      page.update();
+
+      expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/status/tsdb', undefined);
+
+      for (let i = 0; i < tables.length; i++) {
+        let data = tables[i].data;
+        let table = page
+          .find(Table)
+          .at(tables[i].table_index)
+          .find('tbody');
+        let rows = table.find('tr');
+        for (let i = 0; i < data.length; i++) {
+          const firstRowColumns = rows
+            .at(i)
+            .find('td')
+            .map(column => column.text());
+          expect(rows.length).toBe(data.length);
+          expect(firstRowColumns[0]).toBe(data[i].name);
+          expect(firstRowColumns[1]).toBe(data[i].value.toString());
+        }
+      }
+    });
+  });
+});