summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFatih Acet <acetfatih@gmail.com>2019-07-22 12:01:43 +0000
committer🤖 GitLab Bot 🤖 <gitlab-bot@gitlab.com>2019-07-22 13:21:49 +0000
commit3a9f91b6a00ccb00408ec9ca4e6e42aab6dc9dc8 (patch)
tree17db1800b0c15d346e1bbadcf8f402a9ae94e0dc
parent40541393153af5e36f212a39febbbb446db871ca (diff)
downloadgitlab-ce-3a9f91b6a00ccb00408ec9ca4e6e42aab6dc9dc8.tar.gz
Merge branch 'tr-embed-metrics-frontend' into 'master'
Embed metrics charts in issues See merge request gitlab-org/gitlab-ce!29691 (cherry picked from commit 886a6957ec0d981426219f42d75e0af145a9f7cf) 80feba93 Add ability to embed metrics b5cdde0c Integrate latest backend changes and feature flag 330b0414 Fix jest test c260ad82 Migrate TODOs to issues 3a822d99 Seet appropriate default 57c2eb79 Put gfm rendering behind a feature flag cfeda009 Rename link variable 81ff8d43 Move feature spec into new MR 9d08a5dd Add devensive check on `gon` features object ca5e00cf Add w-100 style 6e63457b Condense border into shorthand 8de8b6cf Move sidebarAnimationDuration into constants c9936bf9 Remove extraneous default export 010bb0e3 Reword conditional logic f4ea4c81 Simplify filter logic a2f1b4c2 Move styling from css to utility class da9788df Tidy up component initialization c69a119b Avoid duplication of `embedded` param supplied by backend be4b7a11 Remove unnecessary mount d203980b Apply suggestion to app/assets/javascripts/monitoring/components/charts/area.vue 62ebc6e3 Use object notation 26075703 Add missing class d9464420 Make sidebarAnimationDuration match actual sidebar animation length 83014858 Remove nextTick and compute groupData object directly 259f700b Rename variable for accuracy f34890cc Use composite key for dashboard metric groups 92ef33a0 Fix unit test to accommodate for removal of mount call e2f10bba Remove changelog entry as feature is behind feature flag c6d8b271 Use graph title as key for embeds
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_metrics.js24
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue96
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue97
-rw-r--r--app/assets/javascripts/monitoring/constants.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js3
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss5
-rw-r--r--spec/frontend/behaviors/markdown/render_metrics_spec.js37
-rw-r--r--spec/frontend/monitoring/embed/embed_spec.js78
-rw-r--r--spec/frontend/monitoring/embed/mock_data.js87
-rw-r--r--spec/frontend/test_setup.js6
15 files changed, 410 insertions, 50 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index bfb073fdcdc..789a057caf8 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
+import renderMetrics from './render_metrics';
import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
import initMRPopovers from '../../mr_popover';
@@ -17,6 +18,9 @@ $.fn.renderGFM = function renderGFM() {
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
initMRPopovers(this.find('.gfm-merge_request').get());
+ if (gon.features && gon.features.gfmEmbeddedMetrics) {
+ renderMetrics(this.find('.js-render-metrics').get());
+ }
return this;
};
diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js
new file mode 100644
index 00000000000..252b98610b6
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import Metrics from '~/monitoring/components/embed.vue';
+import { createStore } from '~/monitoring/stores';
+
+// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-ce/issues/64369.
+export default function renderMetrics(elements) {
+ if (!elements.length) {
+ return;
+ }
+
+ elements.forEach(element => {
+ const { dashboardUrl } = element.dataset;
+ const MetricsComponent = Vue.extend(Metrics);
+
+ // eslint-disable-next-line no-new
+ new MetricsComponent({
+ el: element,
+ store: createStore(),
+ propsData: {
+ dashboardUrl,
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index 454ff4f284e..edf9423c74c 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -37,7 +37,13 @@ export default {
},
projectPath: {
type: String,
- required: true,
+ required: false,
+ default: () => '',
+ },
+ showBorder: {
+ type: Boolean,
+ required: false,
+ default: () => false,
},
thresholds: {
type: Array,
@@ -234,52 +240,54 @@ export default {
</script>
<template>
- <div class="prometheus-graph col-12 col-lg-6">
- <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="chartData"
- :option="chartOptions"
- :format-tooltip-text="formatTooltipText"
- :thresholds="thresholds"
- :width="width"
- :height="height"
- @updated="onChartUpdated"
- >
- <template v-if="tooltip.isDeployment">
- <template slot="tooltipTitle">
- {{ __('Deployed') }}
- </template>
- <div slot="tooltipContent" class="d-flex align-items-center">
- <icon name="commit" class="mr-2" />
- <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
- </div>
- </template>
- <template v-else>
- <template slot="tooltipTitle">
- <div class="text-nowrap">
- {{ tooltip.title }}
+ <div class="col-12 col-lg-6" :class="[showBorder ? 'p-2' : 'p-0']">
+ <div class="prometheus-graph" :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }">
+ <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="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :thresholds="thresholds"
+ :width="width"
+ :height="height"
+ @updated="onChartUpdated"
+ >
+ <template v-if="tooltip.isDeployment">
+ <template slot="tooltipTitle">
+ {{ __('Deployed') }}
+ </template>
+ <div slot="tooltipContent" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
- <template slot="tooltipContent">
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="prepend-left-32">
- {{ content.value }}
+ <template v-else>
+ <template slot="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
</div>
- </div>
+ </template>
+ <template slot="tooltipContent">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
+ </div>
+ </template>
</template>
- </template>
- </gl-area-chart>
+ </gl-area-chart>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 6eaced0c108..c7c55880040 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -11,10 +11,9 @@ import MonitorSingleStatChart from './charts/single_stat.vue';
import PanelType from './panel_type.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
-import { timeWindows, timeWindowsKeyNames } from '../constants';
+import { sidebarAnimationDuration, timeWindows, timeWindowsKeyNames } from '../constants';
import { getTimeDiff } from '../utils';
-const sidebarAnimationDuration = 150;
let sidebarMutationObserver;
export default {
@@ -370,8 +369,8 @@ export default {
</div>
<div v-if="!showEmptyState">
<graph-group
- v-for="(groupData, index) in groupsWithData"
- :key="index"
+ v-for="groupData in groupsWithData"
+ :key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
new file mode 100644
index 00000000000..e17f03de0fd
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -0,0 +1,97 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import GraphGroup from './graph_group.vue';
+import MonitorAreaChart from './charts/area.vue';
+import { sidebarAnimationDuration, timeWindowsKeyNames, timeWindows } from '../constants';
+import { getTimeDiff } from '../utils';
+
+let sidebarMutationObserver;
+
+export default {
+ components: {
+ GraphGroup,
+ MonitorAreaChart,
+ },
+ props: {
+ dashboardUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ params: {
+ ...getTimeDiff(timeWindows[timeWindowsKeyNames.eightHours]),
+ },
+ elWidth: 0,
+ };
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['groups', 'metricsWithData']),
+ groupData() {
+ const groupsWithData = this.groups.filter(group => this.chartsWithData(group.metrics).length);
+ if (groupsWithData.length) {
+ return groupsWithData[0];
+ }
+ return null;
+ },
+ },
+ mounted() {
+ this.setInitialState();
+ this.fetchMetricsData(this.params);
+ sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
+ sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
+ attributes: true,
+ childList: false,
+ subtree: false,
+ });
+ },
+ beforeDestroy() {
+ if (sidebarMutationObserver) {
+ sidebarMutationObserver.disconnect();
+ }
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', [
+ 'fetchMetricsData',
+ 'setEndpoints',
+ 'setFeatureFlags',
+ 'setShowErrorBanner',
+ ]),
+ chartsWithData(charts) {
+ return charts.filter(chart =>
+ chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
+ );
+ },
+ onSidebarMutation() {
+ setTimeout(() => {
+ this.elWidth = this.$el.clientWidth;
+ }, sidebarAnimationDuration);
+ },
+ setInitialState() {
+ this.setFeatureFlags({
+ prometheusEndpointEnabled: true,
+ });
+ this.setEndpoints({
+ dashboardEndpoint: this.dashboardUrl,
+ });
+ this.setShowErrorBanner(false);
+ },
+ },
+};
+</script>
+<template>
+ <div class="metrics-embed">
+ <div v-if="groupData" class="row w-100 m-n2 pb-4">
+ <monitor-area-chart
+ v-for="graphData in chartsWithData(groupData.metrics)"
+ :key="graphData.title"
+ :graph-data="graphData"
+ :container-width="elWidth"
+ group-id="monitor-area-chart"
+ :project-path="null"
+ :show-border="true"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 26f1bf3f68d..605c95e6da5 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -1,5 +1,7 @@
import { __ } from '~/locale';
+export const sidebarAnimationDuration = 300; // milliseconds.
+
export const chartHeight = 300;
export const graphTypes = {
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 5b3da51e9a6..245cc2eaca3 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -44,6 +44,10 @@ export const setFeatureFlags = (
commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
};
+export const setShowErrorBanner = ({ commit }, enabled) => {
+ commit(types.SET_SHOW_ERROR_BANNER, enabled);
+};
+
export const requestMetricsDashboard = ({ commit }) => {
commit(types.REQUEST_METRICS_DATA);
};
@@ -99,7 +103,9 @@ export const fetchMetricsData = ({ state, dispatch }, params) => {
})
.catch(error => {
dispatch('receiveMetricsDataFailure', error);
- createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ if (state.setShowErrorBanner) {
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ }
});
};
@@ -119,7 +125,9 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
})
.catch(error => {
dispatch('receiveMetricsDashboardFailure', error);
- createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ if (state.setShowErrorBanner) {
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ }
});
};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index c89daba3df7..4b1aadbcf05 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -16,3 +16,4 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
+export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 0104dcb867d..b19520d6638 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -96,4 +96,7 @@ export default {
[types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) {
state.additionalPanelTypesEnabled = enabled;
},
+ [types.SET_SHOW_ERROR_BANNER](state, enabled) {
+ state.showErrorBanner = enabled;
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index e54bb712695..440bdc951e0 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -12,6 +12,7 @@ export default () => ({
additionalPanelTypesEnabled: false,
emptyState: 'gettingStarted',
showEmptyState: true,
+ showErrorBanner: true,
groups: [],
deploymentData: [],
environments: [],
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 2d600e3aef6..05a4cc168a8 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -29,6 +29,11 @@
padding: $gl-padding / 2;
}
+.prometheus-graph-embed {
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+}
+
.prometheus-graph-header {
display: flex;
align-items: center;
diff --git a/spec/frontend/behaviors/markdown/render_metrics_spec.js b/spec/frontend/behaviors/markdown/render_metrics_spec.js
new file mode 100644
index 00000000000..6db0eabc16b
--- /dev/null
+++ b/spec/frontend/behaviors/markdown/render_metrics_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import renderMetrics from '~/behaviors/markdown/render_metrics';
+import { TEST_HOST } from 'helpers/test_constants';
+
+const originalExtend = Vue.extend;
+
+describe('Render metrics for Gitlab Flavoured Markdown', () => {
+ const container = {
+ Metrics() {},
+ };
+
+ let spyExtend;
+
+ beforeEach(() => {
+ Vue.extend = () => container.Metrics;
+ spyExtend = jest.spyOn(Vue, 'extend');
+ });
+
+ afterEach(() => {
+ Vue.extend = originalExtend;
+ });
+
+ it('does nothing when no elements are found', () => {
+ renderMetrics([]);
+
+ expect(spyExtend).not.toHaveBeenCalled();
+ });
+
+ it('renders a vue component when elements are found', () => {
+ const element = document.createElement('div');
+ element.setAttribute('data-dashboard-url', TEST_HOST);
+
+ renderMetrics([element]);
+
+ expect(spyExtend).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js
new file mode 100644
index 00000000000..3b18a0f77c7
--- /dev/null
+++ b/spec/frontend/monitoring/embed/embed_spec.js
@@ -0,0 +1,78 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import Embed from '~/monitoring/components/embed.vue';
+import MonitorAreaChart from '~/monitoring/components/charts/area.vue';
+import { TEST_HOST } from 'helpers/test_constants';
+import { groups, initialState, metricsData, metricsWithData } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Embed', () => {
+ let wrapper;
+ let store;
+ let actions;
+
+ function mountComponent() {
+ wrapper = shallowMount(Embed, {
+ localVue,
+ store,
+ propsData: {
+ dashboardUrl: TEST_HOST,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ actions = {
+ setFeatureFlags: () => {},
+ setShowErrorBanner: () => {},
+ setEndpoints: () => {},
+ fetchMetricsData: () => {},
+ };
+
+ store = new Vuex.Store({
+ modules: {
+ monitoringDashboard: {
+ namespaced: true,
+ actions,
+ state: initialState,
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('no metrics are available yet', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('shows an empty state when no metrics are present', () => {
+ expect(wrapper.find('.metrics-embed').exists()).toBe(true);
+ expect(wrapper.find(MonitorAreaChart).exists()).toBe(false);
+ });
+ });
+
+ describe('metrics are available', () => {
+ beforeEach(() => {
+ store.state.monitoringDashboard.groups = groups;
+ store.state.monitoringDashboard.groups[0].metrics = metricsData;
+ store.state.monitoringDashboard.metricsWithData = metricsWithData;
+
+ mountComponent();
+ });
+
+ it('shows a chart when metrics are present', () => {
+ wrapper.setProps({});
+ expect(wrapper.find('.metrics-embed').exists()).toBe(true);
+ expect(wrapper.find(MonitorAreaChart).exists()).toBe(true);
+ expect(wrapper.findAll(MonitorAreaChart).length).toBe(2);
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/embed/mock_data.js b/spec/frontend/monitoring/embed/mock_data.js
new file mode 100644
index 00000000000..df4acb82e95
--- /dev/null
+++ b/spec/frontend/monitoring/embed/mock_data.js
@@ -0,0 +1,87 @@
+export const metricsWithData = [15, 16];
+
+export const groups = [
+ {
+ panels: [
+ {
+ title: 'Memory Usage (Total)',
+ type: 'area-chart',
+ y_label: 'Total Memory Used',
+ weight: 4,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_memory_total',
+ metric_id: 15,
+ },
+ ],
+ },
+ {
+ title: 'Core Usage (Total)',
+ type: 'area-chart',
+ y_label: 'Total Cores',
+ weight: 3,
+ metrics: [
+ {
+ id: 'system_metrics_kubernetes_container_cores_total',
+ metric_id: 16,
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const metrics = [
+ {
+ id: 'system_metrics_kubernetes_container_memory_total',
+ metric_id: 15,
+ },
+ {
+ id: 'system_metrics_kubernetes_container_cores_total',
+ metric_id: 16,
+ },
+];
+
+const queries = [
+ {
+ result: [
+ {
+ values: [
+ ['Mon', 1220],
+ ['Tue', 932],
+ ['Wed', 901],
+ ['Thu', 934],
+ ['Fri', 1290],
+ ['Sat', 1330],
+ ['Sun', 1320],
+ ],
+ },
+ ],
+ },
+];
+
+export const metricsData = [
+ {
+ queries,
+ metrics: [
+ {
+ metric_id: 15,
+ },
+ ],
+ },
+ {
+ queries,
+ metrics: [
+ {
+ metric_id: 16,
+ },
+ ],
+ },
+];
+
+export const initialState = {
+ monitoringDashboard: {},
+ groups: [],
+ metricsWithData: [],
+ useDashboardEndpoint: true,
+};
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 634c78ec029..e4d62b044ca 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -69,3 +69,9 @@ Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => {
// Tech debt issue TBD
testUtilsConfig.logModifiedComponents = false;
+
+// Basic stub for MutationObserver
+global.MutationObserver = () => ({
+ disconnect: () => {},
+ observe: () => {},
+});