From 309bcabf9b62938d63b0cf38c96e5ddc55ddd2fc Mon Sep 17 00:00:00 2001 From: Mcat12 Date: Fri, 9 Nov 2018 23:49:47 -0500 Subject: [PATCH] Add WithAPIData component This component acts as a provider of API data to decouple the view from the data source. Previously the components would handle getting the API data themselves, which hurt testing. util.js has been split out into three files in a new "util" directory. Signed-off-by: Mcat12 --- src/components/common/Sidebar.js | 2 +- src/components/common/Sidebar.test.js | 2 +- src/components/common/StatusBadge.js | 3 +- src/components/common/WithAPIData.js | 102 +++++++++ src/components/dashboard/ClientsGraph.js | 3 +- .../dashboard/ForwardDestinationsChart.js | 2 +- .../dashboard/GenericDoughnutChart.js | 2 +- src/components/dashboard/QueriesGraph.js | 3 +- src/components/dashboard/QueryTypesChart.js | 2 +- src/components/dashboard/SummaryStats.js | 119 +++++----- src/components/dashboard/SummaryStats.test.js | 13 +- src/components/dashboard/TopBlocked.js | 2 +- src/components/dashboard/TopClients.js | 2 +- src/components/dashboard/TopDomains.js | 2 +- src/components/dashboard/TopTable.js | 2 +- src/components/list/DomainInput.js | 2 +- src/components/list/DomainInput.test.js | 2 +- src/components/list/DomainList.js | 3 +- src/components/list/DomainList.test.js | 2 +- src/components/list/ListPage.js | 2 +- src/components/log/QueryLog.js | 3 +- src/components/settings/DHCPInfo.js | 3 +- src/components/settings/DNSInfo.js | 3 +- src/components/settings/FTLInfo.js | 3 +- src/components/settings/NetworkInfo.js | 3 +- src/components/settings/VersionInfo.js | 3 +- src/containers/Full.js | 2 +- src/index.js | 2 +- src/setupTests.js | 2 +- src/util/api.js | 85 ++++++++ src/util/http.js | 172 +++++++++++++++ src/util/index.js | 104 +++++++++ src/utils.js | 205 ------------------ src/utils.test.js | 2 +- src/views/Blacklist.js | 2 +- src/views/Dashboard.js | 2 +- src/views/Login.js | 2 +- src/views/Logout.js | 2 +- src/views/Regexlist.js | 2 +- src/views/Whitelist.js | 2 +- 40 files changed, 582 insertions(+), 294 deletions(-) create mode 100644 src/components/common/WithAPIData.js create mode 100644 src/util/api.js create mode 100644 src/util/http.js create mode 100644 src/util/index.js delete mode 100644 src/utils.js diff --git a/src/components/common/Sidebar.js b/src/components/common/Sidebar.js index 4c29eb4..3f62841 100644 --- a/src/components/common/Sidebar.js +++ b/src/components/common/Sidebar.js @@ -14,7 +14,7 @@ import { NavLink } from "react-router-dom"; import { Nav, NavItem } from "reactstrap"; import logo from "../../img/logo.svg"; import { mobileSidebarHide } from "./Header"; -import { api } from "../../utils"; +import api from "../../util/api"; import StatusBadge from "./StatusBadge"; const handleClick = e => { diff --git a/src/components/common/Sidebar.test.js b/src/components/common/Sidebar.test.js index e65c46f..3fc2043 100644 --- a/src/components/common/Sidebar.test.js +++ b/src/components/common/Sidebar.test.js @@ -11,7 +11,7 @@ import React from "react"; import { shallow } from "enzyme"; import { dropDownClassList, navDropdown, navItem, navList } from "./Sidebar"; -import { api } from "../../utils"; +import api from "../../util/api"; it("expands active drop down items", () => { const classList = dropDownClassList("/testRoute", { diff --git a/src/components/common/StatusBadge.js b/src/components/common/StatusBadge.js index e60585c..c9cd911 100644 --- a/src/components/common/StatusBadge.js +++ b/src/components/common/StatusBadge.js @@ -1,6 +1,7 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, makeCancelable, ignoreCancel } from "../../utils"; +import api from "../../util/api"; +import { makeCancelable, ignoreCancel } from "../../util"; class StatusBadge extends Component { state = { diff --git a/src/components/common/WithAPIData.js b/src/components/common/WithAPIData.js new file mode 100644 index 0000000..e658194 --- /dev/null +++ b/src/components/common/WithAPIData.js @@ -0,0 +1,102 @@ +/* Pi-hole: A black hole for Internet advertisements +* (c) 2017 Pi-hole, LLC (https://pi-hole.net) +* Network-wide ad blocking via your own hardware. +* +* Web Interface +* WithAPIData Component +* +* This file is copyright under the latest version of the EUPL. +* Please see LICENSE file for your rights under this license. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; +import { ignoreCancel, makeCancelable, Result } from "../../util"; + +/** + * A component for getting API data. It can retrieve information once, + * periodically, or on call (refresh). + */ +export class WithAPIData extends Component { + static propTypes = { + apiCall: PropTypes.func.isRequired, + repeatOptions: PropTypes.shape({ + ignoreCancel: PropTypes.bool, + interval: PropTypes.number + }), + /** + * Render the children before the first API request is done + */ + renderInitial: PropTypes.func.isRequired, + /** + * Render the children if the API request succeeded + * + * @param data the data returned from the API call + * @param refresh a function to trigger an asynchronous data refresh + */ + renderOk: PropTypes.func.isRequired, + /** + * Render the children if the API request failed + * + * @param error the error returned from the API call + * @param refresh a function to trigger an asynchronous data refresh + */ + renderErr: PropTypes.func.isRequired + }; + + static defaultProps = { + repeatOptions: { + ignoreCancel: true, + repeatInterval: 0 + } + }; + + state = { + apiResult: null + }; + + loadData = () => { + // Only repeat if there is a non-zero repeat interval + const cancelOptions = { + repeat: this.props.repeatOptions.interval !== 0 ? this.loadData : null, + interval: this.props.repeatOptions.interval + }; + + this.dataHandle = makeCancelable(this.props.apiCall(), cancelOptions); + + if (this.props.repeatOptions.ignoreCancel) { + this.dataHandle.promise = this.dataHandle.promise.catch(ignoreCancel); + } + + this.dataHandle.promise + .then(data => { + this.setState({ + apiResult: Result.Ok(data) + }); + }) + .catch(error => { + this.setState({ + apiResult: Result.Err(error) + }); + }); + }; + + componentDidMount() { + this.loadData(); + } + + componentWillUnmount() { + this.dataHandle.cancel(); + } + + render() { + if (!this.state.apiResult) { + return this.props.renderInitial(); + } + + if (Result.isOk(this.state.apiResult)) { + return this.props.renderOk(this.state.apiResult.value, this.loadData); + } else { + return this.props.renderErr(this.state.apiResult.value, this.loadData); + } + } +} diff --git a/src/components/dashboard/ClientsGraph.js b/src/components/dashboard/ClientsGraph.js index e43f219..7c0e164 100644 --- a/src/components/dashboard/ClientsGraph.js +++ b/src/components/dashboard/ClientsGraph.js @@ -12,7 +12,8 @@ import React, { Component } from "react"; import ReactDOM from "react-dom"; import { Line } from "react-chartjs-2"; import { translate } from "react-i18next"; -import { padNumber, api, makeCancelable, ignoreCancel } from "../../utils"; +import { padNumber, makeCancelable, ignoreCancel } from "../../util"; +import api from "../../util/api"; import ChartTooltip from "./ChartTooltip"; class ClientsGraph extends Component { diff --git a/src/components/dashboard/ForwardDestinationsChart.js b/src/components/dashboard/ForwardDestinationsChart.js index 8c485f3..167e370 100644 --- a/src/components/dashboard/ForwardDestinationsChart.js +++ b/src/components/dashboard/ForwardDestinationsChart.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import GenericDoughnutChart from "./GenericDoughnutChart"; const ForwardDestinationsChart = ({ t }) => ( diff --git a/src/components/dashboard/GenericDoughnutChart.js b/src/components/dashboard/GenericDoughnutChart.js index 8c3206c..bd8ba39 100644 --- a/src/components/dashboard/GenericDoughnutChart.js +++ b/src/components/dashboard/GenericDoughnutChart.js @@ -11,7 +11,7 @@ import React, { Component } from "react"; import { Doughnut } from "react-chartjs-2"; import PropTypes from "prop-types"; -import { makeCancelable, ignoreCancel } from "../../utils"; +import { makeCancelable, ignoreCancel } from "../../util"; class GenericDoughnutChart extends Component { state = { diff --git a/src/components/dashboard/QueriesGraph.js b/src/components/dashboard/QueriesGraph.js index f61057f..30146f8 100644 --- a/src/components/dashboard/QueriesGraph.js +++ b/src/components/dashboard/QueriesGraph.js @@ -11,7 +11,8 @@ import React, { Component } from "react"; import { Line } from "react-chartjs-2"; import { translate } from "react-i18next"; -import { padNumber, api, makeCancelable, ignoreCancel } from "../../utils"; +import { padNumber, makeCancelable, ignoreCancel } from "../../util"; +import api from "../../util/api"; class QueriesGraph extends Component { state = { diff --git a/src/components/dashboard/QueryTypesChart.js b/src/components/dashboard/QueryTypesChart.js index 130ec54..1bc58b8 100644 --- a/src/components/dashboard/QueryTypesChart.js +++ b/src/components/dashboard/QueryTypesChart.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import GenericDoughnutChart from "./GenericDoughnutChart"; const QueryTypesChart = ({ t }) => ( diff --git a/src/components/dashboard/SummaryStats.js b/src/components/dashboard/SummaryStats.js index 001773a..f2e5f32 100644 --- a/src/components/dashboard/SummaryStats.js +++ b/src/components/dashboard/SummaryStats.js @@ -9,57 +9,21 @@ * Please see LICENSE file for your rights under this license. */ import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { WithAPIData } from "../common/WithAPIData"; +import api from "../../util/api"; class SummaryStats extends Component { - state = { - blockedQueries: "---", - totalQueries: "---", - percentBlocked: "---", - gravityDomains: "---", - uniqueClients: "---" + static propTypes = { + totalQueries: PropTypes.string.isRequired, + blockedQueries: PropTypes.string.isRequired, + percentBlocked: PropTypes.string.isRequired, + gravityDomains: PropTypes.string.isRequired, + uniqueClients: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + .isRequired }; - constructor(props) { - super(props); - this.updateStats = this.updateStats.bind(this); - } - - updateStats() { - this.updateHandler = makeCancelable(api.getSummary(), { - repeat: this.updateStats, - interval: 5000 - }); - this.updateHandler.promise - .then(res => { - this.setState({ - blockedQueries: res.blocked_queries.toLocaleString(), - totalQueries: res.total_queries.toLocaleString(), - percentBlocked: res.percent_blocked.toFixed(2).toLocaleString() + "%", - gravityDomains: res.domains_blocked.toLocaleString(), - uniqueClients: res.unique_clients - }); - }) - .catch(ignoreCancel) - .catch(() => { - this.setState({ - totalQueries: "Lost", - blockedQueries: "Connection", - percentBlocked: "To", - gravityDomains: "API" - }); - }); - } - - componentDidMount() { - this.updateStats(); - } - - componentWillUnmount() { - this.updateHandler.cancel(); - } - render() { const { t } = this.props; @@ -73,10 +37,10 @@ class SummaryStats extends Component {
-

{this.state.totalQueries}

+

{this.props.totalQueries}

{t("Total Queries ({{count}} clients)", { - count: this.state.uniqueClients + count: this.props.uniqueClients })}

@@ -90,7 +54,7 @@ class SummaryStats extends Component {
-

{this.state.blockedQueries}

+

{this.props.blockedQueries}

{t("Queries Blocked")}

@@ -103,7 +67,7 @@ class SummaryStats extends Component {
-

{this.state.percentBlocked}

+

{this.props.percentBlocked}

{t("Percent Blocked")}

@@ -116,7 +80,7 @@ class SummaryStats extends Component {
-

{this.state.gravityDomains}

+

{this.props.gravityDomains}

{t("Domains On Blocklist")}

@@ -126,4 +90,55 @@ class SummaryStats extends Component { } } -export default translate(["common", "dashboard"])(SummaryStats); +/** + * Transform the API data into props for the component + * + * @param data the API data + * @returns {*} the transformed props + */ +export const transformData = data => ({ + totalQueries: data.total_queries.toLocaleString(), + blockedQueries: data.blocked_queries.toLocaleString(), + percentBlocked: data.percent_blocked.toFixed(2).toLocaleString() + "%", + gravityDomains: data.domains_blocked.toLocaleString(), + uniqueClients: data.unique_clients +}); + +/** + * The props the summary stats should use when it fails to get the API data + * (it does not need the error object) + * + * @returns {*} the error props + */ +export const errorState = () => ({ + totalQueries: "Lost", + blockedQueries: "Connection", + percentBlocked: "To", + gravityDomains: "API", + uniqueClients: "" +}); + +export const TranslatedSummaryStats = translate(["common", "dashboard"])( + SummaryStats +); + +export default props => ( + ( + + )} + renderOk={data => ( + + )} + renderErr={() => } + /> +); diff --git a/src/components/dashboard/SummaryStats.test.js b/src/components/dashboard/SummaryStats.test.js index e939a93..40495c2 100644 --- a/src/components/dashboard/SummaryStats.test.js +++ b/src/components/dashboard/SummaryStats.test.js @@ -11,7 +11,11 @@ import React from "react"; import { shallow } from "enzyme"; import fetchMock from "fetch-mock"; -import SummaryStats from "./SummaryStats"; +import { + TranslatedSummaryStats, + transformData, + errorState +} from "./SummaryStats"; const endpoint = "/admin/api/stats/summary"; const fakeData = { @@ -30,7 +34,9 @@ const fakeData = { it("displays summary stats correctly", async () => { fetchMock.mock(endpoint, fakeData); - const wrapper = shallow(); + const wrapper = shallow( + + ); await tick(); wrapper.update(); @@ -38,7 +44,6 @@ it("displays summary stats correctly", async () => { expect(wrapper.childAt(0).find("h3")).toHaveText( fakeData.total_queries.toLocaleString() ); - expect(wrapper.state().uniqueClients).toEqual(fakeData.unique_clients); expect(wrapper.childAt(1).find("h3")).toHaveText( fakeData.blocked_queries.toLocaleString() ); @@ -53,7 +58,7 @@ it("displays summary stats correctly", async () => { it("displays an error message on error", async () => { fetchMock.mock(endpoint, { error: {} }); - const wrapper = shallow(); + const wrapper = shallow(); await tick(); wrapper.update(); diff --git a/src/components/dashboard/TopBlocked.js b/src/components/dashboard/TopBlocked.js index 8d9902a..85e8e59 100644 --- a/src/components/dashboard/TopBlocked.js +++ b/src/components/dashboard/TopBlocked.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import TopTable from "./TopTable"; const TopBlocked = ({ t, ...props }) => ( diff --git a/src/components/dashboard/TopClients.js b/src/components/dashboard/TopClients.js index a1c342d..e6a3b2c 100644 --- a/src/components/dashboard/TopClients.js +++ b/src/components/dashboard/TopClients.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import TopTable from "./TopTable"; const TopClients = ({ t, ...props }) => ( diff --git a/src/components/dashboard/TopDomains.js b/src/components/dashboard/TopDomains.js index 92d6072..e2a0a7e 100644 --- a/src/components/dashboard/TopDomains.js +++ b/src/components/dashboard/TopDomains.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import TopTable from "./TopTable"; const TopDomains = ({ t, ...props }) => ( diff --git a/src/components/dashboard/TopTable.js b/src/components/dashboard/TopTable.js index f117698..671bdec 100644 --- a/src/components/dashboard/TopTable.js +++ b/src/components/dashboard/TopTable.js @@ -10,7 +10,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import { ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; class TopTable extends Component { state = { diff --git a/src/components/list/DomainInput.js b/src/components/list/DomainInput.js index cbe1d4d..c5c3003 100644 --- a/src/components/list/DomainInput.js +++ b/src/components/list/DomainInput.js @@ -11,7 +11,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; class DomainInput extends Component { state = { diff --git a/src/components/list/DomainInput.test.js b/src/components/list/DomainInput.test.js index 052c914..a0f17b3 100644 --- a/src/components/list/DomainInput.test.js +++ b/src/components/list/DomainInput.test.js @@ -11,7 +11,7 @@ import React from "react"; import { shallow } from "enzyme"; import DomainInput from "./DomainInput"; -import { api } from "../../utils"; +import api from "../../util/api"; import { isValidDomain } from "../../validate"; it("has a placeholder", () => { diff --git a/src/components/list/DomainList.js b/src/components/list/DomainList.js index ccf6fc6..ba98db9 100644 --- a/src/components/list/DomainList.js +++ b/src/components/list/DomainList.js @@ -11,7 +11,8 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; class DomainList extends Component { onRemove(domain) { diff --git a/src/components/list/DomainList.test.js b/src/components/list/DomainList.test.js index ab04e30..77452ac 100644 --- a/src/components/list/DomainList.test.js +++ b/src/components/list/DomainList.test.js @@ -11,7 +11,7 @@ import React from "react"; import { shallow } from "enzyme"; import DomainList from "./DomainList"; -import { api } from "../../utils"; +import api from "../../util/api"; const domains = ["domain1.com", "domain2.com", "domain3.com"]; diff --git a/src/components/list/ListPage.js b/src/components/list/ListPage.js index 8c99ca0..7e73667 100644 --- a/src/components/list/ListPage.js +++ b/src/components/list/ListPage.js @@ -14,7 +14,7 @@ import { translate } from "react-i18next"; import DomainInput from "./DomainInput"; import Alert from "../common/Alert"; import DomainList from "./DomainList"; -import { ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; class ListPage extends Component { state = { diff --git a/src/components/log/QueryLog.js b/src/components/log/QueryLog.js index 20ef6c3..4bd9da2 100644 --- a/src/components/log/QueryLog.js +++ b/src/components/log/QueryLog.js @@ -11,7 +11,8 @@ import React, { Component } from "react"; import ReactTable from "react-table"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable, padNumber } from "../../utils"; +import { ignoreCancel, makeCancelable, padNumber } from "../../util"; +import api from "../../util/api"; import "react-table/react-table.css"; class QueryLog extends Component { diff --git a/src/components/settings/DHCPInfo.js b/src/components/settings/DHCPInfo.js index c5a345c..9867a41 100644 --- a/src/components/settings/DHCPInfo.js +++ b/src/components/settings/DHCPInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; import { Button, Col, diff --git a/src/components/settings/DNSInfo.js b/src/components/settings/DNSInfo.js index 5ecabf6..7e7c278 100644 --- a/src/components/settings/DNSInfo.js +++ b/src/components/settings/DNSInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; class DNSInfo extends Component { state = { diff --git a/src/components/settings/FTLInfo.js b/src/components/settings/FTLInfo.js index 71f4bac..6a4d1ee 100644 --- a/src/components/settings/FTLInfo.js +++ b/src/components/settings/FTLInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; class FTLInfo extends Component { state = { diff --git a/src/components/settings/NetworkInfo.js b/src/components/settings/NetworkInfo.js index 0cd94af..4f1c3e8 100644 --- a/src/components/settings/NetworkInfo.js +++ b/src/components/settings/NetworkInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; import { Col, Form, FormGroup, Input, Label } from "reactstrap"; class NetworkInfo extends Component { diff --git a/src/components/settings/VersionInfo.js b/src/components/settings/VersionInfo.js index ced1ce5..a8b4d59 100644 --- a/src/components/settings/VersionInfo.js +++ b/src/components/settings/VersionInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; import VersionCard from "./VersionCard"; class VersionInfo extends Component { diff --git a/src/containers/Full.js b/src/containers/Full.js index ead2fcf..b0f011a 100644 --- a/src/containers/Full.js +++ b/src/containers/Full.js @@ -13,7 +13,7 @@ import { Switch, Route, Redirect } from "react-router-dom"; import Header, { mobileSidebarHide } from "../components/common/Header"; import Sidebar from "../components/common/Sidebar"; import Footer from "../components/common/Footer"; -import { api } from "../utils"; +import api from "../util/api"; import { nav } from "../routes"; export default props => ( diff --git a/src/index.js b/src/index.js index 78fd6b3..828ba91 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,7 @@ import "font-awesome/css/font-awesome.min.css"; import "simple-line-icons/css/simple-line-icons.css"; import "./scss/style.css"; import Full from "./containers/Full"; -import { api } from "./utils"; +import api from "./util/api"; import { setupI18n } from "./i18n"; // Before rendering anything, check if there is a session cookie. diff --git a/src/setupTests.js b/src/setupTests.js index 6dba3f4..087ea04 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -11,7 +11,7 @@ import { configure } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; import "jest-enzyme"; -import { api } from "./utils"; +import api from "./util/api"; import fetchMock from "fetch-mock"; // Setup enzyme diff --git a/src/util/api.js b/src/util/api.js new file mode 100644 index 0000000..ec3358b --- /dev/null +++ b/src/util/api.js @@ -0,0 +1,85 @@ +import http from "./http"; + +export default { + loggedIn: false, + authenticate(key) { + return http.get("auth", { + headers: new Headers({ "X-Pi-hole-Authenticate": key }) + }); + }, + logout() { + return http.delete("auth"); + }, + getSummary() { + return http.get("stats/summary"); + }, + getHistoryGraph() { + return http.get("stats/overTime/history"); + }, + getClientsGraph() { + return http.get("stats/overTime/clients"); + }, + getQueryTypes() { + return http.get("stats/query_types"); + }, + getForwardDestinations() { + return http.get("stats/forward_destinations"); + }, + getTopDomains() { + return http.get("stats/top_domains"); + }, + getTopBlocked() { + return http.get("stats/top_blocked"); + }, + getTopClients() { + return http.get("stats/top_clients"); + }, + getHistory() { + return http.get("stats/history"); + }, + getWhitelist() { + return http.get("dns/whitelist"); + }, + getBlacklist() { + return http.get("dns/blacklist"); + }, + getRegexlist() { + return http.get("dns/regexlist"); + }, + addWhitelist(domain) { + return http.post("dns/whitelist", { domain: domain }); + }, + addBlacklist(domain) { + return http.post("dns/blacklist", { domain: domain }); + }, + addRegexlist(domain) { + return http.post("dns/regexlist", { domain: domain }); + }, + removeWhitelist(domain) { + return http.delete("dns/whitelist/" + domain); + }, + removeBlacklist(domain) { + return http.delete("dns/blacklist/" + domain); + }, + removeRegexlist(domain) { + return http.delete("dns/regexlist/" + encodeURIComponent(domain)); + }, + getStatus() { + return http.get("dns/status"); + }, + getNetworkInfo() { + return http.get("settings/network"); + }, + getVersion() { + return http.get("version"); + }, + getFTLdb() { + return http.get("settings/ftldb"); + }, + getDNSInfo() { + return http.get("settings/dns"); + }, + getDHCPInfo() { + return http.get("settings/dhcp"); + } +}; diff --git a/src/util/http.js b/src/util/http.js new file mode 100644 index 0000000..6740cc9 --- /dev/null +++ b/src/util/http.js @@ -0,0 +1,172 @@ +/* Pi-hole: A black hole for Internet advertisements +* (c) 2017 Pi-hole, LLC (https://pi-hole.net) +* Network-wide ad blocking via your own hardware. +* +* Web Interface +* Basic HTTP functions for accessing API endpoints +* +* This file is copyright under the latest version of the EUPL. +* Please see LICENSE file for your rights under this license. */ + +import api from "./api"; +import config from "../config"; + +/** + * A group of HTTP functions. Each function parses the response checks for + * errors + */ +export default { + /** + * Perform a GET request + * + * @param url the URL to access + * @param options optional fetch configuration + * @returns {Promise<* | never>} a promise with the data or error returned + */ + get(url, options = {}) { + return fetch(urlFor(url), { + credentials: credentialType(), + ...options + }) + .then(checkIfLoggedOut) + .then(convertJSON) + .catch(convertJSON) + .then(checkForErrors); + }, + + /** + * Perform a POST request + * + * @param url the URL to access + * @param data the data to send + * @returns {Promise<* | never>} a promise with the data or error returned + */ + post(url, data) { + return fetch(urlFor(url), { + method: "POST", + body: JSON.stringify(data), + headers: new Headers({ "Content-Type": "application/json" }), + credentials: credentialType() + }) + .then(checkIfLoggedOut) + .then(convertJSON) + .catch(convertJSON) + .then(checkForErrors); + }, + + /** + * Perform a PUT request + * + * @param url the URL to access + * @param data the data to send + * @returns {Promise<* | never>} a promise with the data or error returned + */ + put(url, data) { + return fetch(urlFor(url), { + method: "PUT", + body: JSON.stringify(data), + headers: new Headers({ "Content-Type": "application/json" }), + credentials: credentialType() + }) + .then(checkIfLoggedOut) + .then(convertJSON) + .catch(convertJSON) + .then(checkForErrors); + }, + + /** + * Perform a DELETE request + * + * @param url the URL to access + * @returns {Promise<* | never>} a promise with the data or error returned + */ + delete(url) { + return fetch(urlFor(url), { + method: "DELETE", + credentials: credentialType() + }) + .then(checkIfLoggedOut) + .then(convertJSON) + .catch(convertJSON) + .then(checkForErrors); + } +}; + +/** + * If the user is logged in, check if the user's session has lapsed. + * If so, log them out and refresh the page. + * + * @param response the Response from fetch + * @return {Promise} if logged in, the response, otherwise a canceled promise + */ +const checkIfLoggedOut = response => { + if (api.loggedIn && response.status === 401) { + // Clear the user's old session and refresh the page + document.cookie = + "user_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; + window.location.reload(); + return Promise.reject({ isCanceled: true }); + } + + return Promise.resolve(response); +}; + +/** + * If the input is a Response, return a promise for parsing the JSON. + * If the input is an Error, return a rejecting promise with error. + * If the request was canceled, return a rejecting promise with cancel object. + * + * @param data a Response or Error + * @returns {*} a promise with the parsed JSON, or the error + */ +const convertJSON = data => { + if (data.isCanceled || data instanceof Error) { + return Promise.reject(data); + } + + return data.json(); +}; + +/** + * Check for an error returned by the API + * + * @param data the parsed JSON body of the response + * @returns {*} a resolving promise with the data if no error, otherwise a + * rejecting promise with the error + */ +const checkForErrors = data => { + if (data.error) { + return Promise.reject(data.error); + } + + return Promise.resolve(data); +}; + +/** + * Get the URL for an endpoint + * + * @param endpoint the endpoint + * @returns {string} the URL for the endpoint + */ +const urlFor = endpoint => { + let apiLocation; + + if (config.fakeAPI) { + apiLocation = process.env.PUBLIC_URL + "/fakeAPI"; + } else { + apiLocation = "/admin/api"; + } + + return apiLocation + "/" + endpoint; +}; + +/** + * Get the credential type for requests + * + * @returns {string} the credential type + */ +const credentialType = () => { + // Development API requests use a different origin (pi.hole) since it is running off of the developer's machine. + // Therefore, allow credentials to be used across origins when in development mode. + return config.developmentMode ? "include" : "same-origin"; +}; diff --git a/src/util/index.js b/src/util/index.js new file mode 100644 index 0000000..b7525bc --- /dev/null +++ b/src/util/index.js @@ -0,0 +1,104 @@ +/* Pi-hole: A black hole for Internet advertisements +* (c) 2017 Pi-hole, LLC (https://pi-hole.net) +* Network-wide ad blocking via your own hardware. +* +* Web Interface +* Various utilities +* +* This file is copyright under the latest version of the EUPL. +* Please see LICENSE file for your rights under this license. */ + +/** + * Pad a two digit number + * + * @param num the number + * @returns {string} a padding number string + */ +export const padNumber = num => { + return ("00" + num).substr(-2, 2); +}; + +/** + * Make a promise cancelable and repeatable + * + * @param promise the promise + * @param repeat the function to call to repeat the promise + * @param interval the amount of time to wait until repeating + * @returns {{promise: Promise, cancel(): void}} a handle on the cancelable + * promise + */ +export const makeCancelable = ( + promise, + { repeat = null, interval = 0 } = {} +) => { + let hasCanceled = false; + let repeatId = null; + + const handle = (resolve, reject, val, isError) => { + if (hasCanceled) reject({ isCanceled: true }); + else { + if (isError) reject(val); + else resolve(val); + + if (repeat) repeatId = setTimeout(repeat, interval); + } + }; + + const wrappedPromise = new Promise((resolve, reject) => { + promise.then( + val => handle(resolve, reject, val, false), + error => handle(resolve, reject, error, true) + ); + }); + + return { + promise: wrappedPromise, + cancel() { + clearTimeout(repeatId); + hasCanceled = true; + } + }; +}; + +/** + * Ignore canceled promises (pass into a promise's catch function) + * + * @param err the error from catching the promise + */ +export const ignoreCancel = err => { + if (!err.isCanceled) throw err; +}; + +/** + * A result type, similar to Rust's Result. + * A result can either be Ok or Err + */ +export const Result = { + /** + * Create a new Ok Result + * + * @param value the value of the result + */ + Ok: value => ({ type: "Ok", value }), + + /** + * Create a new Err Result + * + * @param value the value of the result (error) + */ + Err: value => ({ type: "Err", value }), + + /** + * Check if the result is Ok + * @param result the result + * @returns {boolean} if it is an Ok result + */ + isOk: result => result.type === "Ok", + + /** + * Check if the result is Err + * @param result the result + * @returns {boolean} if it is an Err result + */ + isErr: result => result.type === "Err" +}; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index d53e52c..0000000 --- a/src/utils.js +++ /dev/null @@ -1,205 +0,0 @@ -/* Pi-hole: A black hole for Internet advertisements -* (c) 2017 Pi-hole, LLC (https://pi-hole.net) -* Network-wide ad blocking via your own hardware. -* -* Web Interface -* Various utilities including the API -* -* This file is copyright under the latest version of the EUPL. -* Please see LICENSE file for your rights under this license. */ - -import config from "./config"; - -export const padNumber = num => { - return ("00" + num).substr(-2, 2); -}; - -export const makeCancelable = ( - promise, - { repeat = null, interval = 0 } = {} -) => { - let hasCanceled = false; - let repeatId = null; - - const handle = (resolve, reject, val, isError) => { - if (hasCanceled) reject({ isCanceled: true }); - else { - if (isError) reject(val); - else resolve(val); - - if (repeat) repeatId = setTimeout(repeat, interval); - } - }; - - const wrappedPromise = new Promise((resolve, reject) => { - promise.then( - val => handle(resolve, reject, val, false), - error => handle(resolve, reject, error, true) - ); - }); - - return { - promise: wrappedPromise, - cancel() { - clearTimeout(repeatId); - hasCanceled = true; - } - }; -}; - -export const ignoreCancel = err => { - if (!err.isCanceled) throw err; -}; - -export const api = { - loggedIn: false, - authenticate(key) { - return fetch(api.urlFor("auth"), { - headers: new Headers({ "X-Pi-hole-Authenticate": key }), - credentials: this.credentialType() - }) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - logout() { - return api.delete("auth"); - }, - getSummary() { - return api.get("stats/summary"); - }, - getHistoryGraph() { - return api.get("stats/overTime/history"); - }, - getClientsGraph() { - return api.get("stats/overTime/clients"); - }, - getQueryTypes() { - return api.get("stats/query_types"); - }, - getForwardDestinations() { - return api.get("stats/forward_destinations"); - }, - getTopDomains() { - return api.get("stats/top_domains"); - }, - getTopBlocked() { - return api.get("stats/top_blocked"); - }, - getTopClients() { - return api.get("stats/top_clients"); - }, - getHistory() { - return api.get("stats/history"); - }, - getWhitelist() { - return api.get("dns/whitelist"); - }, - getBlacklist() { - return api.get("dns/blacklist"); - }, - getRegexlist() { - return api.get("dns/regexlist"); - }, - addWhitelist(domain) { - return api.post("dns/whitelist", { domain: domain }); - }, - addBlacklist(domain) { - return api.post("dns/blacklist", { domain: domain }); - }, - addRegexlist(domain) { - return api.post("dns/regexlist", { domain: domain }); - }, - removeWhitelist(domain) { - return api.delete("dns/whitelist/" + domain); - }, - removeBlacklist(domain) { - return api.delete("dns/blacklist/" + domain); - }, - removeRegexlist(domain) { - return api.delete("dns/regexlist/" + encodeURIComponent(domain)); - }, - getStatus() { - return api.get("dns/status"); - }, - get(url) { - return fetch(api.urlFor(url), { - credentials: this.credentialType() - }) - .then(api.checkIfLoggedOut) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - post(url, data) { - return fetch(api.urlFor(url), { - method: "POST", - body: JSON.stringify(data), - headers: new Headers({ "Content-Type": "application/json" }), - credentials: this.credentialType() - }) - .then(api.checkIfLoggedOut) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - delete(url) { - return fetch(api.urlFor(url), { - method: "DELETE", - credentials: this.credentialType() - }) - .then(api.checkIfLoggedOut) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - getNetworkInfo() { - return api.get("settings/network"); - }, - getVersion() { - return api.get("version"); - }, - getFTLdb() { - return api.get("settings/ftldb"); - }, - getDNSInfo() { - return api.get("settings/dns"); - }, - getDHCPInfo() { - return api.get("settings/dhcp"); - }, - /** - * If the user is logged in, check if the user's session has lapsed. - * If so, log them out and refresh the page. - */ - checkIfLoggedOut(response) { - if (api.loggedIn && response.status === 401) { - // Clear the user's old session and refresh the page - document.cookie = - "user_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; - window.location.reload(); - return Promise.reject({ isCanceled: true }); - } - - return Promise.resolve(response); - }, - async convertJSON(data) { - if (!data.ok) return Promise.reject({ data, json: await data.json() }); - else return data.json(); - }, - checkForErrors(data) { - if (data.error) { - return Promise.reject(data.error); - } - return Promise.resolve(data); - }, - urlFor(endpoint) { - let apiLocation; - - if (config.fakeAPI) apiLocation = process.env.PUBLIC_URL + "/fakeAPI"; - else apiLocation = "/admin/api"; - - return apiLocation + "/" + endpoint; - }, - credentialType() { - // Development API requests use a different origin (pi.hole) since it is running off of the developer's machine. - // Therefore, allow credentials to be used across origins when in development mode. - return config.developmentMode ? "include" : "same-origin"; - } -}; diff --git a/src/utils.test.js b/src/utils.test.js index 4d2355c..bb83461 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -8,7 +8,7 @@ * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ -import { api } from "./utils"; +import api from "./util/api"; // This is a dumb test used to set up the next test, // which checks that the logged in state is reset before each test diff --git a/src/views/Blacklist.js b/src/views/Blacklist.js index ede888d..4c3e1c5 100644 --- a/src/views/Blacklist.js +++ b/src/views/Blacklist.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../utils"; +import api from "../util/api"; import ListPage from "../components/list/ListPage"; import { isValidDomain } from "../validate"; diff --git a/src/views/Dashboard.js b/src/views/Dashboard.js index 88f1150..6357915 100644 --- a/src/views/Dashboard.js +++ b/src/views/Dashboard.js @@ -17,7 +17,7 @@ import ForwardDestinationsChart from "../components/dashboard/ForwardDestination import TopDomains from "../components/dashboard/TopDomains"; import TopBlocked from "../components/dashboard/TopBlocked"; import TopClients from "../components/dashboard/TopClients"; -import { api } from "../utils"; +import api from "../util/api"; export default () => (
diff --git a/src/views/Login.js b/src/views/Login.js index aa055f8..d65174f 100644 --- a/src/views/Login.js +++ b/src/views/Login.js @@ -11,7 +11,7 @@ import React, { Component, Fragment } from "react"; import { Redirect } from "react-router-dom"; import sha from "sha.js"; -import { api } from "../utils"; +import api from "../util/api"; import logo from "../img/logo.svg"; import { routes } from "../routes"; import ForgotPassword from "../components/login/ForgotPassword"; diff --git a/src/views/Logout.js b/src/views/Logout.js index 8760779..c1f6bab 100644 --- a/src/views/Logout.js +++ b/src/views/Logout.js @@ -10,7 +10,7 @@ import React, { Component } from "react"; import { Redirect } from "react-router-dom"; -import { api } from "../utils"; +import api from "../util/api"; import config from "../config"; export default class Logout extends Component { diff --git a/src/views/Regexlist.js b/src/views/Regexlist.js index e453792..08e39a5 100644 --- a/src/views/Regexlist.js +++ b/src/views/Regexlist.js @@ -11,7 +11,7 @@ import React from "react"; import { translate } from "react-i18next"; import ListPage from "../components/list/ListPage"; -import { api } from "../utils"; +import api from "../util/api"; import { isValidRegex } from "../validate"; const Regexlist = props => { diff --git a/src/views/Whitelist.js b/src/views/Whitelist.js index 5f641c0..73e7aad 100644 --- a/src/views/Whitelist.js +++ b/src/views/Whitelist.js @@ -11,7 +11,7 @@ import React from "react"; import { translate } from "react-i18next"; import ListPage from "../components/list/ListPage"; -import { api } from "../utils"; +import api from "../util/api"; import { isValidDomain } from "../validate"; const Whitelist = props => {