🔀 Merge pull request #392 from Lissy93/FEATURE/add-request-proxy

[FIX] Adds support for proxying CORS requests
Happy new year :) 🎇
This commit is contained in:
Alicia Sykes 2022-01-01 01:18:42 +00:00 committed by GitHub
commit 3b7d5a6ff7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 262 additions and 172 deletions

View File

@ -1,5 +1,11 @@
# Changelog # Changelog
## ⚡️ 1.9.6 - Adds Proxy Support for Widget Requests [PR #392](https://github.com/Lissy93/dashy/pull/392)
- Refactors widget mixin to include data requests, so that code can be shared between widgets
- Adds a Node endpoint for proxying requests server-side, used for APIs that are not CORS enabled
- Adds option to config file for user to force proxying of requests
- Writes a Netlify cloud function to support proxying when the app is hosted on Netlify
## 🐛 1.9.5 - Bug fixes and Minor Improvements [PR #388](https://github.com/Lissy93/dashy/pull/388) ## 🐛 1.9.5 - Bug fixes and Minor Improvements [PR #388](https://github.com/Lissy93/dashy/pull/388)
- Adds icon.horse to supported favicon APIs - Adds icon.horse to supported favicon APIs
- Fixes tile move bug, Re: #366 - Fixes tile move bug, Re: #366

View File

