Merge branch 'development' into l10n_development

This commit is contained in:
Mark Drobnak
2019-05-26 20:20:47 -04:00
committed by GitHub
41 changed files with 7246 additions and 7668 deletions
+5706 -7223
View File
File diff suppressed because it is too large Load Diff
+16 -18
View File
@@ -1,24 +1,23 @@
{
"name": "pi-hole_web",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"homepage": ".",
"devDependencies": {
"@types/chart.js": "^2.7.42",
"@types/enzyme": "^3.1.15",
"@types/enzyme-adapter-react-16": "^1.0.3",
"@types/enzyme": "^3.9.3",
"@types/enzyme-adapter-react-16": "^1.0.5",
"@types/fetch-mock": "^7.2.1",
"@types/i18next-browser-languagedetector": "^2.0.1",
"@types/i18next-xhr-backend": "^1.4.1",
"@types/jest": "^23.3.13",
"@types/jest": "^24.0.13",
"@types/lodash.debounce": "^4.0.4",
"@types/node": "^10.12.18",
"@types/react": "^16.8.8",
"@types/node": "^12.0.2",
"@types/react": "^16.8.18",
"@types/react-bootstrap-daterangepicker": "0.0.26",
"@types/react-bootstrap-typeahead": "^3.4.0",
"@types/react-dom": "^16.8.2",
"@types/react-router-dom": "^4.3.1",
"@types/react-table": "^6.7.21",
"@types/react-router-dom": "^4.3.3",
"@types/react-table": "^6.8.1",
"@types/reactstrap": "^6.4.4",
"@types/sha.js": "^2.4.0",
"enzyme": "^3.8.0",
@@ -33,32 +32,31 @@
"node-sass-chokidar": "^1.3.4",
"npm-run-all": "^4.1.5",
"prettier": "^1.15.3",
"react-scripts": "^2.1.8",
"react-test-renderer": "^16.7.0",
"react-scripts": "^3.0.1",
"react-test-renderer": "^16.8.6",
"typescript": "^3.2.4"
},
"dependencies": {
"@coreui/coreui": "^2.1.6",
"@coreui/coreui-plugin-chartjs-custom-tooltips": "^1.2.0",
"@coreui/coreui": "^2.1.9",
"@coreui/icons": "0.3.0",
"@coreui/react": "^2.1.3",
"@fortawesome/fontawesome-free": "^5.6.3",
"bootstrap": "^4.2.1",
"bootstrap": "^4.3.1",
"bootstrap-daterangepicker": "^3.0.3",
"chart.js": "^2.7.3",
"i18next": "^13.1.4",
"i18next-browser-languagedetector": "^2.2.4",
"i18next-xhr-backend": "^1.5.1",
"i18next-xhr-backend": "^2.0.1",
"ionicons": "^4.5.1",
"lodash.debounce": "^4.0.8",
"moment": "^2.23.0",
"prop-types": "^15.6.2",
"react": "^16.8.4",
"react": "^16.8.6",
"react-bootstrap-daterangepicker": "^4.1.0",
"react-bootstrap-typeahead": "^3.2.4",
"react-chartjs-2": "^2.7.4",
"react-dom": "^16.8.4",
"react-i18next": "^9.0.3",
"react-dom": "^16.8.6",
"react-i18next": "^9.0.7",
"react-router-dom": "^4.3.1",
"react-table": "^6.8.6",
"react-transition-group": "^2.5.3",
+2 -1
View File
@@ -26,5 +26,6 @@
"Processing...": "Processing...",
"Successfully saved settings": "Successfully saved settings",
"Detected custom upstream server": "Detected custom upstream server",
"DNS Options": "DNS Options"
"DNS Options": "DNS Options",
"Rapid Commit": "Rapid Commit"
}
+2 -1
View File
@@ -343,7 +343,8 @@ function getDHCPInfo() {
.word()
.toLowerCase()
.split(" ", 2)[0],
ipv6_support: faker.random.boolean()
ipv6_support: faker.random.boolean(),
rapid_commit: faker.random.boolean()
};
}
+13 -9
View File
@@ -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 React, { FunctionComponent } from "react";
import React from "react";
export type AlertType = "info" | "success" | "danger";
@@ -16,23 +16,27 @@ export interface AlertProps {
type: AlertType;
onClick: () => void;
message: string;
dismissible: boolean;
}
const Alert: FunctionComponent<AlertProps> = (props: AlertProps) => {
const Alert = (props: AlertProps) => {
const dismissClass = props.dismissible ? "alert-dismissible" : "";
return (
<div
className={"alert alert-" + props.type + " alert-dismissible fade show"}
>
<button type="button" className="close" onClick={props.onClick}>
&times;
</button>
<div className={`alert alert-${props.type} ${dismissClass} fade show`}>
{props.dismissible ? (
<button type="button" className="close" onClick={props.onClick}>
&times;
</button>
) : null}
{props.message}
</div>
);
};
Alert.defaultProps = {
onClick: () => {}
onClick: () => {},
dismissible: true
};
export default Alert;
+5 -2
View File
@@ -15,7 +15,10 @@ import { WithNamespaces, withNamespaces } from "react-i18next";
import NavButton from "./NavButton";
import NavDropdown from "./NavDropdown";
import { StatusContext } from "./context/StatusContext";
import { CancelablePromise, makeCancelable } from "../../util";
import {
CancelablePromise,
makeCancelable
} from "../../util/CancelablePromise";
import api from "../../util/api";
import {
Button,
@@ -49,7 +52,7 @@ class EnableDisable extends Component<EnableDisableProps, EnableDisableState> {
customMultiplier: 60
};
private updateHandler: CancelablePromise<ApiStatus> | undefined;
private updateHandler: CancelablePromise<ApiSuccessResponse> | undefined;
/**
* Convert a status action into a status. ex. "enable" -> "enabled"
+5 -1
View File
@@ -9,7 +9,11 @@
* Please see LICENSE file for your rights under this license. */
import { Component, ReactNode } from "react";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import {
CancelablePromise,
ignoreCancel,
makeCancelable
} from "../../util/CancelablePromise";
import { Err, Ok, Result } from "../../util/result";
export interface WithAPIDataProps<T> {
+1 -1
View File
@@ -12,7 +12,7 @@ import React, { Component, RefObject } from "react";
import ReactDOM from "react-dom";
import { Line } from "react-chartjs-2";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { getIntervalForRange, padNumber } from "../../util";
import { getIntervalForRange, padNumber } from "../../util/graphUtils";
import api from "../../util/api";
import ChartTooltip from "./ChartTooltip";
import { WithAPIData } from "../common/WithAPIData";
+1 -1
View File
@@ -10,7 +10,7 @@
import React, { Component } from "react";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { getIntervalForRange, padNumber } from "../../util";
import { getIntervalForRange, padNumber } from "../../util/graphUtils";
import api from "../../util/api";
import { WithAPIData } from "../common/WithAPIData";
import { ChartData, ChartOptions, TimeUnit } from "chart.js";
+6 -3
View File
@@ -12,6 +12,7 @@ import React from "react";
import { WithNamespaces, withNamespaces } from "react-i18next";
import api from "../../util/api";
import { Button } from "reactstrap";
import Alert from "../common/Alert";
export interface DomainListProps extends WithNamespaces {
domains: string[];
@@ -54,9 +55,11 @@ const DomainList = ({ domains, onRemove, t }: DomainListProps) => {
body = domains.map(mapDomainsToListItems);
} else {
body = (
<div className="alert alert-info" role="alert">
{t("There are no domains in this list")}
</div>
<Alert
type="info"
message={t("There are no domains in this list")}
dismissible={false}
/>
);
}
+11 -7
View File
@@ -13,15 +13,19 @@ import { WithNamespaces, withNamespaces } from "react-i18next";
import DomainInput from "./DomainInput";
import Alert, { AlertType } from "../common/Alert";
import DomainList from "./DomainList";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import {
CancelablePromise,
ignoreCancel,
makeCancelable
} from "../../util/CancelablePromise";
export interface ListPageProps extends WithNamespaces {
title: string;
note?: {} | string;
placeholder: string;
add: (domain: string) => Promise<any | never>;
refresh: () => Promise<any | never>;
remove: (domain: string) => Promise<any | never>;
onAdd: (domain: string) => Promise<any | never>;
onRefresh: () => Promise<any | never>;
onRemove: (domain: string) => Promise<any | never>;
isValid: (domain: string) => boolean;
validationErrorMsg: string;
}
@@ -56,7 +60,7 @@ export class ListPage extends Component<ListPageProps, ListPageState> {
const prevDomains = this.state.domains.slice();
// Try to add the domain
this.addHandler = makeCancelable(this.props.add(domain));
this.addHandler = makeCancelable(this.props.onAdd(domain));
this.addHandler.promise
.then(() => {
this.onAdded(domain);
@@ -113,7 +117,7 @@ export class ListPage extends Component<ListPageProps, ListPageState> {
if (this.state.domains.includes(domain)) {
const prevDomains = this.state.domains.slice();
this.removeHandler = makeCancelable(this.props.remove(domain));
this.removeHandler = makeCancelable(this.props.onRemove(domain));
this.removeHandler.promise.catch(ignoreCancel).catch(() => {
this.onRemoveFailed(domain, prevDomains);
});
@@ -123,7 +127,7 @@ export class ListPage extends Component<ListPageProps, ListPageState> {
};
onRefresh = () => {
this.refreshHandler = makeCancelable(this.props.refresh());
this.refreshHandler = makeCancelable(this.props.onRefresh());
this.refreshHandler.promise
.then(data => {
this.setState({ domains: data });
@@ -12,6 +12,7 @@ import React from "react";
import { shallow } from "enzyme";
import DomainList from "../DomainList";
import api from "../../../util/api";
import Alert from "../../common/Alert";
const domains = ["domain1.com", "domain2.com", "domain3.com"];
@@ -26,9 +27,11 @@ it("shows a list of domains", () => {
it("shows an alert if there are no domains", () => {
const wrapper = shallow(<DomainList domains={[]} onRemove={jest.fn()} />);
expect(wrapper.find("li")).toHaveLength(0);
expect(wrapper.find("ul").childAt(0)).toHaveClassName("alert-info");
expect(wrapper).toIncludeText("There are no domains in this list");
expect(wrapper.find("li")).not.toExist();
expect(wrapper.find(Alert)).toExist();
expect(wrapper.find(Alert).props().message).toEqual(
"There are no domains in this list"
);
});
it("does not have a delete button when not logged in", () => {
@@ -36,12 +39,7 @@ it("does not have a delete button when not logged in", () => {
<DomainList domains={domains} onRemove={jest.fn()} />
);
expect(
wrapper
.find("ul")
.childAt(0)
.find("button")
).not.toExist();
expect(wrapper.find("Button")).not.toExist();
});
it("has a delete button when logged in", () => {
+179 -73
View File
@@ -15,6 +15,9 @@ import ListPage, {
ListPageProps,
ListPageState
} from "../ListPage";
import Alert from "../../common/Alert";
import DomainInput from "../DomainInput";
import DomainList from "../DomainList";
const ignoreAPI = global.ignoreAPI;
const tick = global.tick;
@@ -32,9 +35,9 @@ it("shows the title", () => {
title={title}
placeholder=""
note=""
add={ignoreAPI}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
@@ -50,15 +53,15 @@ it("shows the placeholder", () => {
title=""
placeholder={placeholder}
note=""
add={ignoreAPI}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
expect(wrapper.find("DomainInput")).toHaveProp("placeholder", placeholder);
expect(wrapper.find(DomainInput)).toHaveProp("placeholder", placeholder);
});
it("shows the note", () => {
@@ -68,9 +71,9 @@ it("shows the note", () => {
title=""
placeholder=""
note={note}
add={ignoreAPI}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
@@ -85,15 +88,105 @@ it("starts with no alerts shown", () => {
title=""
placeholder=""
note=""
add={ignoreAPI}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
expect(wrapper.find("Alert")).toHaveLength(0);
expect(wrapper.find(Alert)).not.toExist();
});
it("hides the alert if closed", () => {
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
// Show an error message
wrapper.instance().onAlreadyAdded("domain");
// Now the alert is shown
const alert = wrapper.find(Alert);
expect(alert).toExist();
// Hide the alert
alert.props().onClick();
expect(wrapper.find(Alert)).not.toExist();
});
it("cancels requests when un-mounting", async () => {
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
onAdd={ignoreAPI}
onRefresh={() => Promise.resolve(["domain"])}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
// Load the domains into state
await tick();
// Initiate requests to refresh, add, and remove domains
wrapper.instance().onRefresh();
wrapper.instance().onEnter("domain2");
wrapper.instance().onRemove("domain");
// Spy on the handlers
// Casting instance to any to access private fields
const instance = wrapper.instance() as any;
const cancelRefreshSpy = jest.spyOn(instance.refreshHandler, "cancel");
const cancelAddSpy = jest.spyOn(instance.addHandler, "cancel");
const cancelRemoveSpy = jest.spyOn(instance.removeHandler, "cancel");
// Unmount, which should cancel the requests
wrapper.unmount();
expect(cancelRefreshSpy).toHaveBeenCalled();
expect(cancelAddSpy).toHaveBeenCalled();
expect(cancelRemoveSpy).toHaveBeenCalled();
});
it("shows a validation message as an error", () => {
const validationError = "test message";
const wrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg={validationError}
/>
);
wrapper
.find(DomainInput)
.props()
.onValidationError();
const alert = wrapper.find(Alert);
expect(alert).toExist();
expect(alert.props().message).toEqual(validationError);
expect(alert.props().type).toEqual("danger");
});
it("loads domains after mounting", async () => {
@@ -103,42 +196,38 @@ it("loads domains after mounting", async () => {
title=""
placeholder=""
note=""
add={ignoreAPI}
refresh={() => Promise.resolve(domains)}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={() => Promise.resolve(domains)}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
await tick();
wrapper.update();
expect(wrapper.find("DomainList")).toHaveProp("domains", domains);
expect(wrapper.find(DomainList)).toHaveProp("domains", domains);
});
it("checks if the domain was already added", async () => {
const domains = ["domain1", "domain2.com", "domain3.net"];
const onAlreadyAdded = jest.fn();
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={ignoreAPI}
refresh={() => Promise.resolve(domains)}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={() => Promise.resolve(domains)}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
const onAlreadyAdded = jest.spyOn(wrapper.instance(), "onAlreadyAdded");
// Setup with domains (wait for promise to resolve) and mock function
// Setup with domains (wait for promise to resolve)
await tick();
wrapper.instance().onAlreadyAdded = onAlreadyAdded;
wrapper.update();
// Test onEnter
wrapper.instance().onEnter(domains[0]);
expect(onAlreadyAdded).toHaveBeenCalledWith(domains[0]);
@@ -146,15 +235,15 @@ it("checks if the domain was already added", async () => {
it("calls the add prop when adding a domain", () => {
const domain = "domain";
const add = jest.fn(ignoreAPI);
const onAdd = jest.fn(ignoreAPI);
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={add}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={onAdd}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
@@ -162,27 +251,25 @@ it("calls the add prop when adding a domain", () => {
wrapper.instance().onEnter(domain);
expect(add).toHaveBeenCalledWith(domain);
expect(onAdd).toHaveBeenCalledWith(domain);
});
it("calls onAdding when adding a domain", () => {
const domain = "domain";
const onAdding = jest.fn();
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={ignoreAPI}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
const onAdding = jest.spyOn(wrapper.instance(), "onAdding");
wrapper.instance().onAdding = onAdding;
wrapper.update();
wrapper.instance().onEnter(domain);
expect(onAdding).toHaveBeenCalledWith(domain);
@@ -190,22 +277,20 @@ it("calls onAdding when adding a domain", () => {
it("calls onAdded after API request succeeds", async () => {
const domain = "domain";
const onAdded = jest.fn();
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={() => Promise.resolve()}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={() => Promise.resolve()}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
const onAdded = jest.spyOn(wrapper.instance(), "onAdded");
wrapper.instance().onAdded = onAdded;
wrapper.update();
wrapper.instance().onEnter(domain);
await tick();
@@ -214,22 +299,20 @@ it("calls onAdded after API request succeeds", async () => {
it("calls onAddFailed after API request fails", async () => {
const domain = "domain";
const onAddFailed = jest.fn();
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={() => Promise.reject({})}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={() => Promise.reject({})}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
const onAddFailed = jest.spyOn(wrapper.instance(), "onAddFailed");
wrapper.instance().onAddFailed = onAddFailed;
wrapper.update();
wrapper.instance().onEnter(domain);
await tick();
@@ -243,16 +326,15 @@ it("adds the domain in onAdded", async () => {
title=""
placeholder=""
note=""
add={() => Promise.resolve()}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={() => Promise.resolve()}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
wrapper.instance().onEnter(domain);
wrapper.update();
await tick();
expect(wrapper.state().domains).toEqual([domain]);
@@ -265,60 +347,84 @@ it("resets the domains when adding failed", async () => {
title=""
placeholder=""
note=""
add={() => Promise.reject({})}
refresh={ignoreAPI}
remove={ignoreAPI}
onAdd={() => Promise.reject({})}
onRefresh={ignoreAPI}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
wrapper.instance().onEnter(domain);
wrapper.update();
await tick();
expect(wrapper.state().domains).toEqual([]);
});
it("removes the domain when onRemoved is called", async () => {
const domain = "domain";
const domains = [domain];
it("does not remove the domain if it is not present", async () => {
const domain = "domain1";
const domains = ["domain2"];
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={ignoreAPI}
refresh={() => Promise.resolve(domains)}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={() => Promise.resolve(domains)}
onRemove={ignoreAPI}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
wrapper.instance().onRemoved(domain);
wrapper.update();
await tick();
wrapper.instance().onRemove(domain);
expect(wrapper.state().domains).toEqual([]);
expect(wrapper.state().domains).toEqual(domains);
});
it("resets the domains when removal failed", () => {
it("removes the domain from state when onRemove is called", async () => {
const domain = "domain";
const domain2 = "domain2";
const domains = [domain, domain2];
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
onAdd={ignoreAPI}
onRefresh={() => Promise.resolve(domains)}
onRemove={() => Promise.resolve()}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
await tick();
wrapper.instance().onRemove(domain);
expect(wrapper.state().domains).toEqual([domain2]);
});
it("resets the domains when removal failed", async () => {
const domain = "domain";
const domains = [domain];
const wrapper: ListPageWrapper = shallow(
<ListPage
title=""
placeholder=""
note=""
add={ignoreAPI}
refresh={() => Promise.resolve(domains)}
remove={ignoreAPI}
onAdd={ignoreAPI}
onRefresh={() => Promise.resolve(domains)}
onRemove={() => Promise.reject()}
isValid={jest.fn()}
validationErrorMsg=""
/>
);
wrapper.instance().onRemoveFailed(domain, domains);
wrapper.update();
await tick();
wrapper.instance().onRemove(domain);
await tick();
expect(wrapper.state().domains).toEqual(domains);
});
+6 -6
View File
@@ -20,18 +20,18 @@ import i18next from "i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import debounce from "lodash.debounce";
import moment from "moment";
import {
CancelablePromise,
ignoreCancel,
makeCancelable,
padNumber
} from "../../util";
import { padNumber } from "../../util/graphUtils";
import api from "../../util/api";
import { dateRanges } from "../../util/dateRanges";
import { TranslatedTimeRangeSelector } from "../dashboard/TimeRangeSelector";
import { TimeRange } from "../common/context/TimeRangeContext";
import "react-table/react-table.css";
import "bootstrap-daterangepicker/daterangepicker.css";
import {
CancelablePromise,
ignoreCancel,
makeCancelable
} from "../../util/CancelablePromise";
export interface QueryLogState {
history: Array<ApiQuery>;
+19 -3
View File
@@ -10,7 +10,11 @@
import React, { ChangeEvent, Component, FormEvent } from "react";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import {
CancelablePromise,
ignoreCancel,
makeCancelable
} from "../../util/CancelablePromise";
import api from "../../util/api";
import {
Button,
@@ -46,12 +50,13 @@ class DHCPInfo extends Component<WithNamespaces, DHCPInfoState> {
router_ip: "",
lease_time: 0,
domain: "",
ipv6_support: false
ipv6_support: false,
rapid_commit: true
}
};
private loadHandler: undefined | CancelablePromise<ApiDhcpSettings>;
private updateHandler: undefined | CancelablePromise<ApiResultResponse>;
private updateHandler: undefined | CancelablePromise<ApiSuccessResponse>;
loadDHCPInfo = () => {
this.loadHandler = makeCancelable(api.getDHCPInfo());
@@ -298,6 +303,17 @@ class DHCPInfo extends Component<WithNamespaces, DHCPInfoState> {
{t("IPv6 Support")}
</Label>
</FormGroup>
<FormGroup check>
<Label check>
<Input
type="checkbox"
disabled={!this.state.settings.active}
checked={this.state.settings.rapid_commit}
onChange={this.onChange("rapid_commit", "checked")}
/>
{t("Rapid Commit")}
</Label>
</FormGroup>
<Button
type="submit"
disabled={
+6 -2
View File
@@ -10,7 +10,11 @@
import React, { Component, FormEvent } from "react";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import {
CancelablePromise,
ignoreCancel,
makeCancelable
} from "../../util/CancelablePromise";
import api from "../../util/api";
import DnsList from "./DnsList";
import { Button, Col, Form, FormGroup } from "reactstrap";
@@ -52,7 +56,7 @@ class DNSInfo extends Component<WithNamespaces, DNSInfoState> {
};
private loadHandler: undefined | CancelablePromise<ApiDnsSettings>;
private updateHandler: undefined | CancelablePromise<ApiResultResponse>;
private updateHandler: undefined | CancelablePromise<ApiSuccessResponse>;
loadDNSInfo = () => {
this.loadHandler = makeCancelable(api.getDNSInfo());
@@ -10,7 +10,11 @@
import React, { ChangeEvent, Component, FormEvent } from "react";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import {
CancelablePromise,
ignoreCancel,
makeCancelable
} from "../../util/CancelablePromise";
import api from "../../util/api";
import Alert, { AlertType } from "../common/Alert";
import { Button, Col, Form, FormGroup, Input, Label } from "reactstrap";
@@ -48,7 +52,7 @@ class PreferenceSettings extends Component<
};
private loadHandler: undefined | CancelablePromise<ApiPreferences>;
private updateHandler: undefined | CancelablePromise<ApiResultResponse>;
private updateHandler: undefined | CancelablePromise<ApiSuccessResponse>;
loadPreferences = () => {
this.loadHandler = makeCancelable(api.getPreferences());
@@ -24,7 +24,8 @@ const fakeData = {
router_ip: "192.168.1.1",
lease_time: "24",
domain: "lan",
ipv6_support: false
ipv6_support: false,
rapid_commit: true
};
it("retrieves settings correctly", async () => {
+2 -1
View File
@@ -12,5 +12,6 @@ import { Config } from "./config";
export default {
developmentMode: true,
fakeAPI: false
fakeAPI: false,
apiPath: process.env.PUBLIC_URL + "/api"
} as Config;
+2 -1
View File
@@ -12,5 +12,6 @@ import { Config } from "./config";
export default {
developmentMode: false,
fakeAPI: false
fakeAPI: false,
apiPath: process.env.PUBLIC_URL + "/api"
} as Config;
+2
View File
@@ -14,6 +14,7 @@ import productionConfig from "./config.production";
export interface Config {
developmentMode: boolean;
fakeAPI: boolean;
apiPath: string;
}
let config: Config;
@@ -26,6 +27,7 @@ if (process.env.NODE_ENV === "development") {
if (process.env.REACT_APP_FAKE_API) {
config.fakeAPI = true;
config.apiPath = process.env.PUBLIC_URL + "/fakeAPI";
}
export default config;
+1 -1
View File
@@ -19,7 +19,7 @@ import "./scss/style.css";
import Full from "./containers/Full";
import api from "./util/api";
import { setupI18n } from "./util/i18n";
import { getBasePath } from "./util";
import { getBasePath } from "./util/basePath";
// Before rendering anything, check if there is a session cookie.
// Note: the user could have an old session, so the first API call
-1
View File
@@ -1,5 +1,4 @@
// Variable overrides
$border-color: #0275d8;
$navbar-bg: #3c8dbc;
$navbar-brand-bg: #367fa9;
$navbar-brand-width: 200px;
+1
View File
@@ -67,6 +67,7 @@ interface ApiDhcpSettings {
lease_time: number;
domain: string;
ipv6_support: boolean;
rapid_commit: boolean;
}
interface ApiFtlDbResponse {
@@ -3,55 +3,22 @@
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Various utilities
* Wrap promises to make them cancelable
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { TimeRange } from "../components/common/context/TimeRangeContext";
/**
* Pad a two digit number
*
* @param num the number
* @returns A padding number string
* A promise which can be canceled
*/
export const padNumber = (num: number): string => {
return ("00" + num).substr(-2, 2);
};
/**
* Get the base path of the web interface. The API will inject a base element
* for this purpose, but if the web interface is not hosted by the API, it will
* fall back to the public URL set by Create React App.
*
* @returns The base path to use
*/
export const getBasePath = (): string => {
const baseElement = document.getElementsByTagName("base")[0];
if (baseElement) {
return new URL(baseElement.href).pathname;
} else {
return process.env.PUBLIC_URL;
}
};
/**
* Dynamically calculate a time interval so there are always 144 data points
* (144 so that every point represents 10 minutes when the range is 24 hours)
*
* @param range The range to find the interval for
*/
export const getIntervalForRange = (range: TimeRange): number => {
return Math.ceil((range.until.unix() - range.from.unix()) / 144);
};
export interface CancelablePromise<T> {
promise: Promise<T>;
cancel: () => void;
}
/**
* The options given to {@link makeCancelable}
*/
export interface CancelableOptions {
/**
* The function to call to repeat the promise
@@ -64,13 +31,19 @@ export interface CancelableOptions {
interval: number;
}
/**
* The error thrown when the {@link CancelablePromise} is canceled
*/
export interface CanceledError {
isCanceled: true;
}
/**
* Make a promise cancelable and repeatable
*
* @param promise the promise
* @param options the interval repeat options
* @returns {{promise: Promise<T>, cancel(): void}} a handle on the cancelable
* promise
* @returns a cancelable promise
*/
export function makeCancelable<T>(
promise: Promise<T>,
@@ -80,17 +53,24 @@ export function makeCancelable<T>(
let repeatId: NodeJS.Timeout | null = null;
const handle = (
resolve: (value: any) => void,
resolve: (value: T) => void,
reject: (error: any) => void,
val: T,
isError: boolean
) => {
if (hasCanceled) reject({ isCanceled: true });
else {
if (isError) reject(val);
else resolve(val);
if (hasCanceled) {
reject({ isCanceled: true });
return;
}
if (options) repeatId = setTimeout(options.repeat, options.interval);
if (isError) {
reject(val);
} else {
resolve(val);
}
if (options) {
repeatId = setTimeout(options.repeat, options.interval);
}
};
@@ -0,0 +1,102 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Tests for canceling promises
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { makeCancelable, ignoreCancel } from "../CancelablePromise";
describe("makeCancelable", () => {
const testValue = "test";
const testError = "testError";
const promise = Promise.resolve(testValue);
const promiseErr = Promise.reject(testError);
it("passes through promises in the default case", async () => {
const cancelablePromise = makeCancelable(promise);
await expect(cancelablePromise.promise).resolves.toEqual(testValue);
});
it("passes through errors in the default case", async () => {
const cancelablePromise = makeCancelable(promiseErr);
await expect(cancelablePromise.promise).rejects.toEqual(testError);
});
it("rejects with cancel error if canceled", async () => {
const cancelablePromise = makeCancelable(promise);
cancelablePromise.cancel();
await expect(cancelablePromise.promise).rejects.toEqual({
isCanceled: true
});
});
it("calls the repeat function after resolving", async () => {
jest.useFakeTimers();
const mockFunction = jest.fn();
const interval = 1000;
const cancelablePromise = makeCancelable(promise, {
interval,
repeat: mockFunction
});
await expect(cancelablePromise.promise).resolves;
expect(setTimeout).toHaveBeenCalledWith(mockFunction, interval);
});
it("calls the repeat function after rejecting", async () => {
jest.useFakeTimers();
const mockFunction = jest.fn();
const interval = 1000;
const cancelablePromise = makeCancelable(promiseErr, {
interval,
repeat: mockFunction
});
await expect(cancelablePromise.promise).rejects.toEqual(testError);
expect(setTimeout).toHaveBeenCalledWith(mockFunction, interval);
});
it("clears the timeout if canceled after resolving", async () => {
jest.useFakeTimers();
const mockFunction = jest.fn();
const interval = 1000;
const cancelablePromise = makeCancelable(promise, {
interval,
repeat: mockFunction
});
await cancelablePromise.promise;
cancelablePromise.cancel();
expect(clearTimeout).toHaveBeenCalled();
});
});
describe("ignoreCancel", () => {
it("passes through non-canceled errors", async () => {
const testError = "test";
const promise = Promise.reject(testError);
await expect(promise.catch(ignoreCancel)).rejects.toEqual(testError);
});
it("does not pass through canceled errors", async () => {
const canceledPromise = Promise.reject({ isCanceled: true });
await expect(canceledPromise.catch(ignoreCancel)).resolves;
});
});
+311 -10
View File
@@ -3,21 +3,322 @@
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Utility function tests
* API service tests
*
* 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 { ApiClient } from "../api";
import HttpClient from "../http";
import { TimeRange } from "../../components/common/context/TimeRangeContext";
import moment from "moment";
import { Config } from "../../config";
// This is a dumb test used to set up the next test,
// which checks that the logged in state is reset before each test
it("sets logged in to true", () => {
api.loggedIn = true;
// Test each endpoint function to make sure it is calling the right endpoint
// with the right data.
describe("ApiClient", () => {
// Services
let httpClient: HttpClient;
let api: ApiClient;
expect(api.loggedIn).toBeTruthy();
});
// Test response data
const getData = { test: "GET" };
const postData = { test: "POST" };
const putData = { test: "PUT" };
const deleteData = { test: "DELETE" };
const getPromise = Promise.resolve(getData);
const postPromise = Promise.resolve(postData);
const putPromise = Promise.resolve(putData);
const deletePromise = Promise.resolve(deleteData);
it("resets the logged in state for each test", () => {
expect(api.loggedIn).toBeFalsy();
// Test data
const range: TimeRange = {
name: "Test time range",
from: moment("2019-04-12T01:03:17+00:00"),
until: moment("2019-04-13T01:03:17+00:00")
};
const rangeParams = "from=1555030997&until=1555117397";
const config: Config = {
developmentMode: true,
fakeAPI: true,
apiPath: "/admin/api"
};
beforeEach(() => {
// Create fresh services
httpClient = ({
get: jest.fn(() => getPromise),
post: jest.fn(() => postPromise),
put: jest.fn(() => putPromise),
delete: jest.fn(() => deletePromise),
config
} as any) as HttpClient;
api = new ApiClient(httpClient);
});
describe("authentication calls", () => {
it("should call login endpoint with auth headers", async () => {
const key = "test";
await expect(api.authenticate(key)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("auth", {
headers: { "X-Pi-hole-Authenticate": key }
});
});
it("should call logout endpoint", async () => {
await expect(api.logout()).resolves.toEqual(deleteData);
expect(httpClient.delete).toHaveBeenCalledWith("auth");
});
});
describe("statistics calls", () => {
it("should call summary endpoint", async () => {
await expect(api.getSummary()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/summary");
});
it("should call history graph endpoint", async () => {
await expect(api.getHistoryGraph()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/overTime/history");
});
it("should call clients graph endpoint", async () => {
await expect(api.getClientsGraph()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/overTime/clients");
});
it("should call query types endpoint", async () => {
await expect(api.getQueryTypes()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/query_types");
});
it("should call upstreams endpoint", async () => {
await expect(api.getUpstreams()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/upstreams");
});
it("should call top domains endpoint", async () => {
await expect(api.getTopDomains()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/top_domains");
});
it("should call top blocked endpoint (top_domains?blocked=true)", async () => {
config.fakeAPI = false;
await expect(api.getTopBlocked()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/top_domains?blocked=true"
);
});
it("should call top blocked endpoint (top_blocked)", async () => {
config.fakeAPI = true;
await expect(api.getTopBlocked()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/top_blocked");
});
it("should call top clients endpoint", async () => {
await expect(api.getTopClients()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/top_clients");
});
it("should call top history endpoint with params", async () => {
const params = { test: "params" };
await expect(api.getHistory(params)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("stats/history?test=params");
});
});
describe("database statistic calls", () => {
it("should call summary DB endpoint with time range", async () => {
await expect(api.getSummaryDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/summary?" + rangeParams
);
});
it("should call history graph DB endpoint with interval and time range", async () => {
await expect(api.getHistoryGraphDb(range, 100)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/overTime/history?interval=100&" + rangeParams
);
});
it("should call client graph DB endpoint with interval and time range", async () => {
await expect(api.getClientsGraphDb(range, 100)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/overTime/clients?interval=100&" + rangeParams
);
});
it("should call query types DB endpoint with time range", async () => {
await expect(api.getQueryTypesDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/query_types?" + rangeParams
);
});
it("should call query types DB endpoint with time range", async () => {
await expect(api.getQueryTypesDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/query_types?" + rangeParams
);
});
it("should call upstreams DB endpoint with time range", async () => {
await expect(api.getUpstreamsDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/upstreams?" + rangeParams
);
});
it("should call top domains DB endpoint with time range", async () => {
await expect(api.getTopDomainsDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/top_domains?" + rangeParams
);
});
it("should call top blocked DB endpoint (top_domains?blocked=true) with time range", async () => {
config.fakeAPI = false;
await expect(api.getTopBlockedDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/top_domains?blocked=true&" + rangeParams
);
});
it("should call top blocked DB endpoint (top_blocked) with time range", async () => {
config.fakeAPI = true;
await expect(api.getTopBlockedDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/top_blocked?" + rangeParams
);
});
it("should call top clients DB endpoint with time range", async () => {
await expect(api.getTopClientsDb(range)).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith(
"stats/database/top_clients?" + rangeParams
);
});
});
describe("dns calls", () => {
it("should call get whitelist endpoint", async () => {
await expect(api.getWhitelist()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("dns/whitelist");
});
it("should call get blacklist endpoint", async () => {
await expect(api.getBlacklist()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("dns/blacklist");
});
it("should call get regexlist endpoint", async () => {
await expect(api.getRegexlist()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("dns/regexlist");
});
it("should call add whitelist endpoint with domain", async () => {
const domain = "test.com";
await expect(api.addWhitelist(domain)).resolves.toEqual(postData);
expect(httpClient.post).toHaveBeenCalledWith("dns/whitelist", { domain });
});
it("should call add blacklist endpoint with domain", async () => {
const domain = "test.com";
await expect(api.addBlacklist(domain)).resolves.toEqual(postData);
expect(httpClient.post).toHaveBeenCalledWith("dns/blacklist", { domain });
});
it("should call add regexlist endpoint with domain", async () => {
const domain = "test.com";
await expect(api.addRegexlist(domain)).resolves.toEqual(postData);
expect(httpClient.post).toHaveBeenCalledWith("dns/regexlist", { domain });
});
it("should call remove whitelist endpoint with domain", async () => {
const domain = "test.com";
await expect(api.removeWhitelist(domain)).resolves.toEqual(deleteData);
expect(httpClient.delete).toHaveBeenCalledWith("dns/whitelist/" + domain);
});
it("should call remove blacklist endpoint with domain", async () => {
const domain = "test.com";
await expect(api.removeBlacklist(domain)).resolves.toEqual(deleteData);
expect(httpClient.delete).toHaveBeenCalledWith("dns/blacklist/" + domain);
});
it("should call remove regexlist endpoint with encoded domain", async () => {
const regex = "^test\\.com$";
await expect(api.removeRegexlist(regex)).resolves.toEqual(deleteData);
expect(httpClient.delete).toHaveBeenCalledWith(
"dns/regexlist/%5Etest%5C.com%24"
);
});
it("should call get status endpoint", async () => {
await expect(api.getStatus()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("dns/status");
});
it("should call set status endpoint with action data", async () => {
const action: StatusAction = "enable";
const time = 100;
await expect(api.setStatus(action, time)).resolves.toEqual(postData);
expect(httpClient.post).toHaveBeenCalledWith("dns/status", {
action,
time
});
});
});
describe("settings calls", () => {
it("should call get network settings endpoint", async () => {
await expect(api.getNetworkInfo()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("settings/network");
});
it("should call version endpoint", async () => {
await expect(api.getVersion()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("version");
});
it("should call FTL DB settings endpoint", async () => {
await expect(api.getFTLdb()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("settings/ftldb");
});
it("should call get DNS settings endpoint", async () => {
await expect(api.getDNSInfo()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("settings/dns");
});
it("should call get DHCP settings endpoint", async () => {
await expect(api.getDHCPInfo()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("settings/dhcp");
});
it("should call get web preferences settings endpoint", async () => {
await expect(api.getPreferences()).resolves.toEqual(getData);
expect(httpClient.get).toHaveBeenCalledWith("settings/web");
});
it("should call set DNS settings endpoint", async () => {
const settings = ({ test: true } as any) as ApiDnsSettings;
await expect(api.updateDNSInfo(settings)).resolves.toEqual(putData);
expect(httpClient.put).toHaveBeenCalledWith("settings/dns", settings);
});
it("should call set DHCP settings endpoint", async () => {
const settings = ({ test: true } as any) as ApiDhcpSettings;
await expect(api.updateDHCPInfo(settings)).resolves.toEqual(putData);
expect(httpClient.put).toHaveBeenCalledWith("settings/dhcp", settings);
});
it("should call set web preferences settings endpoint", async () => {
const settings = ({ test: true } as any) as ApiPreferences;
await expect(api.updatePreferences(settings)).resolves.toEqual(putData);
expect(httpClient.put).toHaveBeenCalledWith("settings/web", settings);
});
});
});
+31
View File
@@ -0,0 +1,31 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Tests for base path function
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { getBasePath } from "../basePath";
it("should return the public URL when there is no base", () => {
const publicUrl = process.env.PUBLIC_URL;
const basePath = getBasePath();
expect(basePath).toEqual(publicUrl);
});
it("should return the path from the base element when it exists", () => {
const expectedBasePath = "/admin";
const baseElement = document.createElement("base");
baseElement.href = expectedBasePath;
document.head.appendChild(baseElement);
const actualBasePath = getBasePath();
document.head.removeChild(baseElement);
expect(actualBasePath).toEqual(expectedBasePath);
});
+51
View File
@@ -0,0 +1,51 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Graph utility tests
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { padNumber, getIntervalForRange } from "../graphUtils";
import { TimeRange } from "../../components/common/context/TimeRangeContext";
import moment from "moment";
describe("padNumber", () => {
it("pads 0 to 00", () => {
expect(padNumber(0)).toEqual("00");
});
it("pads 1 to 01", () => {
expect(padNumber(1)).toEqual("01");
});
it("pads 12 to 12", () => {
expect(padNumber(12)).toEqual("12");
});
});
describe("getIntervalForRange", () => {
it("returns 10 minutes for 24 hours", () => {
const range: TimeRange = {
name: "24 Hours",
from: moment().subtract(1, "day"),
until: moment()
};
expect(getIntervalForRange(range)).toEqual(10 * 60);
});
it("returns 1 day for 144 days", () => {
const range: TimeRange = {
name: "144 days",
// Use seconds instead of days to ensure the difference in epoch time is
// equal to 144 days
from: moment().subtract(144 * 24 * 60 * 60, "seconds"),
until: moment()
};
expect(getIntervalForRange(range)).toEqual(24 * 60 * 60);
});
});
+288
View File
@@ -0,0 +1,288 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Test basic HTTP functions
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import HttpClient, {
checkForErrors,
checkIfLoggedOut,
convertJSON,
paramsToString,
timeRangeToParams
} from "../http";
import api from "../api";
import { Config } from "../../config";
import { CanceledError } from "../CancelablePromise";
import { TimeRange } from "../../components/common/context/TimeRangeContext";
import moment from "moment";
import fetchMock from "fetch-mock";
const originalReload = window.location.reload;
/**
* Mock window.location.reload
* https://remarkablemark.org/blog/2018/11/17/mock-window-location/
*
* Restore it with {@link restoreLocationReload}
*/
const mockLocationReload = () => {
Object.defineProperty(window.location, "reload", {
configurable: true
});
window.location.reload = jest.fn();
};
/**
* Restore window.location.reload after being mocked by
* {@link mockLocationReload}
*/
const restoreLocationReload = () => {
window.location.reload = originalReload;
};
describe("HttpClient", () => {
const testEndpoint = "test";
const testEndpointFull = "/api/test";
const data = { test: true };
const config: Config = {
developmentMode: true,
apiPath: "/api",
fakeAPI: false
};
const canceledError: CanceledError = { isCanceled: true };
let httpClient: HttpClient;
beforeEach(() => {
httpClient = new HttpClient(config);
});
describe("handleResponse", () => {
it("should make a GET request and return the parsed data", async () => {
const response = {
status: 200,
json: () => Promise.resolve(data)
} as Response;
await expect(httpClient.handleResponse(response)).resolves.toEqual(data);
});
it("should cancel if logged out by API", async () => {
const error: ApiError = {
key: "unauthorized",
message: "Unauthorized",
data: null
};
const response = {
status: 401,
json: () => Promise.resolve({ error })
} as Response;
api.loggedIn = true;
mockLocationReload();
await expect(httpClient.handleResponse(response)).rejects.toEqual(
canceledError
);
expect(window.location.reload).toHaveBeenCalled();
restoreLocationReload();
});
it("should reject with the API error if set", async () => {
const error: ApiError = {
key: "test_key",
message: "Test message",
data: null
};
const response = {
status: 500,
json: () => Promise.resolve({ error })
} as Response;
await expect(httpClient.handleResponse(response)).rejects.toEqual(error);
});
});
describe("HTTP functions", () => {
it("should make a GET request and call handleResponse", async () => {
fetchMock.get(testEndpointFull, { body: data });
// @ts-ignore
httpClient.handleResponse = jest.fn(() => Promise.resolve(data));
await expect(httpClient.get(testEndpoint)).resolves.toEqual(data);
const request = fetchMock.lastCall(testEndpointFull)![1]!;
expect(httpClient.handleResponse).toHaveBeenCalled();
expect(request.method).toEqual("GET");
});
it("should make a POST request and call handleResponse", async () => {
fetchMock.post(testEndpointFull, { body: data });
// @ts-ignore
httpClient.handleResponse = jest.fn(() => Promise.resolve(data));
await expect(httpClient.post(testEndpoint, data)).resolves.toEqual(data);
const request = fetchMock.lastCall(testEndpointFull)![1]!;
expect(httpClient.handleResponse).toHaveBeenCalled();
expect(request.method).toEqual("POST");
expect(request.body).toEqual(JSON.stringify(data));
});
it("should make a PUT request and call handleResponse", async () => {
fetchMock.put(testEndpointFull, { body: data });
// @ts-ignore
httpClient.handleResponse = jest.fn(() => Promise.resolve(data));
await expect(httpClient.put(testEndpoint, data)).resolves.toEqual(data);
const request = fetchMock.lastCall(testEndpointFull)![1]!;
expect(httpClient.handleResponse).toHaveBeenCalled();
expect(request.method).toEqual("PUT");
expect(request.body).toEqual(JSON.stringify(data));
});
it("should make a DELETE request and call handleResponse", async () => {
fetchMock.delete(testEndpointFull, { body: data });
// @ts-ignore
httpClient.handleResponse = jest.fn(() => Promise.resolve(data));
await expect(httpClient.delete(testEndpoint)).resolves.toEqual(data);
const request = fetchMock.lastCall(testEndpointFull)![1]!;
expect(httpClient.handleResponse).toHaveBeenCalled();
expect(request.method).toEqual("DELETE");
});
});
describe("urlFor", () => {
it("uses the fakeAPI route if configured", () => {
const config: Config = {
developmentMode: false,
fakeAPI: true,
apiPath: "/fakeAPI"
};
const httpClient = new HttpClient(config);
expect(httpClient.urlFor("test")).toEqual("/fakeAPI/test");
});
it("uses the production route if configured", () => {
const config: Config = {
developmentMode: false,
fakeAPI: false,
apiPath: "/admin/api"
};
const httpClient = new HttpClient(config);
expect(httpClient.urlFor("test")).toEqual("/admin/api/test");
});
});
});
describe("checkIfLoggedOut", () => {
it("should pass the response through if logged in and not a 401", async () => {
const response = { status: 200 } as Response;
api.loggedIn = true;
await expect(checkIfLoggedOut(response)).resolves.toEqual(response);
});
it("should pass the response through if not logged in and not a 401", async () => {
const response = { status: 200 } as Response;
api.loggedIn = false;
await expect(checkIfLoggedOut(response)).resolves.toEqual(response);
});
it("should pass the response through if not logged in and is a 401", async () => {
const response = { status: 401 } as Response;
api.loggedIn = false;
await expect(checkIfLoggedOut(response)).resolves.toEqual(response);
});
it("should clear the session cookie and reload if logged in and response is a 401", async () => {
const response = { status: 401 } as Response;
api.loggedIn = true;
document.cookie = "user_id=test";
mockLocationReload();
await expect(checkIfLoggedOut(response)).rejects.toEqual({
isCanceled: true
});
expect(window.location.reload).toHaveBeenCalled();
expect(document.cookie).toHaveLength(0);
restoreLocationReload();
});
});
describe("convertJSON", () => {
it("should convert to JSON if it is not canceled or an error", async () => {
const body = { test: true };
const response = new Response(JSON.stringify(body));
await expect(convertJSON(response)).resolves.toEqual(body);
});
it("should reject with input if canceled", async () => {
const cancelError: CanceledError = {
isCanceled: true
};
await expect(convertJSON(cancelError)).rejects.toEqual(cancelError);
});
it("should reject with input if error", async () => {
const error = new Error("test");
await expect(convertJSON(error)).rejects.toEqual(error);
});
});
describe("checkForErrors", () => {
it("should pass through the data if there is no error", async () => {
const data = { test: true };
await expect(checkForErrors(data)).resolves.toEqual(data);
});
it("should reject with the error if there is an error", async () => {
const data = { error: { test: true } };
await expect(checkForErrors(data)).rejects.toEqual(data.error);
});
});
describe("paramsToString", () => {
it("converts an object into parameters", () => {
const object = {
test1: "1",
test2: "two",
test3: 3
};
const expectedParams = "test1=1&test2=two&test3=3";
expect(paramsToString(object)).toEqual(expectedParams);
});
});
describe("timeRangeToParams", () => {
it("converts a time range into parameters", () => {
const range: TimeRange = {
name: "Test time range",
from: moment("2019-04-12T01:03:17+00:00"),
until: moment("2019-04-13T01:03:17+00:00")
};
const expectedParams = "from=1555030997&until=1555117397";
expect(timeRangeToParams(range)).toEqual(expectedParams);
});
});
+32
View File
@@ -0,0 +1,32 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Test internationalization setup
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { setupI18n } from "../i18n";
// Mock react-i18next, as it does not properly export the i18next module during
// testing.
// https://github.com/i18next/react-i18next/issues/434
jest.mock("react-i18next", () => ({
reactI18nextModule: {
type: "3rdParty",
init: () => {}
}
}));
// languages.json is generated during a build or run, so it may not exist yet
jest.mock("../../languages.json", () => [], { virtual: true });
it("configures i18n successfully", async () => {
// Provide a mock ajax function to the XHR backend
const fakeAjax = (url: any, options: any, callback: any) => callback("", {});
// Make sure i18n initializes without error
await setupI18n(fakeAjax);
});
+47
View File
@@ -0,0 +1,47 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Result class tests
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { Ok, Err } from "../result";
describe("Ok", () => {
const testValue = "test";
const ok = new Ok(testValue);
it("knows it's Ok", () => {
expect(ok.isOk()).toBe(true);
expect(ok.isErr()).toBe(false);
});
it("unwraps without error", () => {
expect(ok.unwrap()).toEqual(testValue);
});
it("throws an error on unwrapErr", () => {
expect(() => ok.unwrapErr()).toThrow("unwrapErr on a Result.Ok");
});
});
describe("Err", () => {
const testValue = "test";
const err = new Err(testValue);
it("knows it's Err", () => {
expect(err.isOk()).toBe(false);
expect(err.isErr()).toBe(true);
});
it("throws an error on unwrap", () => {
expect(() => err.unwrap()).toThrow("unwrap on a Result.Err");
});
it("does not throw an error on unwrapErr", () => {
expect(err.unwrapErr()).toEqual(testValue);
});
});
+182 -123
View File
@@ -8,157 +8,216 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import http, { paramsToString, timeRangeToParams } from "./http";
import HttpClient, { paramsToString, timeRangeToParams } from "./http";
import config from "../config";
import { TimeRange } from "../components/common/context/TimeRangeContext";
export default {
loggedIn: false,
authenticate(key: string) {
return http.get("auth", {
headers: new Headers({ "X-Pi-hole-Authenticate": key })
export class ApiClient {
public loggedIn = false;
constructor(private http: HttpClient) {}
authenticate = (key: string): Promise<ApiSuccessResponse> => {
return this.http.get("auth", {
headers: { "X-Pi-hole-Authenticate": key }
});
},
logout() {
return http.delete("auth");
},
getSummary(): Promise<ApiSummary> {
return http.get("stats/summary");
},
getSummaryDb(range: TimeRange): Promise<ApiSummary> {
return http.get("stats/database/summary?" + timeRangeToParams(range));
},
getHistoryGraph(): Promise<Array<ApiHistoryGraphItem>> {
return http.get("stats/overTime/history");
},
getHistoryGraphDb(
};
logout = (): Promise<ApiSuccessResponse> => {
return this.http.delete("auth");
};
getSummary = (): Promise<ApiSummary> => {
return this.http.get("stats/summary");
};
getSummaryDb = (range: TimeRange): Promise<ApiSummary> => {
return this.http.get("stats/database/summary?" + timeRangeToParams(range));
};
getHistoryGraph = (): Promise<Array<ApiHistoryGraphItem>> => {
return this.http.get("stats/overTime/history");
};
getHistoryGraphDb = (
range: TimeRange,
interval: number
): Promise<Array<ApiHistoryGraphItem>> {
return http.get(
): Promise<Array<ApiHistoryGraphItem>> => {
return this.http.get(
"stats/database/overTime/history?interval=" +
interval +
"&" +
timeRangeToParams(range)
);
},
getClientsGraph(): Promise<ApiClientsGraph> {
return http.get("stats/overTime/clients");
},
getClientsGraphDb(
};
getClientsGraph = (): Promise<ApiClientsGraph> => {
return this.http.get("stats/overTime/clients");
};
getClientsGraphDb = (
range: TimeRange,
interval: number
): Promise<ApiClientsGraph> {
return http.get(
): Promise<ApiClientsGraph> => {
return this.http.get(
"stats/database/overTime/clients?interval=" +
interval +
"&" +
timeRangeToParams(range)
);
},
getQueryTypes(): Promise<Array<ApiQueryType>> {
return http.get("stats/query_types");
},
getQueryTypesDb(range: TimeRange): Promise<Array<ApiQueryType>> {
return http.get("stats/database/query_types?" + timeRangeToParams(range));
},
getUpstreams(): Promise<ApiUpstreams> {
return http.get("stats/upstreams");
},
getUpstreamsDb(range: TimeRange): Promise<ApiUpstreams> {
return http.get("stats/database/upstreams?" + timeRangeToParams(range));
},
getTopDomains(): Promise<ApiTopDomains> {
return http.get("stats/top_domains");
},
getTopDomainsDb(range: TimeRange): Promise<ApiTopDomains> {
return http.get("stats/database/top_domains?" + timeRangeToParams(range));
},
};
getQueryTypes = (): Promise<Array<ApiQueryType>> => {
return this.http.get("stats/query_types");
};
getQueryTypesDb = (range: TimeRange): Promise<Array<ApiQueryType>> => {
return this.http.get(
"stats/database/query_types?" + timeRangeToParams(range)
);
};
getUpstreams = (): Promise<ApiUpstreams> => {
return this.http.get("stats/upstreams");
};
getUpstreamsDb = (range: TimeRange): Promise<ApiUpstreams> => {
return this.http.get(
"stats/database/upstreams?" + timeRangeToParams(range)
);
};
getTopDomains = (): Promise<ApiTopDomains> => {
return this.http.get("stats/top_domains");
};
getTopDomainsDb = (range: TimeRange): Promise<ApiTopDomains> => {
return this.http.get(
"stats/database/top_domains?" + timeRangeToParams(range)
);
};
getTopBlocked(): Promise<ApiTopBlocked> {
// The API uses a GET parameter to differentiate top domains from top
// blocked, but the fake API is not able to handle GET parameters right now.
const url = config.fakeAPI
const url = this.http.config.fakeAPI
? "stats/top_blocked"
: "stats/top_domains?blocked=true";
return http.get(url);
},
return this.http.get(url);
}
getTopBlockedDb(range: TimeRange): Promise<ApiTopBlocked> {
// The API uses a GET parameter to differentiate top domains from top
// blocked, but the fake API is not able to handle GET parameters right now.
const url = config.fakeAPI
const url = this.http.config.fakeAPI
? "stats/database/top_blocked?"
: "stats/database/top_domains?blocked=true&";
return http.get(url + timeRangeToParams(range));
},
getTopClients(): Promise<ApiTopClients> {
return http.get("stats/top_clients");
},
getTopClientsDb(range: TimeRange): Promise<ApiTopClients> {
return http.get("stats/database/top_clients?" + timeRangeToParams(range));
},
getHistory(params: any): Promise<ApiHistoryResponse> {
return http.get("stats/history?" + paramsToString(params));
},
getWhitelist() {
return http.get("dns/whitelist");
},
getBlacklist() {
return http.get("dns/blacklist");
},
getRegexlist() {
return http.get("dns/regexlist");
},
addWhitelist(domain: string) {
return http.post("dns/whitelist", { domain: domain });
},
addBlacklist(domain: string) {
return http.post("dns/blacklist", { domain: domain });
},
addRegexlist(domain: string) {
return http.post("dns/regexlist", { domain: domain });
},
removeWhitelist(domain: string) {
return http.delete("dns/whitelist/" + domain);
},
removeBlacklist(domain: string) {
return http.delete("dns/blacklist/" + domain);
},
removeRegexlist(domain: string) {
return http.delete("dns/regexlist/" + encodeURIComponent(domain));
},
getStatus(): Promise<ApiStatus> {
return http.get("dns/status");
},
setStatus(action: StatusAction, time?: number) {
return http.post("dns/status", { action, time });
},
getNetworkInfo(): Promise<ApiNetworkSettings> {
return http.get("settings/network");
},
getVersion(): Promise<ApiVersions> {
return http.get("version");
},
getFTLdb(): Promise<ApiFtlDbResponse> {
return http.get("settings/ftldb");
},
getDNSInfo(): Promise<ApiDnsSettings> {
return http.get("settings/dns");
},
getDHCPInfo(): Promise<ApiDhcpSettings> {
return http.get("settings/dhcp");
},
updateDHCPInfo(settings: ApiDhcpSettings) {
return http.put("settings/dhcp", settings);
},
updateDNSInfo(settings: ApiDnsSettings) {
return http.put("settings/dns", settings);
},
getPreferences(): Promise<ApiPreferences> {
return http.get("settings/web");
},
updatePreferences(settings: ApiPreferences) {
return http.put("settings/web", settings);
return this.http.get(url + timeRangeToParams(range));
}
};
getTopClients = (): Promise<ApiTopClients> => {
return this.http.get("stats/top_clients");
};
getTopClientsDb = (range: TimeRange): Promise<ApiTopClients> => {
return this.http.get(
"stats/database/top_clients?" + timeRangeToParams(range)
);
};
getHistory = (params: any): Promise<ApiHistoryResponse> => {
return this.http.get("stats/history?" + paramsToString(params));
};
getWhitelist = (): Promise<Array<string>> => {
return this.http.get("dns/whitelist");
};
getBlacklist = (): Promise<Array<string>> => {
return this.http.get("dns/blacklist");
};
getRegexlist = (): Promise<Array<string>> => {
return this.http.get("dns/regexlist");
};
addWhitelist = (domain: string): Promise<ApiSuccessResponse> => {
return this.http.post("dns/whitelist", { domain: domain });
};
addBlacklist = (domain: string): Promise<ApiSuccessResponse> => {
return this.http.post("dns/blacklist", { domain: domain });
};
addRegexlist = (domain: string): Promise<ApiSuccessResponse> => {
return this.http.post("dns/regexlist", { domain: domain });
};
removeWhitelist = (domain: string): Promise<ApiSuccessResponse> => {
return this.http.delete("dns/whitelist/" + domain);
};
removeBlacklist = (domain: string): Promise<ApiSuccessResponse> => {
return this.http.delete("dns/blacklist/" + domain);
};
removeRegexlist = (domain: string): Promise<ApiSuccessResponse> => {
return this.http.delete("dns/regexlist/" + encodeURIComponent(domain));
};
getStatus = (): Promise<ApiStatus> => {
return this.http.get("dns/status");
};
setStatus = (
action: StatusAction,
time?: number
): Promise<ApiSuccessResponse> => {
return this.http.post("dns/status", {
action,
time
});
};
getNetworkInfo = (): Promise<ApiNetworkSettings> => {
return this.http.get("settings/network");
};
getVersion = (): Promise<ApiVersions> => {
return this.http.get("version");
};
getFTLdb = (): Promise<ApiFtlDbResponse> => {
return this.http.get("settings/ftldb");
};
getDNSInfo = (): Promise<ApiDnsSettings> => {
return this.http.get("settings/dns");
};
getDHCPInfo = (): Promise<ApiDhcpSettings> => {
return this.http.get("settings/dhcp");
};
updateDHCPInfo = (settings: ApiDhcpSettings): Promise<ApiSuccessResponse> => {
return this.http.put("settings/dhcp", settings);
};
updateDNSInfo = (settings: ApiDnsSettings): Promise<ApiSuccessResponse> => {
return this.http.put("settings/dns", settings);
};
getPreferences = (): Promise<ApiPreferences> => {
return this.http.get("settings/web");
};
updatePreferences = (
settings: ApiPreferences
): Promise<ApiSuccessResponse> => {
return this.http.put("settings/web", settings);
};
}
export default new ApiClient(new HttpClient(config));
+26
View File
@@ -0,0 +1,26 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Provide the base path for relative paths
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/**
* Get the base path of the web interface. The API will inject a base element
* for this purpose, but if the web interface is not hosted by the API, it will
* fall back to the public URL set by Create React App.
*
* @returns The base path to use
*/
export const getBasePath = (): string => {
const baseElement = document.getElementsByTagName("base")[0];
if (baseElement) {
return new URL(baseElement.href).pathname;
} else {
return process.env.PUBLIC_URL;
}
};
+31
View File
@@ -0,0 +1,31 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* Web Interface
* Graph utility functions
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { TimeRange } from "../components/common/context/TimeRangeContext";
/**
* Pad a two digit number
*
* @param num the number
* @returns {string} a padding number string
*/
export const padNumber = (num: number) => {
return ("00" + num).substr(-2, 2);
};
/**
* Dynamically calculate a time interval so there are always 144 data points
* (144 so that every point represents 10 minutes when the range is 24 hours)
*
* @param range The range to find the interval for
*/
export const getIntervalForRange = (range: TimeRange): number => {
return Math.ceil((range.until.unix() - range.from.unix()) / 144);
};
+98 -95
View File
@@ -9,98 +9,124 @@
* Please see LICENSE file for your rights under this license. */
import api from "./api";
import config from "../config";
import { Config } from "../config";
import { TimeRange } from "../components/common/context/TimeRangeContext";
import { CanceledError } from "./CancelablePromise";
/**
* A group of HTTP functions. Each function parses the response checks for
* errors
* A class which provides HTTP functions. Each function parses the response and
* checks for errors
*/
export default {
export default class HttpClient {
constructor(public config: Config) {}
/**
* Check if the user is logged out, convert to JSON, and check for API errors
*
* @param response The HTTP response
*/
handleResponse = <T extends any>(response: Response): Promise<T> => {
// @ts-ignore
return checkIfLoggedOut(response)
.then(convertJSON)
.then(checkForErrors);
};
/**
* Perform a GET request
*
* @param url the URL to access
* @param options optional fetch configuration
* @returns {Promise<any>} a promise with the data or error returned
* @param url The URL to access
* @param options Optional fetch configuration
* @returns A promise with the data or error returned
*/
get(url: string, options = {}) {
return fetch(urlFor(url), {
credentials: credentialType(),
get = <T extends any>(url: string, options: RequestInit = {}): Promise<T> => {
// @ts-ignore
return fetch(this.urlFor(url), {
method: "GET",
credentials: this.credentialType(),
...options
})
.then(checkIfLoggedOut)
.then(convertJSON)
.catch(convertJSON)
.then(checkForErrors);
},
}).then(this.handleResponse);
};
/**
* Perform a POST request
*
* @param url the URL to access
* @param data the data to send
* @returns {Promise<any>} a promise with the data or error returned
* @param url The URL to access
* @param data The data to send
* @returns A promise with the data or error returned
*/
post(url: string, data: {}) {
return fetch(urlFor(url), {
post = <T extends any>(url: string, data: object): Promise<T> => {
// @ts-ignore
return fetch(this.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);
},
credentials: this.credentialType()
}).then(this.handleResponse);
};
/**
* Perform a PUT request
*
* @param url the URL to access
* @param data the data to send
* @returns {Promise<any>} a promise with the data or error returned
* @param url The URL to access
* @param data The data to send
* @returns A promise with the data or error returned
*/
put(url: string, data: {}) {
return fetch(urlFor(url), {
put = <T extends any>(url: string, data: object): Promise<T> => {
// @ts-ignore
return fetch(this.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);
},
credentials: this.credentialType()
}).then(this.handleResponse);
};
/**
* Perform a DELETE request
*
* @param url the URL to access
* @returns {Promise<any>} a promise with the data or error returned
* @param url The URL to access
* @returns A promise with the data or error returned
*/
delete(url: string) {
return fetch(urlFor(url), {
delete = <T extends any>(url: string): Promise<T> => {
// @ts-ignore
return fetch(this.urlFor(url), {
method: "DELETE",
credentials: credentialType()
})
.then(checkIfLoggedOut)
.then(convertJSON)
.catch(convertJSON)
.then(checkForErrors);
}
};
credentials: this.credentialType()
}).then(this.handleResponse);
};
/**
* Get the URL for an endpoint
*
* @param endpoint The endpoint
* @returns The URL for the endpoint
*/
urlFor = (endpoint: string): string => {
return this.config.apiPath + "/" + endpoint;
};
/**
* Get the credential type for requests
*
* @returns The credential type
*/
credentialType = (): RequestCredentials => {
// Development API requests may 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 this.config.developmentMode ? "include" : "same-origin";
};
}
/**
* 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
* @param response The Response from fetch
* @return If logged in, the response, otherwise a canceled promise
*/
const checkIfLoggedOut = (response: Response) => {
export const checkIfLoggedOut = (response: Response): Promise<Response> => {
if (api.loggedIn && response.status === 401) {
// Clear the user's old session and refresh the page
document.cookie =
@@ -120,22 +146,24 @@ const checkIfLoggedOut = (response: Response) => {
* @param data a Response or Error
* @returns {*} a promise with the parsed JSON, or the error
*/
const convertJSON = (data: any): Promise<any> => {
if (data.isCanceled || data instanceof Error) {
export const convertJSON = <T extends any>(
data: Response | Error | CanceledError
): Promise<T> => {
if ((data as CanceledError).isCanceled || data instanceof Error) {
return Promise.reject(data);
}
return data.json();
return (data as Response).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
* @returns A resolving promise with the data if no error, otherwise a
* rejecting promise with the error
*/
const checkForErrors = (data: any): Promise<any> => {
export const checkForErrors = <T extends any>(data: T): Promise<T> => {
if (data.error) {
return Promise.reject(data.error);
}
@@ -143,46 +171,20 @@ const checkForErrors = (data: any): Promise<any> => {
return Promise.resolve(data);
};
/**
* Get the URL for an endpoint
*
* @param endpoint the endpoint
* @returns {string} the URL for the endpoint
*/
const urlFor = (endpoint: string): string => {
let apiLocation;
if (config.fakeAPI) {
apiLocation = process.env.PUBLIC_URL + "/fakeAPI";
} else {
apiLocation = process.env.PUBLIC_URL + "/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";
};
/**
* Convert an object into GET parameters. The object must be flat (only
* key-value pairs).
*
* @param params the parameters object
* @returns {string} the parameters converted into GET parameter form
* @param params The parameters object
* @returns The parameters converted into GET parameter form
*/
export const paramsToString = (params: any) =>
Object.keys(params)
export const paramsToString = (params: {
[key: string]: string | number;
}): string => {
return Object.keys(params)
.map(key => key + "=" + params[key])
.join("&");
};
/**
* Convert a time range into GET parameters
@@ -190,8 +192,9 @@ export const paramsToString = (params: any) =>
* @param range The time range to convert
* @return The time range as GET parameters
*/
export const timeRangeToParams = (range: TimeRange) =>
paramsToString({
export const timeRangeToParams = (range: TimeRange) => {
return paramsToString({
from: range.from.unix(),
until: range.until.unix()
});
};
+9 -3
View File
@@ -15,8 +15,13 @@ import { reactI18nextModule } from "react-i18next";
import config from "../config";
import languages from "../languages.json";
export function setupI18n() {
i18n
/**
* Set up the internationalization service
*
* @param ajax An optional ajax function to use when fetching translations
*/
export function setupI18n(ajax?: any) {
return i18n
.use(XHR)
.use(LanguageDetector)
.use(reactI18nextModule)
@@ -46,7 +51,8 @@ export function setupI18n() {
escapeValue: false
},
backend: {
loadPath: process.env.PUBLIC_URL + "/i18n/{{lng}}/{{ns}}.json"
loadPath: process.env.PUBLIC_URL + "/i18n/{{lng}}/{{ns}}.json",
ajax
},
react: {
// Wait until translations are loaded before rendering
+3 -3
View File
@@ -21,9 +21,9 @@ const Blacklist: FunctionComponent<WithNamespaces> = props => {
<ListPage
title={`${t("Blacklist")} (${t("Exact")})`}
placeholder={t("Add a domain or hostname (example.com or example)")}
add={api.addBlacklist}
remove={api.removeBlacklist}
refresh={api.getBlacklist}
onAdd={api.addBlacklist}
onRemove={api.removeBlacklist}
onRefresh={api.getBlacklist}
isValid={isValidHostname}
validationErrorMsg={t("Not a valid hostname")}
{...props}
+2 -15
View File
@@ -8,13 +8,7 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import React, {
ChangeEvent,
Component,
FormEvent,
Fragment,
KeyboardEvent
} from "react";
import React, { ChangeEvent, Component, FormEvent, Fragment } from "react";
import { Redirect } from "react-router-dom";
import sha from "sha.js";
import api from "../util/api";
@@ -78,14 +72,7 @@ class Login extends Component<LoginProps, LoginState> {
// Send the password to the API to authenticate the user
api
.authenticate(hashedPassword)
.then(data => {
// Verify status
if (data.status !== "success") {
console.log("Failed to log in:");
console.log(data);
return;
}
.then(() => {
api.loggedIn = true;
if (config.fakeAPI) {
+3 -3
View File
@@ -21,9 +21,9 @@ const Regexlist: FunctionComponent<WithNamespaces> = props => {
<ListPage
title={`${t("Blacklist")} (${t("Regex")})`}
placeholder={t("Input a regular expression")}
add={api.addRegexlist}
remove={api.removeRegexlist}
refresh={api.getRegexlist}
onAdd={api.addRegexlist}
onRemove={api.removeRegexlist}
onRefresh={api.getRegexlist}
isValid={isValidRegex}
validationErrorMsg={t("Not a valid regular expression")}
{...props}
+3 -3
View File
@@ -21,9 +21,9 @@ const Whitelist: FunctionComponent<WithNamespaces> = props => {
<ListPage
title={t("Whitelist")}
placeholder={t("Add a domain or hostname (example.com or example)")}
add={api.addWhitelist}
remove={api.removeWhitelist}
refresh={api.getWhitelist}
onAdd={api.addWhitelist}
onRemove={api.removeWhitelist}
onRefresh={api.getWhitelist}
isValid={isValidHostname}
validationErrorMsg={t("Not a valid hostname")}
{...props}