diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
new file mode 100644
index 0000000000000000000000000000000000000000..32c9d6eccb83e97ae2ca6fb37123c3d23a497919
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -0,0 +1,146 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import dateFormat from 'dateformat';
+import { X_INTERVAL } from '../constants';
+import { validateGraphData } from '../utils';
+
+let debouncedResize;
+
+export default {
+  components: {
+    GlAreaChart,
+  },
+  inheritAttrs: false,
+  props: {
+    graphData: {
+      type: Object,
+      required: true,
+      validator: validateGraphData,
+    },
+    containerWidth: {
+      type: Number,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      tooltipPopoverTitle: '',
+      tooltipPopoverContent: '',
+      width: this.containerWidth,
+    };
+  },
+  computed: {
+    chartData() {
+      return this.graphData.queries.reduce((accumulator, query) => {
+        accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
+        return accumulator;
+      }, {});
+    },
+    extractTimeData() {
+      return this.chartData.requests.map(data => data.time);
+    },
+    generateSeries() {
+      return {
+        name: 'Invocations',
+        type: 'line',
+        data: this.chartData.requests.map(data => [data.time, data.value]),
+        symbolSize: 0,
+      };
+    },
+    getInterval() {
+      const { result } = this.graphData.queries[0];
+
+      if (result.length === 0) {
+        return 1;
+      }
+
+      const split = result[0].values.reduce(
+        (acc, pair) => (pair.value > acc ? pair.value : acc),
+        1,
+      );
+
+      return split < X_INTERVAL ? split : X_INTERVAL;
+    },
+    chartOptions() {
+      return {
+        xAxis: {
+          name: 'time',
+          type: 'time',
+          axisLabel: {
+            formatter: date => dateFormat(date, 'h:MM TT'),
+          },
+          data: this.extractTimeData,
+          nameTextStyle: {
+            padding: [18, 0, 0, 0],
+          },
+        },
+        yAxis: {
+          name: this.yAxisLabel,
+          nameTextStyle: {
+            padding: [0, 0, 36, 0],
+          },
+          splitNumber: this.getInterval,
+        },
+        legend: {
+          formatter: this.xAxisLabel,
+        },
+        series: this.generateSeries,
+      };
+    },
+    xAxisLabel() {
+      return this.graphData.queries.map(query => query.label).join(', ');
+    },
+    yAxisLabel() {
+      const [query] = this.graphData.queries;
+      return `${this.graphData.y_label} (${query.unit})`;
+    },
+  },
+  watch: {
+    containerWidth: 'onResize',
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', debouncedResize);
+  },
+  created() {
+    debouncedResize = debounceByAnimationFrame(this.onResize);
+    window.addEventListener('resize', debouncedResize);
+  },
+  methods: {
+    formatTooltipText(params) {
+      const [seriesData] = params.seriesData;
+      this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+      this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`;
+    },
+    onResize() {
+      const { width } = this.$refs.areaChart.$el.getBoundingClientRect();
+      this.width = width;
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="prometheus-graph">
+    <div class="prometheus-graph-header">
+      <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+      <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+    </div>
+    <gl-area-chart
+      ref="areaChart"
+      v-bind="$attrs"
+      :data="[]"
+      :option="chartOptions"
+      :format-tooltip-text="formatTooltipText"
+      :width="width"
+      :include-legend-avg-max="false"
+    >
+      <template slot="tooltipTitle">
+        {{ tooltipPopoverTitle }}
+      </template>
+      <template slot="tooltipContent">
+        {{ tooltipPopoverContent }}
+      </template>
+    </gl-area-chart>
+  </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 4f89ad691290e6904797f6fafb82e8493a3d5358..b8906cfca4e2a566ecced2dac5abe7e964d47429 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,39 +1,77 @@
 <script>
+import _ from 'underscore';
+import { mapState, mapActions, mapGetters } from 'vuex';
 import PodBox from './pod_box.vue';
 import Url from './url.vue';
+import AreaChart from './area.vue';
+import MissingPrometheus from './missing_prometheus.vue';
 
 export default {
   components: {
     PodBox,
     Url,
+    AreaChart,
+    MissingPrometheus,
   },
   props: {
     func: {
       type: Object,
       required: true,
     },
+    hasPrometheus: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    clustersPath: {
+      type: String,
+      required: true,
+    },
+    helpPath: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      elWidth: 0,
+    };
   },
   computed: {
     name() {
       return this.func.name;
     },
     description() {
-      return this.func.description;
+      return _.isString(this.func.description) ? this.func.description : '';
     },
     funcUrl() {
       return this.func.url;
     },
     podCount() {
-      return this.func.podcount || 0;
+      return Number(this.func.podcount) || 0;
     },
+    ...mapState(['graphData', 'hasPrometheusData']),
+    ...mapGetters(['hasPrometheusMissingData']),
+  },
+  created() {
+    this.fetchMetrics({
+      metricsPath: this.func.metricsUrl,
+      hasPrometheus: this.hasPrometheus,
+    });
+  },
+  mounted() {
+    this.elWidth = this.$el.clientWidth;
+  },
+  methods: {
+    ...mapActions(['fetchMetrics']),
   },
 };
 </script>
 
 <template>
   <section id="serverless-function-details">
-    <h3>{{ name }}</h3>
-    <div class="append-bottom-default">
+    <h3 class="serverless-function-name">{{ name }}</h3>
+    <div class="append-bottom-default serverless-function-description">
       <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
     </div>
     <url :uri="funcUrl" />
@@ -52,5 +90,13 @@ export default {
       </p>
     </div>
     <div v-else><p>No pods loaded at this time.</p></div>
+
+    <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" />
+    <missing-prometheus
+      v-if="!hasPrometheus || hasPrometheusMissingData"
+      :help-path="helpPath"
+      :clusters-path="clustersPath"
+      :missing-data="hasPrometheusMissingData"
+    />
   </section>
 </template>
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 773d18781fda328eb3cd0b6ca4fd7d7365362269..4b3bb078eae4b81933d637e8f774470cff0a0aed 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,4 +1,5 @@
 <script>
+import _ from 'underscore';
 import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
 import Url from './url.vue';
 import { visitUrl } from '~/lib/utils/url_utility';
@@ -19,6 +20,10 @@ export default {
       return this.func.name;
     },
     description() {
+      if (!_.isString(this.func.description)) {
+        return '';
+      }
+
       const desc = this.func.description.split('\n');
       if (desc.length > 1) {
         return desc[1];
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 4bde409f9066ce1bbbb1a770aa6cf0a244bfcf82..f9b4e78956380593a17e32fde62a398f01730716 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,5 +1,6 @@
 <script>
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
 import FunctionRow from './function_row.vue';
 import EnvironmentRow from './environment_row.vue';
 import EmptyState from './empty_state.vue';
@@ -9,14 +10,9 @@ export default {
     EnvironmentRow,
     FunctionRow,
     EmptyState,
-    GlSkeletonLoading,
+    GlLoadingIcon,
   },
   props: {
-    functions: {
-      type: Object,
-      required: true,
-      default: () => ({}),
-    },
     installed: {
       type: Boolean,
       required: true,
@@ -29,17 +25,23 @@ export default {
       type: String,
       required: true,
     },
-    loadingData: {
-      type: Boolean,
-      required: false,
-      default: true,
-    },
-    hasFunctionData: {
-      type: Boolean,
-      required: false,
-      default: true,
+    statusPath: {
+      type: String,
+      required: true,
     },
   },
+  computed: {
+    ...mapState(['isLoading', 'hasFunctionData']),
+    ...mapGetters(['getFunctions']),
+  },
+  created() {
+    this.fetchFunctions({
+      functionsPath: this.statusPath,
+    });
+  },
+  methods: {
+    ...mapActions(['fetchFunctions']),
+  },
 };
 </script>
 
@@ -47,14 +49,16 @@ export default {
   <section id="serverless-functions">
     <div v-if="installed">
       <div v-if="hasFunctionData">
-        <template v-if="loadingData">
-          <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
-        </template>
+        <gl-loading-icon
+          v-if="isLoading"
+          :size="2"
+          class="prepend-top-default append-bottom-default"
+        />
         <template v-else>
           <div class="groups-list-tree-container">
             <ul class="content-list group-list-tree">
               <environment-row
-                v-for="(env, index) in functions"
+                v-for="(env, index) in getFunctions"
                 :key="index"
                 :env="env"
                 :env-name="index"
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6c19434f2020939daef8d5abecc55858374aadbd
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlButton, GlLink } from '@gitlab/ui';
+import { s__ } from '../../locale';
+
+export default {
+  components: {
+    GlButton,
+    GlLink,
+  },
+  props: {
+    clustersPath: {
+      type: String,
+      required: true,
+    },
+    helpPath: {
+      type: String,
+      required: true,
+    },
+    missingData: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  computed: {
+    missingStateClass() {
+      return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state';
+    },
+    prometheusHelpPath() {
+      return `${this.helpPath}#prometheus-support`;
+    },
+    description() {
+      return this.missingData
+        ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`)
+        : s__(
+            `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`,
+          );
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="row" :class="missingStateClass">
+    <div class="col-12">
+      <div class="text-content">
+        <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4>
+        <p class="state-description">
+          {{ description }}
+          <gl-link :href="prometheusHelpPath">{{
+            s__(`ServerlessDetails|More information`)
+          }}</gl-link
+          >.
+        </p>
+
+        <div v-if="!missingData" class="text-left">
+          <gl-button :href="clustersPath" variant="success">
+            {{ s__('ServerlessDetails|Install Prometheus') }}
+          </gl-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..35f77205f2ce9185dc4c152d0102f8587fc8a9fb
--- /dev/null
+++ b/app/assets/javascripts/serverless/constants.js
@@ -0,0 +1,3 @@
+export const MAX_REQUESTS = 3; // max number of times to retry
+
+export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis
diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js
index 47a510d5fb59736cde8d09395e9215c287921508..2d3f086ffeedf592e3cc4bb318df64f6560ded6b 100644
--- a/app/assets/javascripts/serverless/serverless_bundle.js
+++ b/app/assets/javascripts/serverless/serverless_bundle.js
@@ -1,13 +1,7 @@
-import Visibility from 'visibilityjs';
 import Vue from 'vue';
-import { s__ } from '../locale';
-import Flash from '../flash';
-import Poll from '../lib/utils/poll';
-import ServerlessStore from './stores/serverless_store';
-import ServerlessDetailsStore from './stores/serverless_details_store';
-import GetFunctionsService from './services/get_functions_service';
 import Functions from './components/functions.vue';
 import FunctionDetails from './components/function_details.vue';
+import { createStore } from './store';
 
 export default class Serverless {
   constructor() {
@@ -19,10 +13,12 @@ export default class Serverless {
         serviceUrl,
         serviceNamespace,
         servicePodcount,
+        serviceMetricsUrl,
+        prometheus,
+        clustersPath,
+        helpPath,
       } = document.querySelector('.js-serverless-function-details-page').dataset;
       const el = document.querySelector('#js-serverless-function-details');
-      this.store = new ServerlessDetailsStore();
-      const { store } = this;
 
       const service = {
         name: serviceName,
@@ -31,20 +27,19 @@ export default class Serverless {
         url: serviceUrl,
         namespace: serviceNamespace,
         podcount: servicePodcount,
+        metricsUrl: serviceMetricsUrl,
       };
 
-      this.store.updateDetailedFunction(service);
       this.functionDetails = new Vue({
         el,
-        data() {
-          return {
-            state: store.state,
-          };
-        },
+        store: createStore(),
         render(createElement) {
           return createElement(FunctionDetails, {
             props: {
-              func: this.state.functionDetail,
+              func: service,
+              hasPrometheus: prometheus !== undefined,
+              clustersPath,
+              helpPath,
             },
           });
         },
@@ -54,95 +49,27 @@ export default class Serverless {
         '.js-serverless-functions-page',
       ).dataset;
 
-      this.service = new GetFunctionsService(statusPath);
-      this.knativeInstalled = installed !== undefined;
-      this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
-      this.initServerless();
-      this.functionLoadCount = 0;
-
-      if (statusPath && this.knativeInstalled) {
-        this.initPolling();
-      }
-    }
-  }
-
-  initServerless() {
-    const { store } = this;
-    const el = document.querySelector('#js-serverless-functions');
-
-    this.functions = new Vue({
-      el,
-      data() {
-        return {
-          state: store.state,
-        };
-      },
-      render(createElement) {
-        return createElement(Functions, {
-          props: {
-            functions: this.state.functions,
-            installed: this.state.installed,
-            clustersPath: this.state.clustersPath,
-            helpPath: this.state.helpPath,
-            loadingData: this.state.loadingData,
-            hasFunctionData: this.state.hasFunctionData,
-          },
-        });
-      },
-    });
-  }
-
-  initPolling() {
-    this.poll = new Poll({
-      resource: this.service,
-      method: 'fetchData',
-      successCallback: data => this.handleSuccess(data),
-      errorCallback: () => Serverless.handleError(),
-    });
-
-    if (!Visibility.hidden()) {
-      this.poll.makeRequest();
-    } else {
-      this.service
-        .fetchData()
-        .then(data => this.handleSuccess(data))
-        .catch(() => Serverless.handleError());
-    }
-
-    Visibility.change(() => {
-      if (!Visibility.hidden() && !this.destroyed) {
-        this.poll.restart();
-      } else {
-        this.poll.stop();
-      }
-    });
-  }
-
-  handleSuccess(data) {
-    if (data.status === 200) {
-      this.store.updateFunctionsFromServer(data.data);
-      this.store.updateLoadingState(false);
-    } else if (data.status === 204) {
-      /* Time out after 3 attempts to retrieve data */
-      this.functionLoadCount += 1;
-      if (this.functionLoadCount === 3) {
-        this.poll.stop();
-        this.store.toggleNoFunctionData();
-      }
+      const el = document.querySelector('#js-serverless-functions');
+      this.functions = new Vue({
+        el,
+        store: createStore(),
+        render(createElement) {
+          return createElement(Functions, {
+            props: {
+              installed: installed !== undefined,
+              clustersPath,
+              helpPath,
+              statusPath,
+            },
+          });
+        },
+      });
     }
   }
 
-  static handleError() {
-    Flash(s__('Serverless|An error occurred while retrieving serverless components'));
-  }
-
   destroy() {
     this.destroyed = true;
 
-    if (this.poll) {
-      this.poll.stop();
-    }
-
     this.functions.$destroy();
     this.functionDetails.$destroy();
   }
diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js
deleted file mode 100644
index 303b42dc66ccc6a696de2d03f0b292d1674f37e6..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/serverless/services/get_functions_service.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default class GetFunctionsService {
-  constructor(endpoint) {
-    this.endpoint = endpoint;
-  }
-
-  fetchData() {
-    return axios.get(this.endpoint);
-  }
-}
diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js
new file mode 100644
index 0000000000000000000000000000000000000000..826501c90224ceba3e7c2bda676f40e52188d053
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/actions.js
@@ -0,0 +1,113 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { MAX_REQUESTS } from '../constants';
+
+export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING);
+export const receiveFunctionsSuccess = ({ commit }, data) =>
+  commit(types.RECEIVE_FUNCTIONS_SUCCESS, data);
+export const receiveFunctionsNoDataSuccess = ({ commit }) =>
+  commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS);
+export const receiveFunctionsError = ({ commit }, error) =>
+  commit(types.RECEIVE_FUNCTIONS_ERROR, error);
+
+export const receiveMetricsSuccess = ({ commit }, data) =>
+  commit(types.RECEIVE_METRICS_SUCCESS, data);
+export const receiveMetricsNoPrometheus = ({ commit }) =>
+  commit(types.RECEIVE_METRICS_NO_PROMETHEUS);
+export const receiveMetricsNoDataSuccess = ({ commit }, data) =>
+  commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data);
+export const receiveMetricsError = ({ commit }, error) =>
+  commit(types.RECEIVE_METRICS_ERROR, error);
+
+export const fetchFunctions = ({ dispatch }, { functionsPath }) => {
+  let retryCount = 0;
+
+  dispatch('requestFunctionsLoading');
+
+  backOff((next, stop) => {
+    axios
+      .get(functionsPath)
+      .then(response => {
+        if (response.status === statusCodes.NO_CONTENT) {
+          retryCount += 1;
+          if (retryCount < MAX_REQUESTS) {
+            next();
+          } else {
+            stop(null);
+          }
+        } else {
+          stop(response.data);
+        }
+      })
+      .catch(stop);
+  })
+    .then(data => {
+      if (data !== null) {
+        dispatch('receiveFunctionsSuccess', data);
+      } else {
+        dispatch('receiveFunctionsNoDataSuccess');
+      }
+    })
+    .catch(error => {
+      dispatch('receiveFunctionsError', error);
+      createFlash(error);
+    });
+};
+
+export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => {
+  let retryCount = 0;
+
+  if (!hasPrometheus) {
+    dispatch('receiveMetricsNoPrometheus');
+    return;
+  }
+
+  backOff((next, stop) => {
+    axios
+      .get(metricsPath)
+      .then(response => {
+        if (response.status === statusCodes.NO_CONTENT) {
+          retryCount += 1;
+          if (retryCount < MAX_REQUESTS) {
+            next();
+          } else {
+            dispatch('receiveMetricsNoDataSuccess');
+            stop(null);
+          }
+        } else {
+          stop(response.data);
+        }
+      })
+      .catch(stop);
+  })
+    .then(data => {
+      if (data === null) {
+        return;
+      }
+
+      const updatedMetric = data.metrics;
+      const queries = data.metrics.queries.map(query => ({
+        ...query,
+        result: query.result.map(result => ({
+          ...result,
+          values: result.values.map(([timestamp, value]) => ({
+            time: new Date(timestamp * 1000).toISOString(),
+            value: Number(value),
+          })),
+        })),
+      }));
+
+      updatedMetric.queries = queries;
+      dispatch('receiveMetricsSuccess', updatedMetric);
+    })
+    .catch(error => {
+      dispatch('receiveMetricsError', error);
+      createFlash(error);
+    });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js
new file mode 100644
index 0000000000000000000000000000000000000000..071f663d9d2dd485955399052c4ed901fff9ef61
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/getters.js
@@ -0,0 +1,10 @@
+import { translate } from '../utils';
+
+export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData;
+
+// Convert the function list into a k/v grouping based on the environment scope
+
+export const getFunctions = state => translate(state.functions);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..5f72060633ebefd8ea9df5af819f72b4e980f97b
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+  new Vuex.Store({
+    actions,
+    getters,
+    mutations,
+    state: createState(),
+  });
+
+export default createStore();
diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js
new file mode 100644
index 0000000000000000000000000000000000000000..25b2f7ac38aa20254a50725ea9c161b34d7eb0fe
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutation_types.js
@@ -0,0 +1,9 @@
+export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING';
+export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS';
+export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS';
+export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR';
+
+export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS';
+export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS';
+export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS';
+export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR';
diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js
new file mode 100644
index 0000000000000000000000000000000000000000..991f32a275d0352e03ae366a97ba83ca96b8cc6a
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutations.js
@@ -0,0 +1,38 @@
+import * as types from './mutation_types';
+
+export default {
+  [types.REQUEST_FUNCTIONS_LOADING](state) {
+    state.isLoading = true;
+  },
+  [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) {
+    state.functions = data;
+    state.isLoading = false;
+    state.hasFunctionData = true;
+  },
+  [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) {
+    state.isLoading = false;
+    state.hasFunctionData = false;
+  },
+  [types.RECEIVE_FUNCTIONS_ERROR](state, error) {
+    state.error = error;
+    state.hasFunctionData = false;
+    state.isLoading = false;
+  },
+  [types.RECEIVE_METRICS_SUCCESS](state, data) {
+    state.isLoading = false;
+    state.hasPrometheusData = true;
+    state.graphData = data;
+  },
+  [types.RECEIVE_METRICS_NODATA_SUCCESS](state) {
+    state.isLoading = false;
+    state.hasPrometheusData = false;
+  },
+  [types.RECEIVE_METRICS_ERROR](state, error) {
+    state.hasPrometheusData = false;
+    state.error = error;
+  },
+  [types.RECEIVE_METRICS_NO_PROMETHEUS](state) {
+    state.hasPrometheusData = false;
+    state.hasPrometheus = false;
+  },
+};
diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js
new file mode 100644
index 0000000000000000000000000000000000000000..afc3f37d7ba3f3e81f55718bab5537309ba20c1e
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/state.js
@@ -0,0 +1,13 @@
+export default () => ({
+  error: null,
+  isLoading: true,
+
+  // functions
+  functions: [],
+  hasFunctionData: true,
+
+  // function_details
+  hasPrometheus: true,
+  hasPrometheusData: false,
+  graphData: {},
+});
diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js
deleted file mode 100644
index 5394d2cded1b0dc5980452156924c980120d3211..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/serverless/stores/serverless_details_store.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default class ServerlessDetailsStore {
-  constructor() {
-    this.state = {
-      functionDetail: {},
-    };
-  }
-
-  updateDetailedFunction(func) {
-    this.state.functionDetail = func;
-  }
-}
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
deleted file mode 100644
index 816d55a03f94d09ca761f27113102a9f0a0af560..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default class ServerlessStore {
-  constructor(knativeInstalled = false, clustersPath, helpPath) {
-    this.state = {
-      functions: {},
-      hasFunctionData: true,
-      loadingData: true,
-      installed: knativeInstalled,
-      clustersPath,
-      helpPath,
-    };
-  }
-
-  updateFunctionsFromServer(upstreamFunctions = []) {
-    this.state.functions = upstreamFunctions.reduce((rv, func) => {
-      const envs = rv;
-      envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
-
-      return envs;
-    }, {});
-  }
-
-  updateLoadingState(loadingData) {
-    this.state.loadingData = loadingData;
-  }
-
-  toggleNoFunctionData() {
-    this.state.hasFunctionData = false;
-  }
-}
diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..8b9e96ce9aa69f3dc42be8aa075858d2fb008e88
--- /dev/null
+++ b/app/assets/javascripts/serverless/utils.js
@@ -0,0 +1,23 @@
+// Validate that the object coming in has valid query details and results
+export const validateGraphData = data =>
+  data.queries &&
+  Array.isArray(data.queries) &&
+  data.queries.filter(query => {
+    if (Array.isArray(query.result)) {
+      return query.result.filter(res => Array.isArray(res.values)).length === query.result.length;
+    }
+
+    return false;
+  }).length === data.queries.length;
+
+export const translate = functions =>
+  functions.reduce(
+    (acc, func) =>
+      Object.assign(acc, {
+        [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]),
+      }),
+    {},
+  );
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 39eca10134fe0043540a0d6b7652a23e6e822220..8c3d141c888c17e52c3e9e91c6ad899e614f7a15 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -7,19 +7,14 @@ class FunctionsController < Projects::ApplicationController
 
       before_action :authorize_read_cluster!
 
-      INDEX_PRIMING_INTERVAL = 15_000
-      INDEX_POLLING_INTERVAL = 60_000
-
       def index
         respond_to do |format|
           format.json do
             functions = finder.execute
 
             if functions.any?
-              Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
               render json: serialize_function(functions)
             else
-              Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
               head :no_content
             end
           end
@@ -33,6 +28,8 @@ def index
 
       def show
         @service = serialize_function(finder.service(params[:environment_id], params[:id]))
+        @prometheus = finder.has_prometheus?(params[:environment_id])
+
         return not_found if @service.nil?
 
         respond_to do |format|
@@ -44,10 +41,24 @@ def show
         end
       end
 
+      def metrics
+        respond_to do |format|
+          format.json do
+            metrics = finder.invocation_metrics(params[:environment_id], params[:id])
+
+            if metrics.nil?
+              head :no_content
+            else
+              render json: metrics
+            end
+          end
+        end
+      end
+
       private
 
       def finder
-        Projects::Serverless::FunctionsFinder.new(project.clusters)
+        Projects::Serverless::FunctionsFinder.new(project)
       end
 
       def serialize_function(function)
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
index 2f2816a4a083051c145b5e76a443673ecc8ad80f..d9802598c6405036e9338062da528d08e2626365 100644
--- a/app/finders/projects/serverless/functions_finder.rb
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -3,8 +3,9 @@
 module Projects
   module Serverless
     class FunctionsFinder
-      def initialize(clusters)
-        @clusters = clusters
+      def initialize(project)
+        @clusters = project.clusters
+        @project = project
       end
 
       def execute
@@ -19,6 +20,23 @@ def service(environment_scope, name)
         knative_service(environment_scope, name)&.first
       end
 
+      def invocation_metrics(environment_scope, name)
+        return unless prometheus_adapter&.can_query?
+
+        cluster = clusters_with_knative_installed.preload_knative.find do |c|
+          environment_scope == c.environment_scope
+        end
+
+        func = ::Serverless::Function.new(@project, name, cluster.platform_kubernetes&.actual_namespace)
+        prometheus_adapter.query(:knative_invocation, func)
+      end
+
+      def has_prometheus?(environment_scope)
+        clusters_with_knative_installed.preload_knative.to_a.any? do |cluster|
+          environment_scope == cluster.environment_scope && cluster.application_prometheus_available?
+        end
+      end
+
       private
 
       def knative_service(environment_scope, name)
@@ -55,6 +73,12 @@ def add_metadata(cluster, services)
       def clusters_with_knative_installed
         @clusters.with_knative_installed
       end
+
+      # rubocop: disable CodeReuse/ServiceClass
+      def prometheus_adapter
+        @prometheus_adapter ||= ::Prometheus::AdapterService.new(@project).prometheus_adapter
+      end
+      # rubocop: enable CodeReuse/ServiceClass
     end
   end
 end
diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5d4f8e0c9e2966c038d1d1a3e5116479d85b2361
--- /dev/null
+++ b/app/models/serverless/function.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Serverless
+  class Function
+    attr_accessor :name, :namespace
+
+    def initialize(project, name, namespace)
+      @project = project
+      @name = name
+      @namespace = namespace
+    end
+
+    def id
+      @project.id.to_s + "/" + @name + "/" + @namespace
+    end
+
+    def self.find_by_id(id)
+      array = id.split("/")
+      project = Project.find_by_id(array[0])
+      name = array[1]
+      namespace = array[2]
+
+      self.new(project, name, namespace)
+    end
+  end
+end
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
index c98dc1a1c4aa9af062339ca1894e80128a7a3364..a46f8af1466199f17488e15a842be2baff4f2000 100644
--- a/app/serializers/projects/serverless/service_entity.rb
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -32,6 +32,13 @@ class ServiceEntity < Grape::Entity
         service.dig('podcount')
       end
 
+      expose :metrics_url do |service|
+        project_serverless_metrics_path(
+          request.project,
+          service.dig('environment_scope'),
+          service.dig('metadata', 'name')) + ".json"
+      end
+
       expose :created_at do |service|
         service.dig('metadata', 'creationTimestamp')
       end
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
index 635580eac5c44343fd82a86cb9717097c0e0a163..9c69aedfbfcb401d5a70022dd02001753ad35a9f 100644
--- a/app/views/projects/serverless/functions/index.html.haml
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -5,7 +5,10 @@
 - status_path = project_serverless_functions_path(@project, format: :json)
 - clusters_path = project_clusters_path(@project)
 
-.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } }
+.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path,
+  installed: @installed,
+  clusters_path: clusters_path,
+  help_path: help_page_path('user/project/clusters/serverless/index') } }
 
 %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
   .js-serverless-functions-notice
diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml
index 29737b7014ae6801661da30ff2a3fcc6daebdba8..d1fe208ce60f6280f82b9a5d6cf2d10597c27707 100644
--- a/app/views/projects/serverless/functions/show.html.haml
+++ b/app/views/projects/serverless/functions/show.html.haml
@@ -1,14 +1,19 @@
 - @no_container = true
 - @content_class = "limit-container-width" unless fluid_layout
+- clusters_path = project_clusters_path(@project)
+- help_path = help_page_path('user/project/clusters/serverless/index')
 
 - add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
 
 - page_title @service[:name]
 
-.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } }
+.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json,
+  prometheus: @prometheus,
+  clusters_path: clusters_path,
+  help_path: help_path } }
+
 %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
-  .top-area.adjust
-    .serverless-function-details#js-serverless-function-details
+  .serverless-function-details#js-serverless-function-details
 
   .js-serverless-function-notice
     .flash-container
diff --git a/changelogs/unreleased/knative-prometheus.yml b/changelogs/unreleased/knative-prometheus.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e24f53b72252dc1d074be39501785630739a8687
--- /dev/null
+++ b/changelogs/unreleased/knative-prometheus.yml
@@ -0,0 +1,5 @@
+---
+title: Add Knative metrics to Prometheus
+merge_request: 24663
+author: Chris Baumbauer <cab@cabnetworks.net>
+type: added
diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml
index 9bdaf1575e9d7a52b91333d3f7915154705642ef..884868c63362cf9f435253968f0852f3194655ff 100644
--- a/config/prometheus/common_metrics.yml
+++ b/config/prometheus/common_metrics.yml
@@ -259,3 +259,13 @@
       label: Pod average
       unit: "cores"
       track: canary
+  - title: "Knative function invocations"
+    y_label: "Invocations"
+    required_metrics:
+    - istio_revision_request_count
+    weight: 1
+    queries:
+    - id: system_metrics_knative_function_invocation_count
+      query_range: 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))'
+      label: invocations / minute
+      unit: requests
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 1cb8f331f6f5dcae478ee48a852c1458519f303c..93d168fc595fdb4ba4c94e03d4a0420466609a1e 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -252,7 +252,11 @@
       end
 
       namespace :serverless do
-        get '/functions/:environment_id/:id', to: 'functions#show'
+        scope :functions do
+          get '/:environment_id/:id', to: 'functions#show'
+          get '/:environment_id/:id/metrics', to: 'functions#metrics', as: :metrics
+        end
+
         resources :functions, only: [:index]
       end
 
diff --git a/db/migrate/20190326164045_import_common_metrics_knative.rb b/db/migrate/20190326164045_import_common_metrics_knative.rb
new file mode 100644
index 0000000000000000000000000000000000000000..340ec1e1f750e3a878a648886d69046d91f895d9
--- /dev/null
+++ b/db/migrate/20190326164045_import_common_metrics_knative.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ImportCommonMetricsKnative < ActiveRecord::Migration[5.0]
+  include Gitlab::Database::MigrationHelpers
+
+  require Rails.root.join('db/importers/common_metrics_importer.rb')
+
+  DOWNTIME = false
+
+  def up
+    Importers::CommonMetricsImporter.new.execute
+  end
+
+  def down
+    # no-op
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1a50c6efbc7ae7cea153dd9d350f681e1d0a608b..ca5b04e810a0cc66945d2c8bf0af508a42af920c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20190325165127) do
+ActiveRecord::Schema.define(version: 20190326164045) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
diff --git a/doc/user/project/clusters/serverless/img/function-details-loaded.png b/doc/user/project/clusters/serverless/img/function-details-loaded.png
new file mode 100644
index 0000000000000000000000000000000000000000..34465c5c08761c4cf6a177fd1868413676802d20
Binary files /dev/null and b/doc/user/project/clusters/serverless/img/function-details-loaded.png differ
diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md
index b72083e85df5aaa86e843de765996105c276fd08..5b7e9ef906fed855f3130310fac44ff938186d93 100644
--- a/doc/user/project/clusters/serverless/index.md
+++ b/doc/user/project/clusters/serverless/index.md
@@ -301,3 +301,23 @@ The second to last line, labeled **Service domain** contains the URL for the dep
 browser to see the app live.
 
 ![knative app](img/knative-app.png)
+
+## Function details
+
+Go to the **Operations > Serverless** page and click on one of the function
+rows to bring up the function details page.
+
+![function_details](img/function-details-loaded.png)
+
+The pod count will give you the number of pods running the serverless function instances on a given cluster.
+
+### Prometheus support
+
+For the Knative function invocations to appear,
+[Prometheus must be installed](../index.md#installing-applications).
+
+Once Prometheus is installed, a message may appear indicating that the metrics data _is
+loading or is not available at this time._  It will appear upon the first access of the
+page, but should go away after a few seconds. If the message does not disappear, then it
+is possible that GitLab is unable to connect to the Prometheus instance running on the
+cluster.
diff --git a/lib/gitlab/prometheus/queries/knative_invocation_query.rb b/lib/gitlab/prometheus/queries/knative_invocation_query.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2691abe46d6288bf9884c998a3e951b2a412bfdd
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/knative_invocation_query.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Prometheus
+    module Queries
+      class KnativeInvocationQuery < BaseQuery
+        include QueryAdditionalMetrics
+
+        def query(serverless_function_id)
+          PrometheusMetric
+            .find_by_identifier(:system_metrics_knative_function_invocation_count)
+            .to_query_metric.tap do |q|
+            q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id))
+          end
+        end
+
+        protected
+
+        def context(function_id)
+          function = Serverless::Function.find_by_id(function_id)
+          {
+            function_name: function.name,
+            kube_namespace: function.namespace
+          }
+        end
+
+        def run_query(query, context)
+          query %= context
+          client_query_range(query, start: 8.hours.ago.to_f, stop: Time.now.to_f)
+        end
+
+        def self.transform_reactive_result(result)
+          result[:metrics] = result.delete :data
+          result
+        end
+      end
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4e756cf2095f3011c403da6fd600f1d6ca2f4f2e..711baff9f9acbbcc73b5ea69e43474fba8b5eed9 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -7238,9 +7238,24 @@ msgstr ""
 msgid "Serverless"
 msgstr ""
 
+msgid "ServerlessDetails|Function invocation metrics require Prometheus to be installed first."
+msgstr ""
+
+msgid "ServerlessDetails|Install Prometheus"
+msgstr ""
+
+msgid "ServerlessDetails|Invocation metrics loading or not available at this time."
+msgstr ""
+
+msgid "ServerlessDetails|Invocations"
+msgstr ""
+
 msgid "ServerlessDetails|Kubernetes Pods"
 msgstr ""
 
+msgid "ServerlessDetails|More information"
+msgstr ""
+
 msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity."
 msgstr ""
 
@@ -7256,9 +7271,6 @@ msgstr ""
 msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
 msgstr ""
 
-msgid "Serverless|An error occurred while retrieving serverless components"
-msgstr ""
-
 msgid "Serverless|Getting started with serverless"
 msgstr ""
 
diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb
index 276cf340962b85011d1bbd6523d479f372bd3fae..782f5f272d92182efcfd072aa8aa6bf8699d1251 100644
--- a/spec/controllers/projects/serverless/functions_controller_spec.rb
+++ b/spec/controllers/projects/serverless/functions_controller_spec.rb
@@ -76,6 +76,15 @@ def params(opts = {})
     end
   end
 
+  describe 'GET #metrics' do
+    context 'invalid data' do
+      it 'has a bad function name' do
+        get :metrics, params: params({ format: :json, environment_id: "*", id: "foo" })
+        expect(response).to have_gitlab_http_status(204)
+      end
+    end
+  end
+
   describe 'GET #index with data', :use_clean_rails_memory_store_caching do
     before do
       stub_kubeclient_service_pods
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
index aa71669de988d113ca4b4c53345905cdeb14f378..e14934b16724da6afa53569717fce29160630894 100644
--- a/spec/features/projects/serverless/functions_spec.rb
+++ b/spec/features/projects/serverless/functions_spec.rb
@@ -50,7 +50,7 @@
     end
 
     it 'sees an empty listing of serverless functions' do
-      expect(page).to have_selector('.gl-responsive-table-row')
+      expect(page).to have_selector('.empty-state')
     end
   end
 end
diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb
index 35279906854bb5ce6d7eb201673bd566e20dace5..3ad38207da4a2fd6f8b6163705addb94fecb0411 100644
--- a/spec/finders/projects/serverless/functions_finder_spec.rb
+++ b/spec/finders/projects/serverless/functions_finder_spec.rb
@@ -4,6 +4,7 @@
 
 describe Projects::Serverless::FunctionsFinder do
   include KubernetesHelpers
+  include PrometheusHelpers
   include ReactiveCachingHelpers
 
   let(:user) { create(:user) }
@@ -24,12 +25,12 @@
 
   describe 'retrieve data from knative' do
     it 'does not have knative installed' do
-      expect(described_class.new(project.clusters).execute).to be_empty
+      expect(described_class.new(project).execute).to be_empty
     end
 
     context 'has knative installed' do
       let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
-      let(:finder) { described_class.new(project.clusters) }
+      let(:finder) { described_class.new(project) }
 
       it 'there are no functions' do
         expect(finder.execute).to be_empty
@@ -58,13 +59,36 @@
         expect(result).not_to be_empty
         expect(result["metadata"]["name"]).to be_eql(cluster.project.name)
       end
+
+      it 'has metrics', :use_clean_rails_memory_store_caching do
+      end
+    end
+
+    context 'has prometheus' do
+      let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
+      let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
+      let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
+      let(:finder) { described_class.new(project) }
+
+      before do
+        allow(finder).to receive(:prometheus_adapter).and_return(prometheus_adapter)
+        allow(prometheus_adapter).to receive(:query).and_return(prometheus_empty_body('matrix'))
+      end
+
+      it 'is available' do
+        expect(finder.has_prometheus?("*")).to be true
+      end
+
+      it 'has query data' do
+        expect(finder.invocation_metrics("*", cluster.project.name)).not_to be_nil
+      end
     end
   end
 
   describe 'verify if knative is installed' do
     context 'knative is not installed' do
       it 'does not have knative installed' do
-        expect(described_class.new(project.clusters).installed?).to be false
+        expect(described_class.new(project).installed?).to be false
       end
     end
 
@@ -72,7 +96,7 @@
       let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
 
       it 'does have knative installed' do
-        expect(described_class.new(project.clusters).installed?).to be true
+        expect(described_class.new(project).installed?).to be true
       end
     end
   end
diff --git a/spec/javascripts/serverless/components/area_spec.js b/spec/javascripts/serverless/components/area_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..2be6ac3d268893a620852bf6ae0abb69ff2652ca
--- /dev/null
+++ b/spec/javascripts/serverless/components/area_spec.js
@@ -0,0 +1,121 @@
+import { shallowMount } from '@vue/test-utils';
+import Area from '~/serverless/components/area.vue';
+import { mockNormalizedMetrics } from '../mock_data';
+
+describe('Area component', () => {
+  const mockWidgets = 'mockWidgets';
+  const mockGraphData = mockNormalizedMetrics;
+  let areaChart;
+
+  beforeEach(() => {
+    areaChart = shallowMount(Area, {
+      propsData: {
+        graphData: mockGraphData,
+        containerWidth: 0,
+      },
+      slots: {
+        default: mockWidgets,
+      },
+    });
+  });
+
+  afterEach(() => {
+    areaChart.destroy();
+  });
+
+  it('renders chart title', () => {
+    expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title);
+  });
+
+  it('contains graph widgets from slot', () => {
+    expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets);
+  });
+
+  describe('methods', () => {
+    describe('formatTooltipText', () => {
+      const mockDate = mockNormalizedMetrics.queries[0].result[0].values[0].time;
+      const generateSeriesData = type => ({
+        seriesData: [
+          {
+            componentSubType: type,
+            value: [mockDate, 4],
+          },
+        ],
+        value: mockDate,
+      });
+
+      describe('series is of line type', () => {
+        beforeEach(() => {
+          areaChart.vm.formatTooltipText(generateSeriesData('line'));
+        });
+
+        it('formats tooltip title', () => {
+          expect(areaChart.vm.tooltipPopoverTitle).toBe('28 Feb 2019, 11:11AM');
+        });
+
+        it('formats tooltip content', () => {
+          expect(areaChart.vm.tooltipPopoverContent).toBe('Invocations (requests): 4');
+        });
+      });
+
+      it('verify default interval value of 1', () => {
+        expect(areaChart.vm.getInterval).toBe(1);
+      });
+    });
+
+    describe('onResize', () => {
+      const mockWidth = 233;
+
+      beforeEach(() => {
+        spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({
+          width: mockWidth,
+        }));
+        areaChart.vm.onResize();
+      });
+
+      it('sets area chart width', () => {
+        expect(areaChart.vm.width).toBe(mockWidth);
+      });
+    });
+  });
+
+  describe('computed', () => {
+    describe('chartData', () => {
+      it('utilizes all data points', () => {
+        expect(Object.keys(areaChart.vm.chartData)).toEqual(['requests']);
+        expect(areaChart.vm.chartData.requests.length).toBe(2);
+      });
+
+      it('creates valid data', () => {
+        const data = areaChart.vm.chartData.requests;
+
+        expect(
+          data.filter(
+            datum => new Date(datum.time).getTime() > 0 && typeof datum.value === 'number',
+          ).length,
+        ).toBe(data.length);
+      });
+    });
+
+    describe('generateSeries', () => {
+      it('utilizes correct time data', () => {
+        expect(areaChart.vm.generateSeries.data).toEqual([
+          ['2019-02-28T11:11:38.756Z', 0],
+          ['2019-02-28T11:12:38.756Z', 0],
+        ]);
+      });
+    });
+
+    describe('xAxisLabel', () => {
+      it('constructs a label for the chart x-axis', () => {
+        expect(areaChart.vm.xAxisLabel).toBe('invocations / minute');
+      });
+    });
+
+    describe('yAxisLabel', () => {
+      it('constructs a label for the chart y-axis', () => {
+        expect(areaChart.vm.yAxisLabel).toBe('Invocations (requests)');
+      });
+    });
+  });
+});
diff --git a/spec/javascripts/serverless/components/environment_row_spec.js b/spec/javascripts/serverless/components/environment_row_spec.js
index bdf7a714910550833925cefd3cc164f413a88976..932d712dbecd50469e68dd166b0ddf2f4da5c9f4 100644
--- a/spec/javascripts/serverless/components/environment_row_spec.js
+++ b/spec/javascripts/serverless/components/environment_row_spec.js
@@ -1,81 +1,70 @@
-import Vue from 'vue';
-
 import environmentRowComponent from '~/serverless/components/environment_row.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import ServerlessStore from '~/serverless/stores/serverless_store';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
 
 import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+import { translate } from '~/serverless/utils';
 
-const createComponent = (env, envName) =>
-  mountComponent(Vue.extend(environmentRowComponent), { env, envName });
+const createComponent = (localVue, env, envName) =>
+  shallowMount(environmentRowComponent, { localVue, propsData: { env, envName } }).vm;
 
 describe('environment row component', () => {
   describe('default global cluster case', () => {
+    let localVue;
     let vm;
 
     beforeEach(() => {
-      const store = new ServerlessStore(false, '/cluster_path', 'help_path');
-      store.updateFunctionsFromServer(mockServerlessFunctions);
-      vm = createComponent(store.state.functions['*'], '*');
+      localVue = createLocalVue();
+      vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*');
     });
 
+    afterEach(() => vm.$destroy());
+
     it('has the correct envId', () => {
       expect(vm.envId).toEqual('env-global');
-      vm.$destroy();
     });
 
     it('is open by default', () => {
       expect(vm.isOpenClass).toEqual({ 'is-open': true });
-      vm.$destroy();
     });
 
     it('generates correct output', () => {
-      expect(vm.$el.querySelectorAll('li').length).toEqual(2);
       expect(vm.$el.id).toEqual('env-global');
       expect(vm.$el.classList.contains('is-open')).toBe(true);
       expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
-
-      vm.$destroy();
     });
 
     it('opens and closes correctly', () => {
       expect(vm.isOpen).toBe(true);
 
       vm.toggleOpen();
-      Vue.nextTick(() => {
-        expect(vm.isOpen).toBe(false);
-      });
 
-      vm.$destroy();
+      expect(vm.isOpen).toBe(false);
     });
   });
 
   describe('default named cluster case', () => {
     let vm;
+    let localVue;
 
     beforeEach(() => {
-      const store = new ServerlessStore(false, '/cluster_path', 'help_path');
-      store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
-      vm = createComponent(store.state.functions.test, 'test');
+      localVue = createLocalVue();
+      vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test');
     });
 
+    afterEach(() => vm.$destroy());
+
     it('has the correct envId', () => {
       expect(vm.envId).toEqual('env-test');
-      vm.$destroy();
     });
 
     it('is open by default', () => {
       expect(vm.isOpenClass).toEqual({ 'is-open': true });
-      vm.$destroy();
     });
 
     it('generates correct output', () => {
-      expect(vm.$el.querySelectorAll('li').length).toEqual(1);
       expect(vm.$el.id).toEqual('env-test');
       expect(vm.$el.classList.contains('is-open')).toBe(true);
       expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
-
-      vm.$destroy();
     });
   });
 });
diff --git a/spec/javascripts/serverless/components/function_details_spec.js b/spec/javascripts/serverless/components/function_details_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a29d4a296ef90860f0cb03154539385b98edec17
--- /dev/null
+++ b/spec/javascripts/serverless/components/function_details_spec.js
@@ -0,0 +1,113 @@
+import Vuex from 'vuex';
+
+import functionDetailsComponent from '~/serverless/components/function_details.vue';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { createStore } from '~/serverless/store';
+
+describe('functionDetailsComponent', () => {
+  let localVue;
+  let component;
+  let store;
+
+  beforeEach(() => {
+    localVue = createLocalVue();
+    localVue.use(Vuex);
+
+    store = createStore();
+  });
+
+  afterEach(() => {
+    component.vm.$destroy();
+  });
+
+  describe('Verify base functionality', () => {
+    const serviceStub = {
+      name: 'test',
+      description: 'a description',
+      environment: '*',
+      url: 'http://service.com/test',
+      namespace: 'test-ns',
+      podcount: 0,
+      metricsUrl: '/metrics',
+    };
+
+    it('has a name, description, URL, and no pods loaded', () => {
+      component = shallowMount(functionDetailsComponent, {
+        localVue,
+        store,
+        propsData: {
+          func: serviceStub,
+          hasPrometheus: false,
+          clustersPath: '/clusters',
+          helpPath: '/help',
+        },
+      });
+
+      expect(
+        component.vm.$el.querySelector('.serverless-function-name').innerHTML.trim(),
+      ).toContain('test');
+
+      expect(
+        component.vm.$el.querySelector('.serverless-function-description').innerHTML.trim(),
+      ).toContain('a description');
+
+      expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain(
+        'No pods loaded at this time.',
+      );
+    });
+
+    it('has a pods loaded', () => {
+      serviceStub.podcount = 1;
+
+      component = shallowMount(functionDetailsComponent, {
+        localVue,
+        store,
+        propsData: {
+          func: serviceStub,
+          hasPrometheus: false,
+          clustersPath: '/clusters',
+          helpPath: '/help',
+        },
+      });
+
+      expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('1 pod in use');
+    });
+
+    it('has multiple pods loaded', () => {
+      serviceStub.podcount = 3;
+
+      component = shallowMount(functionDetailsComponent, {
+        localVue,
+        store,
+        propsData: {
+          func: serviceStub,
+          hasPrometheus: false,
+          clustersPath: '/clusters',
+          helpPath: '/help',
+        },
+      });
+
+      expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('3 pods in use');
+    });
+
+    it('can support a missing description', () => {
+      serviceStub.description = null;
+
+      component = shallowMount(functionDetailsComponent, {
+        localVue,
+        store,
+        propsData: {
+          func: serviceStub,
+          hasPrometheus: false,
+          clustersPath: '/clusters',
+          helpPath: '/help',
+        },
+      });
+
+      expect(
+        component.vm.$el.querySelector('.serverless-function-description').querySelector('div')
+          .innerHTML.length,
+      ).toEqual(0);
+    });
+  });
+});
diff --git a/spec/javascripts/serverless/components/function_row_spec.js b/spec/javascripts/serverless/components/function_row_spec.js
index 6933a8f6c87e6571bfe236da2bb70187f18bc0cc..3987e1753bd0bd7391b3b266388ce138cdd2a4f1 100644
--- a/spec/javascripts/serverless/components/function_row_spec.js
+++ b/spec/javascripts/serverless/components/function_row_spec.js
@@ -1,11 +1,9 @@
-import Vue from 'vue';
-
 import functionRowComponent from '~/serverless/components/function_row.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { shallowMount } from '@vue/test-utils';
 
 import { mockServerlessFunction } from '../mock_data';
 
-const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
+const createComponent = func => shallowMount(functionRowComponent, { propsData: { func } }).vm;
 
 describe('functionRowComponent', () => {
   it('Parses the function details correctly', () => {
@@ -13,10 +11,7 @@ describe('functionRowComponent', () => {
 
     expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
     expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
-    expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
-    expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
-      mockServerlessFunction.url,
-    );
+    expect(vm.$el.querySelector('timeago-stub').getAttribute('time')).not.toBe(null);
 
     vm.$destroy();
   });
@@ -25,8 +20,6 @@ describe('functionRowComponent', () => {
     const vm = createComponent(mockServerlessFunction);
 
     expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
-    expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
-    expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
 
     vm.$destroy();
   });
diff --git a/spec/javascripts/serverless/components/functions_spec.js b/spec/javascripts/serverless/components/functions_spec.js
index 85cfe71281ff8730b60fd5dbd085b983f525d179..c32978ea58a3b4c3fb2c20b81e7a64969eb5aed4 100644
--- a/spec/javascripts/serverless/components/functions_spec.js
+++ b/spec/javascripts/serverless/components/functions_spec.js
@@ -1,68 +1,101 @@
-import Vue from 'vue';
+import Vuex from 'vuex';
 
 import functionsComponent from '~/serverless/components/functions.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import ServerlessStore from '~/serverless/stores/serverless_store';
-
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { createStore } from '~/serverless/store';
 import { mockServerlessFunctions } from '../mock_data';
 
-const createComponent = (
-  functions,
-  installed = true,
-  loadingData = true,
-  hasFunctionData = true,
-) => {
-  const component = Vue.extend(functionsComponent);
+describe('functionsComponent', () => {
+  let component;
+  let store;
+  let localVue;
+
+  beforeEach(() => {
+    localVue = createLocalVue();
+    localVue.use(Vuex);
 
-  return mountComponent(component, {
-    functions,
-    installed,
-    clustersPath: '/testClusterPath',
-    helpPath: '/helpPath',
-    loadingData,
-    hasFunctionData,
+    store = createStore();
   });
-};
 
-describe('functionsComponent', () => {
-  it('should render empty state when Knative is not installed', () => {
-    const vm = createComponent({}, false);
+  afterEach(() => {
+    component.vm.$destroy();
+  });
 
-    expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
-    expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
-      'Getting started with serverless',
-    );
+  it('should render empty state when Knative is not installed', () => {
+    component = shallowMount(functionsComponent, {
+      localVue,
+      store,
+      propsData: {
+        installed: false,
+        clustersPath: '',
+        helpPath: '',
+        statusPath: '',
+      },
+      sync: false,
+    });
 
-    vm.$destroy();
+    expect(component.vm.$el.querySelector('emptystate-stub')).not.toBe(null);
   });
 
   it('should render a loading component', () => {
-    const vm = createComponent({});
+    store.dispatch('requestFunctionsLoading');
+    component = shallowMount(functionsComponent, {
+      localVue,
+      store,
+      propsData: {
+        installed: true,
+        clustersPath: '',
+        helpPath: '',
+        statusPath: '',
+      },
+      sync: false,
+    });
 
-    expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
-    expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
+    expect(component.vm.$el.querySelector('glloadingicon-stub')).not.toBe(null);
   });
 
   it('should render empty state when there is no function data', () => {
-    const vm = createComponent({}, true, false, false);
+    store.dispatch('receiveFunctionsNoDataSuccess');
+    component = shallowMount(functionsComponent, {
+      localVue,
+      store,
+      propsData: {
+        installed: true,
+        clustersPath: '',
+        helpPath: '',
+        statusPath: '',
+      },
+      sync: false,
+    });
 
     expect(
-      vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
+      component.vm.$el
+        .querySelector('.empty-state, .js-empty-state')
+        .classList.contains('js-empty-state'),
     ).toBe(true);
 
-    expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+    expect(component.vm.$el.querySelector('.state-title, .text-center').innerHTML.trim()).toEqual(
       'No functions available',
     );
-
-    vm.$destroy();
   });
 
   it('should render the functions list', () => {
-    const store = new ServerlessStore(false, '/cluster_path', 'help_path');
-    store.updateFunctionsFromServer(mockServerlessFunctions);
-    const vm = createComponent(store.state.functions, true, false);
+    component = shallowMount(functionsComponent, {
+      localVue,
+      store,
+      propsData: {
+        installed: true,
+        clustersPath: '',
+        helpPath: '',
+        statusPath: '',
+      },
+      sync: false,
+    });
+
+    component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions);
 
-    expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
-    expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
+    return component.vm.$nextTick().then(() => {
+      expect(component.vm.$el.querySelector('environmentrow-stub')).not.toBe(null);
+    });
   });
 });
diff --git a/spec/javascripts/serverless/components/missing_prometheus_spec.js b/spec/javascripts/serverless/components/missing_prometheus_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..77aca03772bc9c96fa0145e91f7f3f8b4cf5e84f
--- /dev/null
+++ b/spec/javascripts/serverless/components/missing_prometheus_spec.js
@@ -0,0 +1,37 @@
+import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue';
+import { shallowMount } from '@vue/test-utils';
+
+const createComponent = missingData =>
+  shallowMount(missingPrometheusComponent, {
+    propsData: {
+      clustersPath: '/clusters',
+      helpPath: '/help',
+      missingData,
+    },
+  }).vm;
+
+describe('missingPrometheusComponent', () => {
+  let vm;
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  it('should render missing prometheus message', () => {
+    vm = createComponent(false);
+
+    expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain(
+      'Function invocation metrics require Prometheus to be installed first.',
+    );
+
+    expect(vm.$el.querySelector('glbutton-stub').getAttribute('variant')).toEqual('success');
+  });
+
+  it('should render no prometheus data message', () => {
+    vm = createComponent(true);
+
+    expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain(
+      'Invocation metrics loading or not available at this time.',
+    );
+  });
+});
diff --git a/spec/javascripts/serverless/components/pod_box_spec.js b/spec/javascripts/serverless/components/pod_box_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..69ac1a2bb5f127670e8c9111251865c5a34d6a2a
--- /dev/null
+++ b/spec/javascripts/serverless/components/pod_box_spec.js
@@ -0,0 +1,22 @@
+import podBoxComponent from '~/serverless/components/pod_box.vue';
+import { shallowMount } from '@vue/test-utils';
+
+const createComponent = count =>
+  shallowMount(podBoxComponent, {
+    propsData: {
+      count,
+    },
+  }).vm;
+
+describe('podBoxComponent', () => {
+  it('should render three boxes', () => {
+    const count = 3;
+    const vm = createComponent(count);
+    const rects = vm.$el.querySelectorAll('rect');
+
+    expect(rects.length).toEqual(3);
+    expect(parseInt(rects[2].getAttribute('x'), 10)).toEqual(40);
+
+    vm.$destroy();
+  });
+});
diff --git a/spec/javascripts/serverless/components/url_spec.js b/spec/javascripts/serverless/components/url_spec.js
index 21a879a49bb9205b818fa0661623b3c3e5b1ee9e..08c3e4146b1c6efc9879a463247ba6cd3bda70c0 100644
--- a/spec/javascripts/serverless/components/url_spec.js
+++ b/spec/javascripts/serverless/components/url_spec.js
@@ -1,15 +1,13 @@
 import Vue from 'vue';
-
 import urlComponent from '~/serverless/components/url.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-
-const createComponent = uri => {
-  const component = Vue.extend(urlComponent);
+import { shallowMount } from '@vue/test-utils';
 
-  return mountComponent(component, {
-    uri,
-  });
-};
+const createComponent = uri =>
+  shallowMount(Vue.extend(urlComponent), {
+    propsData: {
+      uri,
+    },
+  }).vm;
 
 describe('urlComponent', () => {
   it('should render correctly', () => {
@@ -17,9 +15,7 @@ describe('urlComponent', () => {
     const vm = createComponent(uri);
 
     expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
-    expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
-      uri,
-    );
+    expect(vm.$el.querySelector('clipboardbutton-stub').getAttribute('text')).toEqual(uri);
 
     expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
 
diff --git a/spec/javascripts/serverless/mock_data.js b/spec/javascripts/serverless/mock_data.js
index ecd393b174ce1af5113502990de0a44f7028570b..a2c1861632435682786f147a4623e96c49092bc8 100644
--- a/spec/javascripts/serverless/mock_data.js
+++ b/spec/javascripts/serverless/mock_data.js
@@ -77,3 +77,60 @@ export const mockMultilineServerlessFunction = {
   description: 'testfunc1\nA test service line\\nWith additional services',
   image: 'knative-test-container-buildtemplate',
 };
+
+export const mockMetrics = {
+  success: true,
+  last_update: '2019-02-28T19:11:38.926Z',
+  metrics: {
+    id: 22,
+    title: 'Knative function invocations',
+    required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
+    weight: 0,
+    y_label: 'Invocations',
+    queries: [
+      {
+        query_range:
+          'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
+        unit: 'requests',
+        label: 'invocations / minute',
+        result: [
+          {
+            metric: {},
+            values: [[1551352298.756, '0'], [1551352358.756, '0']],
+          },
+        ],
+      },
+    ],
+  },
+};
+
+export const mockNormalizedMetrics = {
+  id: 22,
+  title: 'Knative function invocations',
+  required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
+  weight: 0,
+  y_label: 'Invocations',
+  queries: [
+    {
+      query_range:
+        'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
+      unit: 'requests',
+      label: 'invocations / minute',
+      result: [
+        {
+          metric: {},
+          values: [
+            {
+              time: '2019-02-28T11:11:38.756Z',
+              value: 0,
+            },
+            {
+              time: '2019-02-28T11:12:38.756Z',
+              value: 0,
+            },
+          ],
+        },
+      ],
+    },
+  ],
+};
diff --git a/spec/javascripts/serverless/store/actions_spec.js b/spec/javascripts/serverless/store/actions_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..602798573e9fbd0eb359c7f342655cdcdd0d09ee
--- /dev/null
+++ b/spec/javascripts/serverless/store/actions_spec.js
@@ -0,0 +1,88 @@
+import MockAdapter from 'axios-mock-adapter';
+import statusCodes from '~/lib/utils/http_status';
+import { fetchFunctions, fetchMetrics } from '~/serverless/store/actions';
+import { mockServerlessFunctions, mockMetrics } from '../mock_data';
+import axios from '~/lib/utils/axios_utils';
+import testAction from '../../helpers/vuex_action_helper';
+import { adjustMetricQuery } from '../utils';
+
+describe('ServerlessActions', () => {
+  describe('fetchFunctions', () => {
+    it('should successfully fetch functions', done => {
+      const endpoint = '/functions';
+      const mock = new MockAdapter(axios);
+      mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions));
+
+      testAction(
+        fetchFunctions,
+        { functionsPath: endpoint },
+        {},
+        [],
+        [
+          { type: 'requestFunctionsLoading' },
+          { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions },
+        ],
+        () => {
+          mock.restore();
+          done();
+        },
+      );
+    });
+
+    it('should successfully retry', done => {
+      const endpoint = '/functions';
+      const mock = new MockAdapter(axios);
+      mock.onGet(endpoint).reply(statusCodes.NO_CONTENT);
+
+      testAction(
+        fetchFunctions,
+        { functionsPath: endpoint },
+        {},
+        [],
+        [{ type: 'requestFunctionsLoading' }],
+        () => {
+          mock.restore();
+          done();
+        },
+      );
+    });
+  });
+
+  describe('fetchMetrics', () => {
+    it('should return no prometheus', done => {
+      const endpoint = '/metrics';
+      const mock = new MockAdapter(axios);
+      mock.onGet(endpoint).reply(statusCodes.NO_CONTENT);
+
+      testAction(
+        fetchMetrics,
+        { metricsPath: endpoint, hasPrometheus: false },
+        {},
+        [],
+        [{ type: 'receiveMetricsNoPrometheus' }],
+        () => {
+          mock.restore();
+          done();
+        },
+      );
+    });
+
+    it('should successfully fetch metrics', done => {
+      const endpoint = '/metrics';
+      const mock = new MockAdapter(axios);
+      mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics));
+
+      testAction(
+        fetchMetrics,
+        { metricsPath: endpoint, hasPrometheus: true },
+        {},
+        [],
+        [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }],
+        () => {
+          mock.restore();
+          done();
+        },
+      );
+    });
+  });
+});
diff --git a/spec/javascripts/serverless/store/getters_spec.js b/spec/javascripts/serverless/store/getters_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..fb549c8f153fcfa1611e1052b2e2a687b56b0485
--- /dev/null
+++ b/spec/javascripts/serverless/store/getters_spec.js
@@ -0,0 +1,43 @@
+import serverlessState from '~/serverless/store/state';
+import * as getters from '~/serverless/store/getters';
+import { mockServerlessFunctions } from '../mock_data';
+
+describe('Serverless Store Getters', () => {
+  let state;
+
+  beforeEach(() => {
+    state = serverlessState;
+  });
+
+  describe('hasPrometheusMissingData', () => {
+    it('should return false if Prometheus is not installed', () => {
+      state.hasPrometheus = false;
+
+      expect(getters.hasPrometheusMissingData(state)).toEqual(false);
+    });
+
+    it('should return false if Prometheus is installed and there is data', () => {
+      state.hasPrometheusData = true;
+
+      expect(getters.hasPrometheusMissingData(state)).toEqual(false);
+    });
+
+    it('should return true if Prometheus is installed and there is no data', () => {
+      state.hasPrometheus = true;
+      state.hasPrometheusData = false;
+
+      expect(getters.hasPrometheusMissingData(state)).toEqual(true);
+    });
+  });
+
+  describe('getFunctions', () => {
+    it('should translate the raw function array to group the functions per environment scope', () => {
+      state.functions = mockServerlessFunctions;
+
+      const funcs = getters.getFunctions(state);
+
+      expect(Object.keys(funcs)).toContain('*');
+      expect(funcs['*'].length).toEqual(2);
+    });
+  });
+});
diff --git a/spec/javascripts/serverless/store/mutations_spec.js b/spec/javascripts/serverless/store/mutations_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ca3053e5c384fe6d2e9df56e6bc42bf55d739800
--- /dev/null
+++ b/spec/javascripts/serverless/store/mutations_spec.js
@@ -0,0 +1,86 @@
+import mutations from '~/serverless/store/mutations';
+import * as types from '~/serverless/store/mutation_types';
+import { mockServerlessFunctions, mockMetrics } from '../mock_data';
+
+describe('ServerlessMutations', () => {
+  describe('Functions List Mutations', () => {
+    it('should ensure loading is true', () => {
+      const state = {};
+
+      mutations[types.REQUEST_FUNCTIONS_LOADING](state);
+
+      expect(state.isLoading).toEqual(true);
+    });
+
+    it('should set proper state once functions are loaded', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_FUNCTIONS_SUCCESS](state, mockServerlessFunctions);
+
+      expect(state.isLoading).toEqual(false);
+      expect(state.hasFunctionData).toEqual(true);
+      expect(state.functions).toEqual(mockServerlessFunctions);
+    });
+
+    it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state);
+
+      expect(state.isLoading).toEqual(false);
+      expect(state.hasFunctionData).toEqual(false);
+      expect(state.functions).toBe(undefined);
+    });
+
+    it('should ensure loading has stopped, and an error is raised', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_FUNCTIONS_ERROR](state, 'sample error');
+
+      expect(state.isLoading).toEqual(false);
+      expect(state.hasFunctionData).toEqual(false);
+      expect(state.functions).toBe(undefined);
+      expect(state.error).not.toBe(undefined);
+    });
+  });
+
+  describe('Function Details Metrics Mutations', () => {
+    it('should ensure isLoading and hasPrometheus data flags indicate data is loaded', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_METRICS_SUCCESS](state, mockMetrics);
+
+      expect(state.isLoading).toEqual(false);
+      expect(state.hasPrometheusData).toEqual(true);
+      expect(state.graphData).toEqual(mockMetrics);
+    });
+
+    it('should ensure isLoading and hasPrometheus data flags are cleared indicating no functions available', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_METRICS_NODATA_SUCCESS](state);
+
+      expect(state.isLoading).toEqual(false);
+      expect(state.hasPrometheusData).toEqual(false);
+      expect(state.graphData).toBe(undefined);
+    });
+
+    it('should properly indicate an error', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_METRICS_ERROR](state, 'sample error');
+
+      expect(state.hasPrometheusData).toEqual(false);
+      expect(state.error).not.toBe(undefined);
+    });
+
+    it('should properly indicate when prometheus is installed', () => {
+      const state = {};
+
+      mutations[types.RECEIVE_METRICS_NO_PROMETHEUS](state);
+
+      expect(state.hasPrometheus).toEqual(false);
+      expect(state.hasPrometheusData).toEqual(false);
+    });
+  });
+});
diff --git a/spec/javascripts/serverless/stores/serverless_store_spec.js b/spec/javascripts/serverless/stores/serverless_store_spec.js
deleted file mode 100644
index 72fd903d7d189a2b2e4e3a141eb49c5517f146db..0000000000000000000000000000000000000000
--- a/spec/javascripts/serverless/stores/serverless_store_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import ServerlessStore from '~/serverless/stores/serverless_store';
-import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
-
-describe('Serverless Functions Store', () => {
-  let store;
-
-  beforeEach(() => {
-    store = new ServerlessStore(false, '/cluster_path', 'help_path');
-  });
-
-  describe('#updateFunctionsFromServer', () => {
-    it('should pass an empty hash object', () => {
-      store.updateFunctionsFromServer();
-
-      expect(store.state.functions).toEqual({});
-    });
-
-    it('should group functions to one global environment', () => {
-      const mockServerlessData = mockServerlessFunctions;
-      store.updateFunctionsFromServer(mockServerlessData);
-
-      expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
-      expect(store.state.functions['*'].length).toEqual(2);
-    });
-
-    it('should group functions to multiple environments', () => {
-      const mockServerlessData = mockServerlessFunctionsDiffEnv;
-      store.updateFunctionsFromServer(mockServerlessData);
-
-      expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
-      expect(store.state.functions['*'].length).toEqual(1);
-      expect(store.state.functions.test.length).toEqual(1);
-      expect(store.state.functions.test[0].name).toEqual('testfunc2');
-    });
-  });
-});
diff --git a/spec/javascripts/serverless/utils.js b/spec/javascripts/serverless/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ce2e37d493674145e2e9831bfcd1c183c83d7ae
--- /dev/null
+++ b/spec/javascripts/serverless/utils.js
@@ -0,0 +1,20 @@
+export const adjustMetricQuery = data => {
+  const updatedMetric = data.metrics;
+
+  const queries = data.metrics.queries.map(query => ({
+    ...query,
+    result: query.result.map(result => ({
+      ...result,
+      values: result.values.map(([timestamp, value]) => ({
+        time: new Date(timestamp * 1000).toISOString(),
+        value: Number(value),
+      })),
+    })),
+  }));
+
+  updatedMetric.queries = queries;
+  return updatedMetric;
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7f6283715f22742ab9e6b63c7885b842c6427e91
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do
+  include PrometheusHelpers
+
+  let(:project) { create(:project) }
+  let(:serverless_func) { Serverless::Function.new(project, 'test-name', 'test-ns') }
+
+  let(:client) { double('prometheus_client') }
+  subject { described_class.new(client) }
+
+  context 'verify queries' do
+    before do
+      allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns')))
+      allow(client).to receive(:query_range)
+    end
+
+    it 'has the query, but no data' do
+      results = subject.query(serverless_func.id)
+
+      expect(results.queries[0][:query_range]).to eql('floor(sum(rate(istio_revision_request_count{destination_configuration="test-name", destination_namespace="test-ns"}[1m])*30))')
+    end
+  end
+end
diff --git a/spec/models/serverless/function_spec.rb b/spec/models/serverless/function_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1854d5f94151c0f66f0a77e6531e7f29352119d0
--- /dev/null
+++ b/spec/models/serverless/function_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Serverless::Function do
+  let(:project) { create(:project) }
+  let(:func) { described_class.new(project, 'test', 'test-ns') }
+
+  it 'has a proper id' do
+    expect(func.id).to eql("#{project.id}/test/test-ns")
+    expect(func.name).to eql("test")
+    expect(func.namespace).to eql("test-ns")
+  end
+
+  it 'can decode an identifier' do
+    f = described_class.find_by_id("#{project.id}/testfunc/dummy-ns")
+
+    expect(f.name).to eql("testfunc")
+    expect(f.namespace).to eql("dummy-ns")
+  end
+end
diff --git a/spec/support/helpers/prometheus_helpers.rb b/spec/support/helpers/prometheus_helpers.rb
index 08d1d7a605915531088f07ff12599410e8f7802c..87f825152cf7810360fd3d4c701c4318d9aed568 100644
--- a/spec/support/helpers/prometheus_helpers.rb
+++ b/spec/support/helpers/prometheus_helpers.rb
@@ -7,6 +7,10 @@ def prometheus_cpu_query(environment_slug)
     %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100}
   end
 
+  def prometheus_istio_query(function_name, kube_namespace)
+    %{floor(sum(rate(istio_revision_request_count{destination_configuration=\"#{function_name}\", destination_namespace=\"#{kube_namespace}\"}[1m])*30))}
+  end
+
   def prometheus_ping_url(prometheus_query)
     query = { query: prometheus_query }.to_query