@ -172,7 +172,8 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
--- | --- | --- | --- --- | --- | --- | ---
**`name`** | `string` | Required | The title for the section **`name`** | `string` | Required | The title for the section
**`icon`** | `string` | _Optional_ | An single icon to be displayed next to the title. See [`section.icon`](#sectionicon-and-sectionitemicon) **`icon`** | `string` | _Optional_ | An single icon to be displayed next to the title. See [`section.icon`](#sectionicon-and-sectionitemicon)
**`items`** | `array` | Required | An array of items to be displayed within the section. See [`item`](#sectionitem) **`items`** | `array` | _Optional_ | An array of items to be displayed within the section. See [`item`](#sectionitem). Sections must include either 1 or more items, or 1 or more widgets.
**`widgets`** | `array` | _Optional_ | An array of widgets to be displayed within the section. See [`widget`](#sectionwidget-optional)
**`displayData`** | `object` | _Optional_ | Meta-data to optionally overide display settings for a given section. See [`displayData`](#sectiondisplaydata-optional) **`displayData`** | `object` | _Optional_ | Meta-data to optionally overide display settings for a given section. See [`displayData`](#sectiondisplaydata-optional)
**[⬆️ Back to Top](#configuring)** **[⬆️ Back to Top](#configuring)**
@ -198,6 +199,18 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
**[⬆️ Back to Top](#configuring)** **[⬆️ Back to Top](#configuring)**
### `section.widget` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`type`** | `string` | Required | The widget type. See [Widget Docs](/docs/widgets.md) for full list of supported widgets
**`options`** | `object` | _Optional_ | Some widgets accept either optional or required additional options. Again, see the [Widget Docs](/docs/widgets.md) for full list of options
**`updateInterval`** | `number` | _Optional_ | You can keep a widget constantly updated by specifying an update interval, in seconds. See [Continuous Updates Docs](/docs/widgets.md#continuous-updates) for more info
**`useProxy`** | `boolean` | _Optional_ | Some widgets make API requests to services that are not CORS-enabled. For these instances, you will need to route requests through a proxy, Dashy has a built in CORS-proxy, which you can use by setting this option to `true`. Defaults to `false`. See the [Proxying Requests Docs](/docs/widgets.md#proxying-requests) for more info
**[⬆️ Back to Top](#configuring)**
### `section.displayData` _(optional)_ ### `section.displayData` _(optional)_
**Field** | **Type** | **Required**| **Description** **Field** | **Type** | **Required**| **Description**

View File

@ -1208,6 +1208,31 @@ For more info on how to apply custom variables, see the [Theming Docs](/docs/the
--- ---
### Proxying Requests
If a widget fails to make a data request, and the console shows a CORS error, this means the server is blocking client-side requests.
Dashy has a built-in CORS proxy ([`services/cors-proxy.js`](https://github.com/Lissy93/dashy/blob/master/services/cors-proxy.js)), which will be used automatically by some widgets, or can be forced to use by other by setting the `useProxy` option.
For example:
```yaml
widgets:
- type: pi-hole-stats
useProxy: true
options:
hostname: http://pi-hole.local
```
Alternativley, and more securley, you can set the auth headers on your service to accept requests from Dashy. For example:
```
Access-Control-Allow-Origin: https://location-of-dashy/
Vary: Origin
```
---
### Language Translations ### Language Translations
Since most of the content displayed within widgets is fetched from an external API, unless that API supports multiple languages, translating dynamic content is not possible. Since most of the content displayed within widgets is fetched from an external API, unless that API supports multiple languages, translating dynamic content is not possible.

View File

@ -27,6 +27,11 @@
to = "/.netlify/functions/not-supported" to = "/.netlify/functions/not-supported"
status = 301 status = 301
force = true force = true
[[redirects]]
from = "/cors-proxy"
to = "/.netlify/functions/netlify-cors"
status = 301
force = true
# For router history mode, ensure pages land on index # For router history mode, ensure pages land on index
[[redirects]] [[redirects]]
@ -40,3 +45,4 @@
[headers.values] [headers.values]
# Uncomment to enable Netlify user control. You must have a paid plan. # Uncomment to enable Netlify user control. You must have a paid plan.
# Basic-Auth = "someuser:somepassword anotheruser:anotherpassword" # Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"

View File

@ -1,6 +1,6 @@
{ {
"name": "Dashy", "name": "Dashy",
"version": "1.9.5", "version": "1.9.6",
"license": "MIT", "license": "MIT",
"main": "server", "main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)", "author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",

View File

@ -32,7 +32,7 @@ module.exports = (req, res) => {
// Prepare the request // Prepare the request
const requestConfig = { const requestConfig = {
method: req.method, method: req.method,
url: targetURL + req.url, url: targetURL,
json: req.body, json: req.body,
headers, headers,
}; };

View File

@ -0,0 +1,48 @@
/* A Netlify cloud function to handle requests to CORS-disabled services */
const axios = require('axios');
exports.handler = (event, context, callback) => {
// Get input data
const { body, headers, queryStringParameters } = event;
// Get URL from header or GET param
const requestUrl = queryStringParameters.url || headers['Target-URL'] || headers['target-url'];
const returnError = (msg, error) => {
callback(null, {
statusCode: 400,
body: JSON.stringify({ success: false, msg, error }),
});
};
// If URL missing, return error
if (!requestUrl) {
returnError('Missing Target-URL header', null);
}
let custom = {};
try {
custom = JSON.parse(headers.CustomHeaders || headers.customheaders || '{}');
} catch (e) { returnError('Unable to parse custom headers'); }
// Response headers
const requestHeaders = {
'Access-Control-Allow-Origin': '*',
...custom,
};
// Prepare request
const requestConfig = {
method: 'GET',
url: requestUrl,
json: body,
headers: requestHeaders,
};
// Make request
axios.request(requestConfig)
.then((response) => {
callback(null, { statusCode: 200, body: JSON.stringify(response.data) });
}).catch((error) => {
returnError('Request failed', error);
});
};

View File

@ -263,6 +263,12 @@
"up": "Online", "up": "Online",
"down": "Offline" "down": "Offline"
}, },
"net-data": {
"cpu-chart-title": "CPU History",
"mem-chart-title": "Memory Usage",
"mem-breakdown-title": "Memory Breakdown",
"load-chart-title": "System Load"
},
"system-info": { "system-info": {
"uptime": "Uptime" "uptime": "Uptime"
}, },

View File

@ -17,9 +17,8 @@
</template> </template>
<script> <script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin'; import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints, serviceEndpoints } from '@/utils/defaults'; import { widgetApiEndpoints } from '@/utils/defaults';
import { capitalize, timestampToDateTime } from '@/utils/MiscHelpers'; import { capitalize, timestampToDateTime } from '@/utils/MiscHelpers';
export default { export default {
@ -48,10 +47,6 @@ export default {
if (this.options.host) return `${this.options.host}/api/v1/checks`; if (this.options.host) return `${this.options.host}/api/v1/checks`;
return `${widgetApiEndpoints.healthChecks}`; return `${widgetApiEndpoints.healthChecks}`;
}, },
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
apiKey() { apiKey() {
if (!this.options.apiKey) { if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info'); this.error('An API key is required, please see the docs for more info');
@ -62,23 +57,11 @@ export default {
methods: { methods: {
/* Make GET request to CoinGecko API endpoint */ /* Make GET request to CoinGecko API endpoint */
fetchData() { fetchData() {
const requestConfig = { this.overrideProxyChoice = true;
method: 'GET', const authHeaders = { 'X-Api-Key': this.apiKey };
url: this.proxyReqEndpoint, this.makeRequest(this.endpoint, authHeaders).then(
headers: { (response) => { this.processData(response); },
'access-control-request-headers': '*', );
'Target-URL': this.endpoint,
CustomHeaders: JSON.stringify({ 'X-Api-Key': this.apiKey }),
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch cron data', error);
}).finally(() => {
this.finishLoading();
});
}, },
/* Assign data variables to the returned data */ /* Assign data variables to the returned data */
processData(data) { processData(data) {

View File

@ -3,20 +3,12 @@
</template> </template>
<script> <script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin'; import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin'; import ChartingMixin from '@/mixins/ChartingMixin';
export default { export default {
mixins: [WidgetMixin, ChartingMixin], mixins: [WidgetMixin, ChartingMixin],
components: {}, components: {},
data() {
return {
chartTitle: null,
chartData: null,
chartDom: null,
};
},
computed: { computed: {
/* URL where NetData is hosted */ /* URL where NetData is hosted */
netDataHost() { netDataHost() {
@ -41,50 +33,44 @@ export default {
methods: { methods: {
/* Make GET request to NetData */ /* Make GET request to NetData */
fetchData() { fetchData() {
axios.get(this.endpoint) this.makeRequest(this.endpoint).then(
.then((response) => { (response) => { this.processData(response); },
this.processData(response.data); );
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
}, },
/* Assign data variables to the returned data */ /* Assign data variables to the returned data */
processData(data) { processData(inputData) {
const timeData = []; const { labels, data } = inputData;
const systemCpu = []; const timeData = []; // List of timestamps for axis
const userCpu = []; const resultGroup = {}; // List of datasets, for each label
data.data.reverse().forEach((reading) => { data.reverse().forEach((reading) => {
timeData.push(this.formatDate(reading[0] * 1000)); labels.forEach((label, indx) => {
systemCpu.push(reading[2]); if (indx === 0) { // First value is the timestamp, add to axis
userCpu.push(reading[3]); timeData.push(this.formatTime(reading[indx] * 1000));
} else { // All other values correspond to a label
if (!resultGroup[label]) resultGroup[label] = [];
resultGroup[label].push(reading[indx]);
}
});
}); });
this.chartData = { const datasets = [];
labels: timeData, Object.keys(resultGroup).forEach((label) => {
datasets: [ datasets.push({ name: label, type: 'bar', values: resultGroup[label] });
{ name: 'System CPU', type: 'bar', values: systemCpu }, });
{ name: 'User CPU', type: 'bar', values: userCpu }, const timeChartData = { labels: timeData, datasets };
], const chartTitle = this.makeChartTitle(data);
}; this.generateChart(timeChartData, chartTitle);
this.chartTitle = this.makeChartTitle(data.data);
this.renderChart();
}, },
makeChartTitle(data) { makeChartTitle(data) {
if (!data || !data[0][0]) return ''; const prefix = this.$t('widgets.net-data.cpu-chart-title');
if (!data || !data[0][0]) return prefix;
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60); const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `Past ${diff} minutes`; return `${prefix}: Past ${diff} minutes`;
},
renderChart() {
this.chartDom = this.generateChart();
}, },
/* Create new chart, using the crypto data */ /* Create new chart, using the crypto data */
generateChart() { generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, { return new this.Chart(`#${this.chartId}`, {
title: this.chartTitle, title: chartTitle,
data: this.chartData, data: timeChartData,
type: 'axis-mixed', type: 'axis-mixed',
height: this.chartHeight, height: this.chartHeight,
colors: this.chartColors, colors: this.chartColors,

View File

@ -10,13 +10,6 @@ import ChartingMixin from '@/mixins/ChartingMixin';
export default { export default {
mixins: [WidgetMixin, ChartingMixin], mixins: [WidgetMixin, ChartingMixin],
components: {}, components: {},
data() {
return {
chartTitle: null,
chartData: null,
chartDom: null,
};
},
computed: { computed: {
/* URL where NetData is hosted */ /* URL where NetData is hosted */
netDataHost() { netDataHost() {
@ -31,7 +24,7 @@ export default {
return this.options.apiVersion || 'v1'; return this.options.apiVersion || 'v1';
}, },
endpoint() { endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.cpu`; return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.load`;
}, },
/* A sudo-random ID for the chart DOM element */ /* A sudo-random ID for the chart DOM element */
chartId() { chartId() {
@ -64,7 +57,7 @@ export default {
load5mins.push(reading[2]); load5mins.push(reading[2]);
load15mins.push(reading[3]); load15mins.push(reading[3]);
}); });
this.chartData = { const chartData = {
labels: timeData, labels: timeData,
datasets: [ datasets: [
{ name: '1 Min', type: 'bar', values: load1min }, { name: '1 Min', type: 'bar', values: load1min },
@ -72,22 +65,20 @@ export default {
{ name: '15 Mins', type: 'bar', values: load15mins }, { name: '15 Mins', type: 'bar', values: load15mins },
], ],
}; };
this.chartTitle = this.makeChartTitle(data.data); const chartTitle = this.makeChartTitle(data.data);
this.renderChart(); this.generateChart(chartData, chartTitle);
}, },
makeChartTitle(data) { makeChartTitle(data) {
if (!data || !data[0][0]) return ''; const prefix = this.$t('widgets.net-data.load-chart-title');
if (!data || !data[0][0]) return prefix;
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60); const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `Past ${diff} minutes`; return `${prefix}: Past ${diff} minutes`;
},
renderChart() {
this.chartDom = this.generateChart();
}, },
/* Create new chart, using the crypto data */ /* Create new chart, using the crypto data */
generateChart() { generateChart(chartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, { return new this.Chart(`#${this.chartId}`, {
title: this.chartTitle, title: chartTitle,
data: this.chartData, data: chartData,
type: 'axis-mixed', type: 'axis-mixed',
height: this.chartHeight, height: this.chartHeight,
colors: this.chartColors, colors: this.chartColors,

View File

@ -6,7 +6,6 @@
</template> </template>
<script> <script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin'; import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin'; import ChartingMixin from '@/mixins/ChartingMixin';
@ -37,16 +36,9 @@ export default {
methods: { methods: {
/* Make GET request to NetData */ /* Make GET request to NetData */
fetchData() { fetchData() {
axios.get(this.endpoint) this.makeRequest(this.endpoint).then(
.then((response) => { (response) => { this.processData(response); },
this.processData(response.data); );
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
}, },
/* Assign data variables to the returned data */ /* Assign data variables to the returned data */
processData(inputData) { processData(inputData) {
@ -86,7 +78,7 @@ export default {
/* Create new chart, using the crypto data */ /* Create new chart, using the crypto data */
generateHistoryChart(timeChartData) { generateHistoryChart(timeChartData) {
return new this.Chart(`#${this.chartId}`, { return new this.Chart(`#${this.chartId}`, {
title: 'History', title: this.$t('widgets.net-data.mem-chart-title'),
data: timeChartData, data: timeChartData,
type: 'axis-mixed', type: 'axis-mixed',
height: this.chartHeight, height: this.chartHeight,
@ -107,7 +99,7 @@ export default {
}, },
generateAggregateChart(aggregateChartData) { generateAggregateChart(aggregateChartData) {
return new this.Chart(`#aggregate-${this.chartId}`, { return new this.Chart(`#aggregate-${this.chartId}`, {
title: 'Averages', title: this.$t('widgets.net-data.mem-breakdown-title'),
data: aggregateChartData, data: aggregateChartData,
type: 'percentage', type: 'percentage',
height: 100, height: 100,

View File

@ -18,7 +18,7 @@
</template> </template>
<script> <script>
import axios from 'axios'; // import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin'; import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin'; import ChartingMixin from '@/mixins/ChartingMixin';
import { capitalize } from '@/utils/MiscHelpers'; import { capitalize } from '@/utils/MiscHelpers';
@ -55,15 +55,9 @@ export default {
methods: { methods: {
/* Make GET request to local pi-hole instance */ /* Make GET request to local pi-hole instance */
fetchData() { fetchData() {
axios.get(this.endpoint) this.makeRequest(this.endpoint)
.then((response) => { .then((response) => {
this.processData(response.data); this.processData(response);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
}); });
}, },
/* Assign data variables to the returned data */ /* Assign data variables to the returned data */

View File

@ -11,7 +11,6 @@
</template> </template>
<script> <script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin'; import WidgetMixin from '@/mixins/WidgetMixin';
import { showNumAsThousand } from '@/utils/MiscHelpers'; import { showNumAsThousand } from '@/utils/MiscHelpers';
@ -46,19 +45,13 @@ export default {
methods: { methods: {
/* Make GET request to local pi-hole instance */ /* Make GET request to local pi-hole instance */
fetchData() { fetchData() {
axios.get(this.endpoint) this.makeRequest(this.endpoint)
.then((response) => { .then((response) => {
if (Array.isArray(response.data)) { if (Array.isArray(response)) {
this.error('Got success, but found no results, possible authorization error'); this.error('Got success, but found no results, possible authorization error');
} else { } else {
this.processData(response.data); this.processData(response);
} }
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
}); });
}, },
/* Assign data variables to the returned data */ /* Assign data variables to the returned data */

View File

@ -3,7 +3,6 @@
</template> </template>
<script> <script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin'; import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin'; import ChartingMixin from '@/mixins/ChartingMixin';
@ -31,17 +30,23 @@ export default {
methods: { methods: {
/* Make GET request to local pi-hole instance */ /* Make GET request to local pi-hole instance */
fetchData() { fetchData() {
axios.get(this.endpoint) this.makeRequest(this.endpoint)
.then((response) => { .then((response) => {
this.processData(response.data); if (this.validate(response)) {
}) this.processData(response);
.catch((dataFetchError) => { }
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
}); });
}, },
validate(response) {
if (!response.ads_over_time || !response.domains_over_time) {
this.error('Expected data was not returned from Pi-Hole');
return false;
} else if (response.ads_over_time.length < 1) {
this.error('Request completed succesfully, but no data in Pi-Hole yet');
return false;
}
return true;
},
/* Assign data variables to the returned data */ /* Assign data variables to the returned data */
processData(data) { processData(data) {
const timeData = []; const timeData = [];

View File

@ -319,7 +319,10 @@ export default {
}, },
/* Returns users specified widget options, or empty object */ /* Returns users specified widget options, or empty object */
widgetOptions() { widgetOptions() {
return this.widget.options || {}; const options = this.widget.options || {};
const useProxy = !!this.widget.useProxy;
const updateInterval = this.widget.updateInterval || 0;
return { useProxy, updateInterval, ...options };
}, },
/* A unique string to reference the widget by */ /* A unique string to reference the widget by */
widgetRef() { widgetRef() {

View File

@ -2,8 +2,10 @@
* Mixin that all pre-built and custom widgets extend from. * Mixin that all pre-built and custom widgets extend from.
* Manages loading state, error handling, data updates and user options * Manages loading state, error handling, data updates and user options
*/ */
import axios from 'axios';
import ProgressBar from 'rsup-progress'; import ProgressBar from 'rsup-progress';
import ErrorHandler from '@/utils/ErrorHandler'; import ErrorHandler from '@/utils/ErrorHandler';
import { serviceEndpoints } from '@/utils/defaults';
const WidgetMixin = { const WidgetMixin = {
props: { props: {
@ -14,11 +16,21 @@ const WidgetMixin = {
}, },
data: () => ({ data: () => ({
progress: new ProgressBar({ color: 'var(--progress-bar)' }), progress: new ProgressBar({ color: 'var(--progress-bar)' }),
overrideProxyChoice: false,
}), }),
/* When component mounted, fetch initial data */ /* When component mounted, fetch initial data */
mounted() { mounted() {
this.fetchData(); this.fetchData();
}, },
computed: {
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
useProxy() {
return this.options.useProxy || this.overrideProxyChoice;
},
},
methods: { methods: {
/* Re-fetches external data, called by parent. Usually overridden by widget */ /* Re-fetches external data, called by parent. Usually overridden by widget */
update() { update() {
@ -44,9 +56,36 @@ const WidgetMixin = {
fetchData() { fetchData() {
this.finishLoading(); this.finishLoading();
}, },
/* Used as v-tooltip, pass text content in, and will show on hover */
tooltip(content) { tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 }; return { content, trigger: 'hover focus', delay: 250 };
}, },
/* Makes data request, returns promise */
makeRequest(endpoint, options) {
// Request Options
const method = 'GET';
const url = this.useProxy ? this.proxyReqEndpoint : endpoint;
const CustomHeaders = options ? JSON.stringify(options) : null;
const headers = this.useProxy
? { 'Target-URL': endpoint, CustomHeaders } : CustomHeaders;
// Make request
return new Promise((resolve, reject) => {
axios.request({ method, url, headers })
.then((response) => {
if (response.data.success === false) {
this.error('Proxy returned error from target server', response.data.message);
}
resolve(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
reject(dataFetchError);
})
.finally(() => {
this.finishLoading();
});
});
},
}, },
}; };