Convert dashboard components to TypeScript

Also fixed the format check to use TypeScript files.

Signed-off-by: Mcat12 <newtoncat12@yahoo.com>
This commit is contained in:
Mcat12
2019-01-24 20:57:51 -08:00
parent 0af3e21ab2
commit 3134c8101d
40 changed files with 509 additions and 277 deletions
+6
View File
@@ -1378,6 +1378,12 @@
"loader-utils": "^1.1.0"
}
},
"@types/chart.js": {
"version": "2.7.42",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.7.42.tgz",
"integrity": "sha512-+0v+PV2J9wsV7u36Vqt5Oke7ugtiZqNeYJgNk5mgNMkEkqtjxo2Q9N5Q9znHlgmXeOTfQL6e1sfcAbTnPHAzlA==",
"dev": true
},
"@types/cheerio": {
"version": "0.22.10",
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.10.tgz",
+3 -2
View File
@@ -4,6 +4,7 @@
"private": true,
"homepage": "/admin",
"devDependencies": {
"@types/chart.js": "^2.7.42",
"@types/enzyme": "^3.1.15",
"@types/enzyme-adapter-react-16": "^1.0.3",
"@types/fetch-mock": "^7.2.1",
@@ -76,8 +77,8 @@
"update-translations": "node update-translations.js",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
"format": "prettier --write \"src/**/*.js\"",
"check-format": "prettier --list-different \"src/**/*.js\""
"format": "prettier --write \"src/**/*.tsx\"",
"check-format": "prettier --list-different \"src/**/*.tsx\""
},
"browserslist": [
">0.2%",
+3 -3
View File
@@ -13,9 +13,9 @@ import React, { FunctionComponent } from "react";
export type AlertType = "info" | "success" | "danger";
export interface AlertProps {
type: AlertType,
onClick: () => void,
message: string
type: AlertType;
onClick: () => void;
message: string;
}
const Alert: FunctionComponent<AlertProps> = (props: AlertProps) => {
+7 -6
View File
@@ -17,7 +17,12 @@ import { mobileSidebarHide } from "./Header";
import api from "../../util/api";
import StatusBadge from "./StatusBadge";
import NavDropdown from "./NavDropdown";
import { RouteCustomItem, RouteData, RouteGroup, RouteItem } from "../../routes";
import {
RouteCustomItem,
RouteData,
RouteGroup,
RouteItem
} from "../../routes";
import i18next from "i18next";
export const isDropdownOpen = (
@@ -88,11 +93,7 @@ export interface SidebarProps extends WithNamespaces {
location: Location;
}
const Sidebar = ({
items,
t,
location
}: SidebarProps) => {
const Sidebar = ({ items, t, location }: SidebarProps) => {
return (
<div className="sidebar">
<nav className="sidebar-nav">
@@ -35,7 +35,7 @@ it("creates nav items with correct data", () => {
icon: "test-icon",
name: "testName",
auth: false,
component: () => <div/>
component: () => <div />
};
const key = "testKey";
const wrapper = shallow(
@@ -8,11 +8,21 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import React, { Component } from "react";
import PropTypes from "prop-types";
import React, { Component, RefObject } from "react";
import { ChartTooltipOptions } from "chart.js";
import { Line } from "react-chartjs-2";
class ChartTooltip extends Component {
state = {
export interface ChartTooltipProps {
chart: RefObject<Line>;
handler: ChartTooltipOptions;
}
export interface ChartTooltipState {
tooltip: any | null;
}
class ChartTooltip extends Component<ChartTooltipProps, ChartTooltipState> {
state: ChartTooltipState = {
tooltip: null
};
@@ -43,12 +53,15 @@ class ChartTooltip extends Component {
return null;
// Get the graph's position data so we can offset the tooltip
const position = this.props.chart.current.chartInstance.canvas.getBoundingClientRect();
const position = this.props.chart.current.chartInstance.canvas!.getBoundingClientRect();
let width = tooltip.caretX;
// Prevent compression of the tooltip at the right edge of the screen
if (document.offsetWidth - tooltip.caretX < 400)
// @ts-ignore
if (document.offsetWidth - tooltip.caretX < 400) {
// @ts-ignore
width = document.offsetWidth - 400;
}
// Prevent tooltip disappearing behind the sidebar
if (tooltip.caretX < 100) width = 100;
@@ -68,13 +81,13 @@ class ChartTooltip extends Component {
let data = [];
if (tooltip.body) {
data = tooltip.body
.map(body => body.lines)
.map((item, i) => ({
.map((body: any) => body.lines)
.map((item: any, i: number) => ({
data: item[0],
colors: tooltip.labelColors[i]
}));
}
data.sort((a, b) =>
data.sort((a: any, b: any) =>
a.data.split(": ")[0].localeCompare(b.data.split(": ")[0])
);
@@ -82,14 +95,14 @@ class ChartTooltip extends Component {
<div className="chartjs-tooltip" style={style}>
<table>
<thead>
{tooltip.title.map((title, i) => (
{tooltip.title.map((title: string, i: number) => (
<tr key={i}>
<th>{title}</th>
</tr>
))}
</thead>
<tbody>
{data.map((item, i) => (
{data.map((item: any, i: number) => (
<tr key={i}>
<td>
<span
@@ -111,11 +124,4 @@ class ChartTooltip extends Component {
}
}
ChartTooltip.propTypes = {
chart: PropTypes.object.isRequired,
handler: PropTypes.shape({
custom: PropTypes.func.isRequired
}).isRequired
};
export default ChartTooltip;
@@ -8,24 +8,26 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import React, { Component } from "react";
import React, { Component, RefObject } from "react";
import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import { Line } from "react-chartjs-2";
import { withNamespaces } from "react-i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { padNumber } from "../../util";
import api from "../../util/api";
import api, { ApiClientsGraph } from "../../util/api";
import ChartTooltip from "./ChartTooltip";
import { WithAPIData } from "../common/WithAPIData";
import { ChartDataSets, ChartOptions } from "chart.js";
class ClientsGraph extends Component {
static propTypes = {
loading: PropTypes.bool.isRequired,
labels: PropTypes.array.isRequired,
datasets: PropTypes.array.isRequired
};
export interface ClientsGraphProps {
loading: boolean;
labels: Array<string>;
datasets: Array<ChartDataSets>;
}
constructor(props) {
class ClientsGraph extends Component<ClientsGraphProps & WithNamespaces, {}> {
private readonly graphRef: RefObject<Line>;
constructor(props: ClientsGraphProps & WithNamespaces) {
super(props);
this.graphRef = React.createRef();
}
@@ -33,19 +35,20 @@ class ClientsGraph extends Component {
render() {
const { t } = this.props;
const options = {
const options: ChartOptions = {
tooltips: {
enabled: false,
mode: "x-axis",
custom: () => "placeholder",
itemSort: function(a, b) {
itemSort: (a, b) => {
// @ts-ignore
return b.yLabel - a.yLabel;
},
callbacks: {
title: tooltipItem => {
const time = tooltipItem[0].xLabel.match(/(\d?\d):?(\d?\d?)/);
const hour = parseInt(time[1], 10);
const minute = parseInt(time[2], 10) || 0;
const time = tooltipItem[0].xLabel!.match(/(\d?\d):?(\d?\d?)/);
const hour = parseInt(time![1], 10);
const minute = parseInt(time![2], 10) || 0;
const from = padNumber(hour) + ":" + padNumber(minute - 5) + ":00";
const to = padNumber(hour) + ":" + padNumber(minute + 4) + ":59";
@@ -53,7 +56,7 @@ class ClientsGraph extends Component {
},
label: (tooltipItems, data) => {
return (
data.datasets[tooltipItems.datasetIndex].label +
data.datasets![tooltipItems.datasetIndex!].label +
": " +
tooltipItems.yLabel
);
@@ -117,7 +120,7 @@ class ClientsGraph extends Component {
{// Now you're thinking with portals!
ReactDOM.createPortal(
<ChartTooltip chart={this.graphRef} handler={options.tooltips} />,
<ChartTooltip chart={this.graphRef} handler={options.tooltips!} />,
document.body
)}
</div>
@@ -132,7 +135,7 @@ class ClientsGraph extends Component {
* @returns {{labels: Date[], datasets: Array, loading: boolean}} ClientsGraph
* props
*/
export const transformData = data => {
export const transformData = (data: ApiClientsGraph) => {
// Remove last data point as it's not yet finished
const overTime = data.over_time.slice(0, -1);
@@ -146,7 +149,7 @@ export const transformData = data => {
"#b0bec5"
];
const labels = overTime.map(step => new Date(1000 * step.timestamp));
const datasets = [];
const datasets: Array<ChartDataSets> = [];
// Fill in dataset metadata
let i = 0;
@@ -175,11 +178,13 @@ export const transformData = data => {
for (let step of overTime) {
for (let destination in datasets) {
if (datasets.hasOwnProperty(destination))
datasets[destination].data.push(step.data[destination]);
(datasets[destination].data as Array<number>).push(
step.data[destination]
);
}
}
datasets.sort((a, b) => a.label.localeCompare(b.label));
datasets.sort((a, b) => a.label!.localeCompare(b.label!));
return { labels, datasets, loading: false };
};
@@ -195,11 +200,12 @@ export const loadingProps = {
export const TranslatedClientsGraph = withNamespaces("dashboard")(ClientsGraph);
export default props => (
export default (props: any) => (
<WithAPIData
apiCall={api.getClientsGraph}
repeatOptions={{
interval: 10 * 60 * 1000
interval: 10 * 60 * 1000,
ignoreCancel: true
}}
renderInitial={() => (
<TranslatedClientsGraph {...loadingProps} {...props} />
@@ -8,28 +8,33 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import React, { Component } from "react";
import React, { Component, MouseEvent, RefObject } from "react";
import { Doughnut } from "react-chartjs-2";
import PropTypes from "prop-types";
import { WithAPIData } from "../common/WithAPIData";
import { ChartOptions } from "chart.js";
export class GenericDoughnutChart extends Component {
static propTypes = {
title: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.array.isRequired,
colors: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired
};
export interface GenericDoughnutChartProps {
title: string;
loading: boolean;
data: Array<number>;
colors: Array<string>;
labels: Array<string>;
}
constructor(props) {
export class GenericDoughnutChart extends Component<
GenericDoughnutChartProps,
{}
> {
private readonly chartRef: RefObject<Doughnut>;
constructor(props: GenericDoughnutChartProps) {
super(props);
this.chartRef = React.createRef();
}
handleClick = (e, index) => {
handleClick = (e: MouseEvent, index: number) => {
// Hide the entry by clicking on the internal legend item
const chart = this.chartRef.current.chartInstance;
const chart: any = this.chartRef.current!.chartInstance;
chart.legend.options.onClick.call(
chart,
e,
@@ -41,17 +46,20 @@ export class GenericDoughnutChart extends Component {
};
render() {
const options = {
const options: ChartOptions = {
legend: { display: false },
tooltips: {
enabled: true,
callbacks: {
title: () => this.props.title,
label: (tooltipItems, data) => {
const dataset = data.datasets[tooltipItems.datasetIndex];
const label = data.labels[tooltipItems.index];
const dataset = data.datasets![tooltipItems.datasetIndex!];
const label = data.labels![tooltipItems.index!];
return (
label + ": " + dataset.data[tooltipItems.index].toFixed(1) + "%"
label +
": " +
(dataset.data![tooltipItems.index!] as number).toFixed(1) +
"%"
);
}
}
@@ -132,14 +140,19 @@ export class GenericDoughnutChart extends Component {
}
}
export interface ChartItem {
name: string;
ip?: string;
percent: number;
}
/**
* Transform the API data into props for GenericDoughnutChart
*
* @param apiData the API data
* @returns {{loading: boolean, data: Array, colors: Array, labels: Array}}
* GenericDoughnutChart props
* @returns GenericDoughnutChartProps
*/
export const transformData = apiData => {
export const transformData = (apiData: Array<ChartItem>) => {
const colors = [
"#20a8d8",
"#f86c6b",
@@ -157,7 +170,7 @@ export const transformData = apiData => {
let i = 0;
for (let entry of apiData) {
data.push(entry.percent);
labels.push(entry.name.length !== 0 ? entry.name : entry.ip);
labels.push(entry.name.length !== 0 ? entry.name : entry.ip!);
usedColors.push(
// If we ran out of colors, make a random one
i < colors.length
@@ -189,21 +202,32 @@ export const loadingProps = {
labels: []
};
export default ({ apiCall, title, apiHandler, ...props }) => (
<WithAPIData
apiCall={apiCall}
renderInitial={() => (
<GenericDoughnutChart title={title} {...loadingProps} {...props} />
)}
renderOk={data => (
<GenericDoughnutChart
title={title}
{...transformData(apiHandler(data))}
{...props}
/>
)}
renderErr={() => (
<GenericDoughnutChart title={title} {...loadingProps} {...props} />
)}
/>
);
export default function<T>({
apiCall,
title,
apiHandler,
...props
}: {
apiCall: () => Promise<T>;
title: string;
apiHandler: (data: T) => Array<ChartItem>;
}) {
return (
<WithAPIData
apiCall={apiCall}
renderInitial={() => (
<GenericDoughnutChart title={title} {...loadingProps} {...props} />
)}
renderOk={data => (
<GenericDoughnutChart
title={title}
{...transformData(apiHandler(data))}
{...props}
/>
)}
renderErr={() => (
<GenericDoughnutChart title={title} {...loadingProps} {...props} />
)}
/>
);
}
@@ -9,25 +9,26 @@
* Please see LICENSE file for your rights under this license. */
import React, { Component } from "react";
import PropTypes from "prop-types";
import { Line } from "react-chartjs-2";
import { withNamespaces } from "react-i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { padNumber } from "../../util";
import api from "../../util/api";
import api, { ApiHistoryGraphItem } from "../../util/api";
import { WithAPIData } from "../common/WithAPIData";
import { ChartData, ChartOptions } from "chart.js";
import { Line } from "react-chartjs-2";
class QueriesGraph extends Component {
static propTypes = {
loading: PropTypes.bool.isRequired,
labels: PropTypes.array.isRequired,
domains_over_time: PropTypes.array.isRequired,
blocked_over_time: PropTypes.array.isRequired
};
export interface QueriesGraphProps {
loading: boolean;
labels: Array<Date>;
domains_over_time: Array<number>;
blocked_over_time: Array<number>;
}
class QueriesGraph extends Component<QueriesGraphProps & WithNamespaces, {}> {
render() {
const { t } = this.props;
const data = {
const data: ChartData = {
// @ts-ignore
labels: this.props.labels,
datasets: [
{
@@ -57,15 +58,15 @@ class QueriesGraph extends Component {
]
};
const options = {
const options: ChartOptions = {
tooltips: {
enabled: true,
mode: "x-axis",
callbacks: {
title: tooltipItem => {
const time = tooltipItem[0].xLabel.match(/(\d?\d):?(\d?\d?)/);
const hour = parseInt(time[1], 10);
const minute = parseInt(time[2], 10) || 0;
const time = tooltipItem[0].xLabel!.match(/(\d?\d):?(\d?\d?)/);
const hour = parseInt(time![1], 10);
const minute = parseInt(time![2], 10) || 0;
const from = padNumber(hour) + ":" + padNumber(minute - 5) + ":00";
const to = padNumber(hour) + ":" + padNumber(minute + 4) + ":59";
@@ -74,19 +75,17 @@ class QueriesGraph extends Component {
label: (tooltipItems, data) => {
if (tooltipItems.datasetIndex === 1) {
let percentage = 0.0;
const total = parseInt(
data.datasets[0].data[tooltipItems.index],
10
);
const blocked = parseInt(
data.datasets[1].data[tooltipItems.index],
10
);
const total = data.datasets![0].data![
tooltipItems.index!
] as number;
const blocked = data.datasets![1].data![
tooltipItems.index!
] as number;
if (total > 0) percentage = (100.0 * blocked) / total;
return (
data.datasets[tooltipItems.datasetIndex].label +
data.datasets![tooltipItems.datasetIndex].label +
": " +
tooltipItems.yLabel +
" (" +
@@ -95,7 +94,7 @@ class QueriesGraph extends Component {
);
} else
return (
data.datasets[tooltipItems.datasetIndex].label +
data.datasets![tooltipItems.datasetIndex!].label +
": " +
tooltipItems.yLabel
);
@@ -157,7 +156,9 @@ class QueriesGraph extends Component {
* @returns {{loading: boolean, labels: Date[], domains_over_time: *,
* blocked_over_time: any[]}} QueriesGraph props
*/
export const transformData = data => {
export const transformData = (
data: Array<ApiHistoryGraphItem>
): QueriesGraphProps => {
// Remove last data point as it's not yet finished
data = data.slice(0, -1);
@@ -185,11 +186,12 @@ export const loadingProps = {
export const TranslatedQueriesGraph = withNamespaces("dashboard")(QueriesGraph);
export default props => (
export default (props: any) => (
<WithAPIData
apiCall={api.getHistoryGraph}
repeatOptions={{
interval: 10 * 60 * 1000
interval: 10 * 60 * 1000,
ignoreCancel: true
}}
renderInitial={() => (
<TranslatedQueriesGraph {...loadingProps} {...props} />
@@ -9,11 +9,11 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { withNamespaces } from "react-i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import api from "../../util/api";
import GenericDoughnutChart from "./GenericDoughnutChart";
const QueryTypesChart = ({ t }) => (
const QueryTypesChart = ({ t }: WithNamespaces) => (
<GenericDoughnutChart
title={t("Query Types")}
apiCall={api.getQueryTypes}
@@ -9,21 +9,19 @@
* Please see LICENSE file for your rights under this license. */
import React, { Component, Fragment } from "react";
import PropTypes from "prop-types";
import { withNamespaces } from "react-i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { WithAPIData } from "../common/WithAPIData";
import api from "../../util/api";
import api, { ApiSummary } from "../../util/api";
class SummaryStats extends Component {
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
};
export interface SummaryStatsProps {
totalQueries: string;
blockedQueries: string;
percentBlocked: string;
gravityDomains: string;
uniqueClients: number;
}
class SummaryStats extends Component<SummaryStatsProps & WithNamespaces, {}> {
render() {
const { t } = this.props;
@@ -96,7 +94,7 @@ class SummaryStats extends Component {
* @param data the API data
* @returns {*} the transformed props
*/
export const transformData = data => ({
export const transformData = (data: ApiSummary): SummaryStatsProps => ({
totalQueries: Object.keys(data.total_queries)
.reduce((total, queryType) => total + data.total_queries[queryType], 0)
.toLocaleString(),
@@ -110,33 +108,33 @@ export const transformData = data => ({
* The props the summary stats should use when it fails to get the API data
* (it does not need the error object)
*/
export const errorProps = {
export const errorProps: SummaryStatsProps = {
totalQueries: "Lost",
blockedQueries: "Connection",
percentBlocked: "To",
gravityDomains: "API",
uniqueClients: ""
uniqueClients: 0
};
/**
* The props used to show a loading state
*/
export const initialProps = {
export const initialProps: SummaryStatsProps = {
blockedQueries: "---",
totalQueries: "---",
percentBlocked: "---",
gravityDomains: "---",
uniqueClients: "---"
uniqueClients: 0
};
export const TranslatedSummaryStats = withNamespaces(["common", "dashboard"])(
SummaryStats
);
export default props => (
export default (props: any) => (
<WithAPIData
apiCall={api.getSummary}
repeatOptions={{ interval: 5000 }}
repeatOptions={{ interval: 5000, ignoreCancel: true }}
renderInitial={() => (
<TranslatedSummaryStats {...initialProps} {...props} />
)}
@@ -9,9 +9,15 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { withNamespaces } from "react-i18next";
import api from "../../util/api";
import { WithNamespaces, withNamespaces } from "react-i18next";
import api, { ApiTopBlocked, ApiTopDomainItem } from "../../util/api";
import TopTable from "./TopTable";
import i18next from "i18next";
export interface TopBlockedData {
totalBlocked: number;
topBlocked: Array<ApiTopDomainItem>;
}
/**
* Transform the API data into the form used in generateRows
@@ -19,7 +25,7 @@ import TopTable from "./TopTable";
* @param data the API data
* @returns {{totalBlocked: number, topBlocked: *}} the parsed data
*/
export const transformData = data => ({
export const transformData = (data: ApiTopBlocked): TopBlockedData => ({
totalBlocked: data.blocked_queries,
topBlocked: data.top_domains
});
@@ -30,7 +36,9 @@ export const transformData = data => ({
* @param t the translation function
* @returns {function(*): any[]} a function to generate rows of top blocked
*/
export const generateRows = t => data => {
export const generateRows = (t: i18next.TranslationFunction) => (
data: TopBlockedData
) => {
return data.topBlocked.map(item => {
const percentage = (item.count / data.totalBlocked) * 100;
@@ -57,7 +65,7 @@ export const generateRows = t => data => {
});
};
const TopBlocked = ({ t, ...props }) => (
const TopBlocked = ({ t, ...props }: WithNamespaces) => (
<TopTable
{...props}
title={t("Top Blocked Domains")}
@@ -9,9 +9,15 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { withNamespaces } from "react-i18next";
import api from "../../util/api";
import { WithNamespaces, withNamespaces } from "react-i18next";
import api, { ApiClient, ApiTopClients } from "../../util/api";
import TopTable from "./TopTable";
import i18next from "i18next";
export interface TopClientsData {
totalQueries: number;
topClients: Array<ApiClient>;
}
/**
* Transform the API data into the form used in generateRows
@@ -19,7 +25,7 @@ import TopTable from "./TopTable";
* @param data the API data
* @returns {{totalQueries: number, topClients: *}} the parsed data
*/
export const transformData = data => ({
export const transformData = (data: ApiTopClients): TopClientsData => ({
totalQueries: data.total_queries,
topClients: data.top_clients
});
@@ -30,7 +36,9 @@ export const transformData = data => ({
* @param t the translation function
* @returns {function(*): any[]} a function to generate rows of top clients
*/
export const generateRows = t => data => {
export const generateRows = (t: i18next.TranslationFunction) => (
data: TopClientsData
) => {
return data.topClients.map(item => {
const percentage = (item.count / data.totalQueries) * 100;
@@ -57,7 +65,7 @@ export const generateRows = t => data => {
});
};
const TopClients = ({ t, ...props }) => (
const TopClients = ({ t, ...props }: WithNamespaces) => (
<TopTable
{...props}
title={t("Top Clients")}
@@ -9,9 +9,15 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { withNamespaces } from "react-i18next";
import api from "../../util/api";
import { WithNamespaces, withNamespaces } from "react-i18next";
import api, { ApiTopDomainItem, ApiTopDomains } from "../../util/api";
import TopTable from "./TopTable";
import i18next from "i18next";
export interface TopDomainsData {
totalQueries: number;
topDomains: Array<ApiTopDomainItem>;
}
/**
* Transform the API data into the form used in generateRows
@@ -19,7 +25,7 @@ import TopTable from "./TopTable";
* @param data the API data
* @returns {{totalQueries: number, topDomains: *}} the parsed data
*/
export const transformData = data => ({
export const transformData = (data: ApiTopDomains): TopDomainsData => ({
totalQueries: data.total_queries,
topDomains: data.top_domains
});
@@ -30,7 +36,9 @@ export const transformData = data => ({
* @param t the translation function
* @returns {function(*): any[]} a function to generate rows of top domains
*/
export const generateRows = t => data => {
export const generateRows = (t: i18next.TranslationFunction) => (
data: TopDomainsData
) => {
return data.topDomains.map(item => {
const percentage = (item.count / data.totalQueries) * 100;
@@ -57,7 +65,7 @@ export const generateRows = t => data => {
});
};
const TopDomains = ({ t, ...props }) => (
const TopDomains = ({ t, ...props }: WithNamespaces) => (
<TopTable
{...props}
title={t("Top Permitted Domains")}
@@ -8,21 +8,21 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import React, { Component } from "react";
import PropTypes from "prop-types";
import React, { Component, ReactNode } from "react";
import { WithAPIData } from "../common/WithAPIData";
import { Subtract } from "react-i18next";
export class TopTable extends Component {
static propTypes = {
loading: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
headers: PropTypes.arrayOf(PropTypes.string).isRequired,
emptyMessage: PropTypes.string.isRequired,
isEmpty: PropTypes.func.isRequired,
generateRows: PropTypes.func.isRequired
};
export interface TopTableInnerProps<T> {
loading: boolean;
title: string;
data: T;
headers: Array<string>;
emptyMessage: string;
isEmpty: (data: T) => boolean;
generateRows: (data: T) => ReactNode;
}
export class TopTable<T> extends Component<TopTableInnerProps<T>, {}> {
static defaultProps = {
loading: true,
title: "",
@@ -86,7 +86,14 @@ export class TopTable extends Component {
}
}
export default ({
export interface TopTableProps<T, D>
extends Subtract<TopTableInnerProps<T>, { loading: boolean; data: T }> {
apiCall: () => Promise<D>;
initialData: T;
apiHandler: (data: D) => T;
}
export default function<T, D>({
title,
apiCall,
initialData,
@@ -96,47 +103,50 @@ export default ({
apiHandler,
generateRows,
...props
}) => (
<WithAPIData
apiCall={apiCall}
repeatOptions={{
interval: 10 * 60 * 1000
}}
renderInitial={() => (
<TopTable
title={title}
headers={headers}
emptyMessage={emptyMessage}
isEmpty={isEmpty}
generateRows={generateRows}
data={initialData}
loading={true}
{...props}
/>
)}
renderOk={data => (
<TopTable
title={title}
headers={headers}
emptyMessage={emptyMessage}
isEmpty={isEmpty}
generateRows={generateRows}
data={apiHandler(data)}
loading={false}
{...props}
/>
)}
renderErr={() => (
<TopTable
title={title}
headers={headers}
emptyMessage={emptyMessage}
isEmpty={isEmpty}
generateRows={generateRows}
data={initialData}
loading={true}
{...props}
/>
)}
/>
);
}: TopTableProps<T, D>) {
return (
<WithAPIData
apiCall={apiCall}
repeatOptions={{
interval: 10 * 60 * 1000,
ignoreCancel: true
}}
renderInitial={() => (
<TopTable
title={title}
headers={headers}
emptyMessage={emptyMessage}
isEmpty={isEmpty}
generateRows={generateRows}
data={initialData}
loading={true}
{...props}
/>
)}
renderOk={data => (
<TopTable
title={title}
headers={headers}
emptyMessage={emptyMessage}
isEmpty={isEmpty}
generateRows={generateRows}
data={apiHandler(data)}
loading={false}
{...props}
/>
)}
renderErr={() => (
<TopTable
title={title}
headers={headers}
emptyMessage={emptyMessage}
isEmpty={isEmpty}
generateRows={generateRows}
data={initialData}
loading={true}
{...props}
/>
)}
/>
);
}
@@ -9,11 +9,11 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { withNamespaces } from "react-i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import api from "../../util/api";
import GenericDoughnutChart from "./GenericDoughnutChart";
const UpstreamsChart = ({ t }) => (
const UpstreamsChart = ({ t }: WithNamespaces) => (
<GenericDoughnutChart
title={t("Queries Answered By Destination")}
apiCall={api.getUpstreams}
@@ -11,12 +11,13 @@
import React from "react";
import { shallow } from "enzyme";
import {
TranslatedClientsGraph,
loadingProps,
transformData,
loadingProps
TranslatedClientsGraph
} from "../ClientsGraph";
import { ApiClientsGraph } from "../../../util/api";
const fakeData = {
const fakeData: ApiClientsGraph = {
over_time: [
{ timestamp: 1513218354, data: [48476, 35688, 95153, 56971, 83497] },
{ timestamp: 1513218954, data: [53603, 88146, 17471, 3039, 33678] },
@@ -60,5 +61,5 @@ it("loads API data correctly", async () => {
);
expect(data.datasets[2].label).toEqual(fakeData.clients[2].name);
expect(data.datasets[1].label).toEqual(fakeData.clients[1].ip);
expect(data.datasets[0].data.length).toEqual(fakeData.over_time.length - 1);
expect(data.datasets[0].data!.length).toEqual(fakeData.over_time.length - 1);
});
@@ -11,12 +11,13 @@
import React from "react";
import { shallow } from "enzyme";
import {
ChartItem,
GenericDoughnutChart,
transformData,
loadingProps
loadingProps,
transformData
} from "../GenericDoughnutChart";
const fakeData = [
const fakeData: Array<ChartItem> = [
{ name: "roberta.net", ip: "8.239.48.32", percent: 0.38411761010240625 },
{ name: "", ip: "89.60.252.186", percent: 0.2830935477791041 },
{ name: "christop.net", ip: "181.219.42.222", percent: 0.6249293208519193 }
@@ -11,12 +11,15 @@
import React from "react";
import { shallow } from "enzyme";
import {
TranslatedSummaryStats,
errorProps,
transformData,
errorProps
TranslatedSummaryStats
} from "../SummaryStats";
import { ApiSummary } from "../../../util/api";
const fakeData = {
const tick = global.tick;
const fakeData: ApiSummary = {
active_clients: 2,
blocked_queries: 2281,
cached_queries: 3573,
@@ -9,9 +9,10 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { transformData, generateRows } from "../TopBlocked";
import { generateRows, transformData } from "../TopBlocked";
import { ApiTopBlocked } from "../../../util/api";
const fakeData = {
const fakeData: ApiTopBlocked = {
top_domains: [
{ domain: "jaron.info", count: 54316 },
{ domain: "candelario.info", count: 47470 },
@@ -9,9 +9,10 @@
* Please see LICENSE file for your rights under this license. */
import React from "react";
import { transformData, generateRows } from "../TopClients";
import { generateRows, transformData } from "../TopClients";
import { ApiTopClients } from "../../../util/api";
const fakeData = {
const fakeData: ApiTopClients = {
top_clients: [
{ name: "", ip: "21.43.61.185", count: 37586 },
{ name: "", ip: "224.77.182.60", count: 28664 },
@@ -10,8 +10,9 @@
import React from "react";
import { generateRows, transformData } from "../TopDomains";
import { ApiTopDomains } from "../../../util/api";
const fakeData = {
const fakeData: ApiTopDomains = {
top_domains: [
{ domain: "willow.com", count: 13739 },
{ domain: "maurine.info", count: 13397 },
+2 -2
View File
@@ -14,8 +14,8 @@ import api from "../../util/api";
import { Button } from "reactstrap";
export interface DomainListProps extends WithNamespaces {
domains: string[],
onRemove: (domain: string) => void
domains: string[];
onRemove: (domain: string) => void;
}
const DomainList = ({ domains, onRemove, t }: DomainListProps) => {
+11 -11
View File
@@ -16,20 +16,20 @@ import DomainList from "./DomainList";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
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>,
isValid: (domain: string) => boolean,
validationErrorMsg: string
title: string;
note?: {} | string;
placeholder: string;
add: (domain: string) => Promise<any | never>;
refresh: () => Promise<any | never>;
remove: (domain: string) => Promise<any | never>;
isValid: (domain: string) => boolean;
validationErrorMsg: string;
}
export interface ListPageState {
domains: string[],
message: string,
messageType: AlertType
domains: string[];
message: string;
messageType: AlertType;
}
export class ListPage extends Component<ListPageProps, ListPageState> {
@@ -10,7 +10,10 @@
import React from "react";
import { shallow, ShallowWrapper } from "enzyme";
import DomainInput, { DomainInputProps, DomainInputState } from "../DomainInput";
import DomainInput, {
DomainInputProps,
DomainInputState
} from "../DomainInput";
import api from "../../../util/api";
import { isValidDomain } from "../../../util/validate";
@@ -10,7 +10,11 @@
import React from "react";
import { shallow, ShallowWrapper } from "enzyme";
import ListPage, { ListPage as ListPageType, ListPageProps, ListPageState } from "../ListPage";
import ListPage, {
ListPage as ListPageType,
ListPageProps,
ListPageState
} from "../ListPage";
const ignoreAPI = global.ignoreAPI;
const tick = global.tick;
+12 -2
View File
@@ -9,7 +9,12 @@
* Please see LICENSE file for your rights under this license. */
import React, { Component, Fragment } from "react";
import ReactTable, { Filter, ReactTableFunction, RowInfo, RowRenderProps } from "react-table";
import ReactTable, {
Filter,
ReactTableFunction,
RowInfo,
RowRenderProps
} from "react-table";
import DateRangePicker from "react-bootstrap-daterangepicker";
import { Button } from "reactstrap";
import i18n from "i18next";
@@ -17,7 +22,12 @@ import i18next from "i18next";
import { WithNamespaces, withNamespaces } from "react-i18next";
import debounce from "lodash.debounce";
import moment, { Moment } from "moment";
import { CancelablePromise, ignoreCancel, makeCancelable, padNumber } from "../../util";
import {
CancelablePromise,
ignoreCancel,
makeCancelable,
padNumber
} from "../../util";
import api, { ApiHistoryResponse, ApiQuery } from "../../util/api";
import "react-table/react-table.css";
import "bootstrap-daterangepicker/daterangepicker.css";
@@ -13,17 +13,17 @@ import { Col, FormGroup, Input, Label } from "reactstrap";
import i18next from "i18next";
export interface ConditionalForwardingObject {
enabled: boolean,
routerIp: string,
domain: string
enabled: boolean;
routerIp: string;
domain: string;
}
export interface ConditionalForwardingSettingsProps {
settings: ConditionalForwardingObject,
onUpdate: (settings: ConditionalForwardingObject) => void,
settings: ConditionalForwardingObject;
onUpdate: (settings: ConditionalForwardingObject) => void;
isRouterIpValid: boolean;
isDomainValid: boolean;
t: i18next.TranslationFunction
t: i18next.TranslationFunction;
}
const ConditionalForwardingSettings = ({
+10 -1
View File
@@ -12,7 +12,16 @@ import React, { ChangeEvent, Component, FormEvent } from "react";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import api, { ApiDhcpSettings, ApiResultResponse } from "../../util/api";
import { Button, Col, Form, FormGroup, Input, InputGroup, InputGroupAddon, Label } from "reactstrap";
import {
Button,
Col,
Form,
FormGroup,
Input,
InputGroup,
InputGroupAddon,
Label
} from "reactstrap";
import { isValidHostname, isValidIpv4 } from "../../util/validate";
import Alert, { AlertType } from "../common/Alert";
+3 -1
View File
@@ -14,7 +14,9 @@ import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util";
import api, { ApiDnsSettings, ApiResultResponse } from "../../util/api";
import DnsList from "./DnsList";
import { Button, Col, Form, FormGroup } from "reactstrap";
import ConditionalForwardingSettings, { ConditionalForwardingObject } from "./ConditionalForwardingSettings";
import ConditionalForwardingSettings, {
ConditionalForwardingObject
} from "./ConditionalForwardingSettings";
import DnsOptionSettings, { DnsOptionsObject } from "./DnsOptionSettings";
import Alert, { AlertType } from "../common/Alert";
import { isValidHostname, isValidIpv4 } from "../../util/validate";
+1 -1
View File
@@ -12,7 +12,7 @@ import React from "react";
import { Button, ListGroupItem } from "reactstrap";
export interface DnsListItemProps {
address: string,
address: string;
onRemove: () => void;
}
+4 -1
View File
@@ -12,7 +12,10 @@ import React, { Component, RefObject } from "react";
import { Button, InputGroup, InputGroupAddon, ListGroupItem } from "reactstrap";
import { Typeahead } from "react-bootstrap-typeahead";
import { WithNamespaces, withNamespaces } from "react-i18next";
import { PreconfiguredUpstreamOption, preconfiguredUpstreamOptions } from "./preconfiguredUpstreams";
import {
PreconfiguredUpstreamOption,
preconfiguredUpstreamOptions
} from "./preconfiguredUpstreams";
export interface DnsListNewItemProps {
onAdd: (address: string) => void;
+3 -3
View File
@@ -14,9 +14,9 @@ import api, { ApiFtlDbResponse } from "../../util/api";
import { WithAPIData } from "../common/WithAPIData";
export interface FTLInfoProps {
fileSize: number,
queries: number,
sqliteVersion: string
fileSize: number;
queries: number;
sqliteVersion: string;
}
class FTLInfo extends Component<FTLInfoProps & WithNamespaces, {}> {
+1 -1
View File
@@ -21,7 +21,7 @@ export interface VersionCardProps extends WithNamespaces {
}
export interface VersionCardState {
collapsed: boolean
collapsed: boolean;
}
class VersionCard extends Component<VersionCardProps, VersionCardState> {
+7 -1
View File
@@ -14,7 +14,13 @@ import Header, { mobileSidebarHide } from "../components/common/Header";
import Sidebar from "../components/common/Sidebar";
import Footer from "../components/common/Footer";
import api from "../util/api";
import { nav, RouteCustomItem, RouteData, RouteGroup, RouteItem } from "../routes";
import {
nav,
RouteCustomItem,
RouteData,
RouteGroup,
RouteItem
} from "../routes";
import { GlobalContextProvider } from "../components/common/context";
import LayoutApplier from "../components/common/LayoutApplier";
+4 -1
View File
@@ -21,7 +21,10 @@ configure({ adapter: new Adapter() });
jest.mock("react-i18next", () => ({
// This mock makes sure any components using the withNamespaces HoC receive the t function as a prop
withNamespaces: () => (component: any) => {
component.defaultProps = { ...component.defaultProps, t: (key: string) => key };
component.defaultProps = {
...component.defaultProps,
t: (key: string) => key
};
return component;
}
}));
+7 -1
View File
@@ -8,7 +8,13 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
import { isPositiveNumber, isValidDomain, isValidIpv4, isValidIpv4OptionalPort, isValidRegex } from "../validate";
import {
isPositiveNumber,
isValidDomain,
isValidIpv4,
isValidIpv4OptionalPort,
isValidRegex
} from "../validate";
describe("Testing the validation functions", () => {
describe("isValidDomain", () => {
+100 -8
View File
@@ -108,6 +108,98 @@ export interface ApiStatus {
status: Status;
}
export interface ApiClientsGraph {
over_time: Array<ApiClientOverTime>;
clients: Array<ApiClientGraphInfo>;
}
export interface ApiClientOverTime {
timestamp: number;
data: Array<number>;
}
export interface ApiClientGraphInfo {
name: string;
ip: string;
}
export interface ApiHistoryGraphItem {
timestamp: number;
total_queries: number;
blocked_queries: number;
}
export interface ApiQueryType {
name: string;
count: number;
}
export interface ApiSummary {
gravity_size: number;
total_queries: {
A: number;
AAAA: number;
ANY: number;
SRV: number;
SOA: number;
PTR: number;
TXT: number;
[key: string]: number;
};
blocked_queries: number;
percent_blocked: number;
unique_domains: number;
forwarded_queries: number;
cached_queries: number;
reply_types: {
IP: number;
CNAME: number;
DOMAIN: number;
NODATA: number;
NXDOMAIN: number;
[key: string]: number;
};
total_clients: number;
active_clients: number;
status: string;
}
export interface ApiUpstreams {
upstreams: Array<{
name: string;
ip: string;
count: number;
}>;
forwarded_queries: number;
total_queries: number;
}
export interface ApiTopDomainItem {
domain: string;
count: number;
}
export interface ApiTopBlocked {
top_domains: Array<ApiTopDomainItem>;
blocked_queries: number;
}
export interface ApiTopDomains {
top_domains: Array<ApiTopDomainItem>;
total_queries: number;
}
export interface ApiTopClients {
top_clients: Array<ApiClient>;
total_queries: number;
}
export interface ApiClient {
name: string;
ip: string;
count: number;
}
export default {
loggedIn: false,
authenticate(key: string) {
@@ -118,25 +210,25 @@ export default {
logout() {
return http.delete("auth");
},
getSummary() {
getSummary(): Promise<ApiSummary> {
return http.get("stats/summary");
},
getHistoryGraph() {
getHistoryGraph(): Promise<Array<ApiHistoryGraphItem>> {
return http.get("stats/overTime/history");
},
getClientsGraph() {
getClientsGraph(): Promise<ApiClientsGraph> {
return http.get("stats/overTime/clients");
},
getQueryTypes() {
getQueryTypes(): Promise<Array<ApiQueryType>> {
return http.get("stats/query_types");
},
getUpstreams() {
getUpstreams(): Promise<ApiUpstreams> {
return http.get("stats/upstreams");
},
getTopDomains() {
getTopDomains(): Promise<ApiTopDomains> {
return http.get("stats/top_domains");
},
getTopBlocked() {
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
@@ -145,7 +237,7 @@ export default {
return http.get(url);
},
getTopClients() {
getTopClients(): Promise<ApiTopClients> {
return http.get("stats/top_clients");
},
getHistory(params: any): Promise<ApiHistoryResponse> {
+11 -3
View File
@@ -8,7 +8,13 @@
* 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,
KeyboardEvent
} from "react";
import { Redirect } from "react-router-dom";
import sha from "sha.js";
import api from "../util/api";
@@ -20,7 +26,7 @@ import config from "../config";
import { History, LocationDescriptorObject } from "history";
export interface LoginProps extends WithNamespaces {
location: LocationDescriptorObject<{ from: Location }>
location: LocationDescriptorObject<{ from: Location }>;
history: History;
}
@@ -97,7 +103,9 @@ class Login extends Component<LoginProps, LoginState> {
}
// Redirect to the page the user was originally going to, or if that doesn't exist, go to home
const locationState = this.props.location.state || { from: { pathname: "/" } };
const locationState = this.props.location.state || {
from: { pathname: "/" }
};
this.props.history.push(locationState.from.pathname);
})
// If there was an error, tell the user they used the wrong password