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 => {