From 22d3c38df280528fcc54bfbb1854a96a5ceb5b86 Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 22 May 2019 17:59:57 +0300 Subject: [PATCH] + client: handle per-client settings --- client/src/__locales/en.json | 24 +- client/src/actions/clients.js | 86 ++++++ client/src/actions/index.js | 50 ++-- client/src/api/Api.js | 38 +++ client/src/components/Logs/Logs.css | 4 + .../src/components/Settings/Clients/Form.js | 215 +++++++++++++++ .../src/components/Settings/Clients/Modal.js | 57 ++++ .../src/components/Settings/Clients/index.js | 252 ++++++++++++++++++ client/src/components/Settings/index.js | 50 +++- client/src/components/ui/Modal.css | 8 +- client/src/containers/Settings.js | 12 + client/src/helpers/constants.js | 14 +- client/src/helpers/form.js | 9 +- client/src/helpers/helpers.js | 18 ++ client/src/reducers/clients.js | 63 +++++ client/src/reducers/index.js | 3 + 16 files changed, 863 insertions(+), 40 deletions(-) create mode 100644 client/src/actions/clients.js create mode 100644 client/src/components/Settings/Clients/Form.js create mode 100644 client/src/components/Settings/Clients/Modal.js create mode 100644 client/src/components/Settings/Clients/index.js create mode 100644 client/src/reducers/clients.js diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index e0cf54e7..1235d10f 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -19,6 +19,7 @@ "dhcp_config_saved": "Saved DHCP server config", "form_error_required": "Required field", "form_error_ip_format": "Invalid IPv4 format", + "form_error_mac_format": "Invalid MAC format", "form_error_positive": "Must be greater than 0", "dhcp_form_gateway_input": "Gateway IP", "dhcp_form_subnet_input": "Subnet mask", @@ -105,6 +106,7 @@ "rules_count_table_header": "Rules count", "last_time_updated_table_header": "Last time updated", "actions_table_header": "Actions", + "edit_table_action": "Edit", "delete_table_action": "Delete", "filters_and_hosts": "Filters and hosts blocklists", "filters_and_hosts_hint": "AdGuard Home understands basic adblock rules and hosts files syntax.", @@ -263,5 +265,25 @@ "dns_providers": "Here is a <0>list of known DNS providers to choose from.", "update_now": "Update now", "update_failed": "Auto-update failed. Please follow the steps<\/a> to update manually.", - "processing_update": "Please wait, AdGuard Home is being updated" + "processing_update": "Please wait, AdGuard Home is being updated", + "clients_title": "Clients", + "clients_desc": "Configure devices connected to AdGuard Home", + "settings_global": "Global", + "settings_custom": "Custom", + "add_client": "Add Client", + "table_client": "Client", + "table_name": "Name", + "save_btn": "Save", + "client_new": "New Client", + "client_identifier": "Identifier", + "ip_address": "IP address", + "client_identifier_desc": "Clients can be identified by the IP address or MAC address. Please note, that using MAC as identifier is possible only if AdGuard Home is also a <0>DHCP server", + "form_enter_ip": "Enter IP", + "form_enter_mac": "Enter MAC", + "form_client_name": "Enter client name", + "client_global_settings": "Use global settings", + "client_deleted": "Client \"{{key}}\" successfully deleted", + "client_added": "Client \"{{key}}\" successfully added", + "client_updated": "Client \"{{key}}\" successfully updated", + "table_statistics": "Statistics (last 24 hours)" } \ No newline at end of file diff --git a/client/src/actions/clients.js b/client/src/actions/clients.js new file mode 100644 index 00000000..1947ad25 --- /dev/null +++ b/client/src/actions/clients.js @@ -0,0 +1,86 @@ +import { createAction } from 'redux-actions'; +import { t } from 'i18next'; +import Api from '../api/Api'; +import { addErrorToast, addSuccessToast, getClients } from './index'; +import { CLIENT_ID } from '../helpers/constants'; + +const apiClient = new Api(); + +export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL'); + +export const addClientRequest = createAction('ADD_CLIENT_REQUEST'); +export const addClientFailure = createAction('ADD_CLIENT_FAILURE'); +export const addClientSuccess = createAction('ADD_CLIENT_SUCCESS'); + +export const addClient = config => async (dispatch) => { + dispatch(addClientRequest()); + try { + let data; + if (config.identifier === CLIENT_ID.MAC) { + const { ip, identifier, ...values } = config; + + data = { ...values }; + } else { + const { mac, identifier, ...values } = config; + + data = { ...values }; + } + + await apiClient.addClient(data); + dispatch(addClientSuccess()); + dispatch(toggleClientModal()); + dispatch(addSuccessToast(t('client_added', { key: config.name }))); + dispatch(getClients()); + } catch (error) { + dispatch(toggleClientModal()); + dispatch(addErrorToast({ error })); + dispatch(addClientFailure()); + } +}; + +export const deleteClientRequest = createAction('DELETE_CLIENT_REQUEST'); +export const deleteClientFailure = createAction('DELETE_CLIENT_FAILURE'); +export const deleteClientSuccess = createAction('DELETE_CLIENT_SUCCESS'); + +export const deleteClient = config => async (dispatch) => { + dispatch(deleteClientRequest()); + try { + await apiClient.deleteClient(config); + dispatch(deleteClientSuccess()); + dispatch(addSuccessToast(t('client_deleted', { key: config.name }))); + dispatch(getClients()); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(deleteClientFailure()); + } +}; + +export const updateClientRequest = createAction('UPDATE_CLIENT_REQUEST'); +export const updateClientFailure = createAction('UPDATE_CLIENT_FAILURE'); +export const updateClientSuccess = createAction('UPDATE_CLIENT_SUCCESS'); + +export const updateClient = (config, name) => async (dispatch) => { + dispatch(updateClientRequest()); + try { + let data; + if (config.identifier === CLIENT_ID.MAC) { + const { ip, identifier, ...values } = config; + + data = { name, data: { ...values } }; + } else { + const { mac, identifier, ...values } = config; + + data = { name, data: { ...values } }; + } + + await apiClient.updateClient(data); + dispatch(updateClientSuccess()); + dispatch(toggleClientModal()); + dispatch(addSuccessToast(t('client_updated', { key: name }))); + dispatch(getClients()); + } catch (error) { + dispatch(toggleClientModal()); + dispatch(addErrorToast({ error })); + dispatch(updateClientFailure()); + } +}; diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 070c9324..493a4140 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -4,7 +4,7 @@ import { t } from 'i18next'; import { showLoading, hideLoading } from 'react-redux-loading-bar'; import axios from 'axios'; -import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea } from '../helpers/helpers'; +import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea, sortClients } from '../helpers/helpers'; import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants'; import Api from '../api/Api'; @@ -213,14 +213,36 @@ export const getClientsSuccess = createAction('GET_CLIENTS_SUCCESS'); export const getClients = () => async (dispatch) => { dispatch(getClientsRequest()); try { - const clients = await apiClient.getGlobalClients(); - dispatch(getClientsSuccess(clients)); + const clients = await apiClient.getClients(); + const sortedClients = sortClients(clients); + dispatch(getClientsSuccess(sortedClients)); } catch (error) { dispatch(addErrorToast({ error })); dispatch(getClientsFailure()); } }; +export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST'); +export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE'); +export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS'); + +export const getTopStats = () => async (dispatch, getState) => { + dispatch(getTopStatsRequest()); + const timer = setInterval(async () => { + const state = getState(); + if (state.dashboard.isCoreRunning) { + clearInterval(timer); + try { + const stats = await apiClient.getGlobalStatsTop(); + dispatch(getTopStatsSuccess(stats)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getTopStatsFailure(error)); + } + } + }, 100); +}; + export const dnsStatusRequest = createAction('DNS_STATUS_REQUEST'); export const dnsStatusFailure = createAction('DNS_STATUS_FAILURE'); export const dnsStatusSuccess = createAction('DNS_STATUS_SUCCESS'); @@ -232,6 +254,7 @@ export const getDnsStatus = () => async (dispatch) => { dispatch(dnsStatusSuccess(dnsStatus)); dispatch(getVersion()); dispatch(getClients()); + dispatch(getTopStats()); } catch (error) { dispatch(addErrorToast({ error })); dispatch(initSettingsFailure()); @@ -289,27 +312,6 @@ export const getStats = () => async (dispatch) => { } }; -export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST'); -export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE'); -export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS'); - -export const getTopStats = () => async (dispatch, getState) => { - dispatch(getTopStatsRequest()); - const timer = setInterval(async () => { - const state = getState(); - if (state.dashboard.isCoreRunning) { - clearInterval(timer); - try { - const stats = await apiClient.getGlobalStatsTop(); - dispatch(getTopStatsSuccess(stats)); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(getTopStatsFailure(error)); - } - } - }, 100); -}; - export const getLogsRequest = createAction('GET_LOGS_REQUEST'); export const getLogsFailure = createAction('GET_LOGS_FAILURE'); export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 1743cc06..79abd2fb 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -409,4 +409,42 @@ export default class Api { }; return this.makeRequest(path, method, parameters); } + + // Per-client settings + GET_CLIENTS = { path: 'clients', method: 'GET' } + ADD_CLIENT = { path: 'clients/add', method: 'POST' } + DELETE_CLIENT = { path: 'clients/delete', method: 'POST' } + UPDATE_CLIENT = { path: 'clients/update', method: 'POST' } + + getClients() { + const { path, method } = this.GET_CLIENTS; + return this.makeRequest(path, method); + } + + addClient(config) { + const { path, method } = this.ADD_CLIENT; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + + deleteClient(config) { + const { path, method } = this.DELETE_CLIENT; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + + updateClient(config) { + const { path, method } = this.UPDATE_CLIENT; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } } diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css index d7df63bb..1b39f592 100644 --- a/client/src/components/Logs/Logs.css +++ b/client/src/components/Logs/Logs.css @@ -5,6 +5,10 @@ min-height: 26px; } +.logs__row--center { + justify-content: center; +} + .logs__row--overflow { overflow: hidden; } diff --git a/client/src/components/Settings/Clients/Form.js b/client/src/components/Settings/Clients/Form.js new file mode 100644 index 00000000..1874a746 --- /dev/null +++ b/client/src/components/Settings/Clients/Form.js @@ -0,0 +1,215 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Field, reduxForm, formValueSelector } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; +import flow from 'lodash/flow'; + +import { renderField, renderSelectField, ipv4, mac, required } from '../../../helpers/form'; +import { CLIENT_ID } from '../../../helpers/constants'; + +let Form = (props) => { + const { + t, + handleSubmit, + reset, + pristine, + submitting, + clientIdentifier, + useGlobalSettings, + toggleClientModal, + processingAdding, + processingUpdating, + } = props; + + return ( +
+
+
+
+ + client_identifier + + + +
+ {clientIdentifier === CLIENT_ID.IP && ( +
+ +
+ )} + {clientIdentifier === CLIENT_ID.MAC && ( +
+ +
+ )} +
+ + link + , + ]} + > + client_identifier_desc + +
+
+ +
+ +
+ +
+ Settings +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + +
+
+
+ ); +}; + +Form.propTypes = { + pristine: PropTypes.bool.isRequired, + handleSubmit: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + toggleClientModal: PropTypes.func.isRequired, + clientIdentifier: PropTypes.string, + useGlobalSettings: PropTypes.bool, + t: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingUpdating: PropTypes.bool.isRequired, +}; + +const selector = formValueSelector('clientForm'); + +Form = connect((state) => { + const clientIdentifier = selector(state, 'identifier'); + const useGlobalSettings = selector(state, 'use_global_settings'); + return { + clientIdentifier, + useGlobalSettings, + }; +})(Form); + +export default flow([ + withNamespaces(), + reduxForm({ + form: 'clientForm', + enableReinitialize: true, + }), +])(Form); diff --git a/client/src/components/Settings/Clients/Modal.js b/client/src/components/Settings/Clients/Modal.js new file mode 100644 index 00000000..2c7cf7ce --- /dev/null +++ b/client/src/components/Settings/Clients/Modal.js @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Form from './Form'; + +const Modal = (props) => { + const { + isModalOpen, + currentClientData, + handleSubmit, + toggleClientModal, + processingAdding, + processingUpdating, + } = props; + + return ( + toggleClientModal()} + > +
+
+

+ client_new +

+ +
+
+
+
+ ); +}; + +Modal.propTypes = { + isModalOpen: PropTypes.bool.isRequired, + currentClientData: PropTypes.object.isRequired, + handleSubmit: PropTypes.func.isRequired, + toggleClientModal: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingUpdating: PropTypes.bool.isRequired, +}; + +export default withNamespaces()(Modal); diff --git a/client/src/components/Settings/Clients/index.js b/client/src/components/Settings/Clients/index.js new file mode 100644 index 00000000..a8ed09ab --- /dev/null +++ b/client/src/components/Settings/Clients/index.js @@ -0,0 +1,252 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; +import ReactTable from 'react-table'; + +import { MODAL_TYPE, CLIENT_ID } from '../../../helpers/constants'; +import Card from '../../ui/Card'; +import Modal from './Modal'; + +class Clients extends Component { + handleFormAdd = (values) => { + this.props.addClient(values); + }; + + handleFormUpdate = (values, name) => { + this.props.updateClient(values, name); + }; + + handleSubmit = (values) => { + if (this.props.modalType === MODAL_TYPE.EDIT) { + this.handleFormUpdate(values, this.props.modalClientName); + } else { + this.handleFormAdd(values); + } + }; + + cellWrap = ({ value }) => ( +
+ + {value} + +
+ ); + + getClient = (name, clients) => { + const client = clients.find(item => name === item.name); + + if (client) { + const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP; + + return { + identifier, + use_global_settings: true, + ...client, + }; + } + + return { + identifier: 'ip', + use_global_settings: true, + }; + }; + + getStats = (ip, stats) => { + if (stats && stats.top_clients) { + return stats.top_clients[ip]; + } + + return ''; + }; + + columns = [ + { + Header: this.props.t('table_client'), + accessor: 'ip', + Cell: (row) => { + if (row.value) { + return ( +
+ + {row.value} (IP) + +
+ ); + } else if (row.original && row.original.mac) { + return ( +
+ + {row.original.mac} (MAC) + +
+ ); + } + + return ''; + }, + }, + { + Header: this.props.t('table_name'), + accessor: 'name', + Cell: this.cellWrap, + }, + { + Header: this.props.t('settings'), + accessor: 'use_global_settings', + maxWidth: 180, + minWidth: 150, + Cell: ({ value }) => { + const title = value ? ( + settings_global + ) : ( + settings_custom + ); + + return ( +
+
+ {title} +
+
+ ); + }, + }, + { + Header: this.props.t('table_statistics'), + accessor: 'statistics', + Cell: (row) => { + const clientIP = row.original.ip; + const clientStats = clientIP && this.getStats(clientIP, this.props.topStats); + + if (clientStats) { + return ( +
+
+ {clientStats} +
+
+ ); + } + + return '–'; + }, + }, + { + Header: this.props.t('actions_table_header'), + accessor: 'actions', + maxWidth: 220, + minWidth: 150, + Cell: (row) => { + const clientName = row.original.name; + const { + toggleClientModal, + deleteClient, + processingDeleting, + processingUpdating, + } = this.props; + + return ( +
+ + +
+ ); + }, + }, + ]; + + render() { + const { + t, + clients, + isModalOpen, + modalClientName, + toggleClientModal, + processingAdding, + processingUpdating, + } = this.props; + + const currentClientData = this.getClient(modalClientName, clients); + + return ( + + + + + + + + + ); + } +} + +Clients.propTypes = { + t: PropTypes.func.isRequired, + clients: PropTypes.array.isRequired, + topStats: PropTypes.object.isRequired, + toggleClientModal: PropTypes.func.isRequired, + deleteClient: PropTypes.func.isRequired, + addClient: PropTypes.func.isRequired, + updateClient: PropTypes.func.isRequired, + isModalOpen: PropTypes.bool.isRequired, + modalType: PropTypes.string.isRequired, + modalClientName: PropTypes.string.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingDeleting: PropTypes.bool.isRequired, + processingUpdating: PropTypes.bool.isRequired, +}; + +export default withNamespaces()(Clients); diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index e6c39cf7..9292efb3 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -4,6 +4,7 @@ import { withNamespaces, Trans } from 'react-i18next'; import Upstream from './Upstream'; import Dhcp from './Dhcp'; import Encryption from './Encryption'; +import Clients from './Clients'; import Checkbox from '../ui/Checkbox'; import Loading from '../ui/Loading'; import PageTitle from '../ui/PageTitle'; @@ -46,29 +47,38 @@ class Settings extends Component { return Object.keys(settings).map((key) => { const setting = settings[key]; const { enabled } = setting; - return ( this.props.toggleSetting(key, enabled)} - />); + return ( + this.props.toggleSetting(key, enabled)} + /> + ); }); } return ( -
no_settings
+
+ no_settings +
); - } + }; render() { - const { settings, dashboard, t } = this.props; + const { + settings, dashboard, clients, t, + } = this.props; return ( - + {settings.processing && } - {!settings.processing && + {!settings.processing && (
- +
{this.renderSettings(settings.settingsList)}
@@ -82,6 +92,22 @@ class Settings extends Component { processingTestUpstream={settings.processingTestUpstream} processingSetUpstream={settings.processingSetUpstream} /> + {!dashboard.processingTopStats && !dashboard.processingClients && ( + + )}
- } + )} ); } diff --git a/client/src/components/ui/Modal.css b/client/src/components/ui/Modal.css index 4a000cb1..43f35f64 100644 --- a/client/src/components/ui/Modal.css +++ b/client/src/components/ui/Modal.css @@ -5,7 +5,7 @@ overflow-x: hidden; overflow-y: auto; background-color: rgba(0, 0, 0, 0.5); - z-index: 1; + z-index: 105; } .ReactModal__Overlay--after-open { @@ -38,3 +38,9 @@ border: none; background-color: transparent; } + +@media (min-width: 576px) { + .modal-dialog--clients { + max-width: 650px; + } +} diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index d593761a..95be768b 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -17,6 +17,12 @@ import { setTlsConfig, validateTlsConfig, } from '../actions/encryption'; +import { + addClient, + updateClient, + deleteClient, + toggleClientModal, +} from '../actions/clients'; import Settings from '../components/Settings'; const mapStateToProps = (state) => { @@ -25,12 +31,14 @@ const mapStateToProps = (state) => { dashboard, dhcp, encryption, + clients, } = state; const props = { settings, dashboard, dhcp, encryption, + clients, }; return props; }; @@ -50,6 +58,10 @@ const mapDispatchToProps = { getTlsStatus, setTlsConfig, validateTlsConfig, + addClient, + updateClient, + deleteClient, + toggleClientModal, }; export default connect( diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 503ca2ed..d5f94f8c 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -1,5 +1,6 @@ export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/\w[\w_\-.]*\.[a-z]{2,8}[^\s]*$/; export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/g; +export const R_MAC = /^((([a-fA-F0-9][a-fA-F0-9]+[-]){5}|([a-fA-F0-9][a-fA-F0-9]+[:]){5})([a-fA-F0-9][a-fA-F0-9])$)|(^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){2}([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]))$/g; export const STATS_NAMES = { avg_processing_time: 'average_processing_time', @@ -19,7 +20,8 @@ export const STATUS_COLORS = { export const REPOSITORY = { URL: 'https://github.com/AdguardTeam/AdGuardHome', - TRACKERS_DB: 'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json', + TRACKERS_DB: + 'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json', }; export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html'; @@ -165,3 +167,13 @@ export const DHCP_STATUS_RESPONSE = { NO: 'no', ERROR: 'error', }; + +export const MODAL_TYPE = { + ADD: 'add', + EDIT: 'edit', +}; + +export const CLIENT_ID = { + MAC: 'mac', + IP: 'ip', +}; diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js index c9703033..72397396 100644 --- a/client/src/helpers/form.js +++ b/client/src/helpers/form.js @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import { Trans } from 'react-i18next'; -import { R_IPV4, UNSAFE_PORTS } from '../helpers/constants'; +import { R_IPV4, R_MAC, UNSAFE_PORTS } from '../helpers/constants'; export const renderField = ({ input, id, className, placeholder, type, disabled, meta: { touched, error }, @@ -55,6 +55,13 @@ export const ipv4 = (value) => { return false; }; +export const mac = (value) => { + if (value && !new RegExp(R_MAC).test(value)) { + return form_error_mac_format; + } + return false; +}; + export const isPositive = (value) => { if ((value || value === 0) && (value <= 0)) { return form_error_positive; diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index d3128f5d..773b54f9 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -208,3 +208,21 @@ export const getClientName = (clients, ip) => { const client = clients.find(item => ip === item.ip); return (client && client.name) || ''; }; + +export const sortClients = (clients) => { + const compare = (a, b) => { + const nameA = a.name.toUpperCase(); + const nameB = b.name.toUpperCase(); + let comparison = 0; + + if (nameA > nameB) { + comparison = 1; + } else if (nameA < nameB) { + comparison = -1; + } + + return comparison; + }; + + return clients.sort(compare); +}; diff --git a/client/src/reducers/clients.js b/client/src/reducers/clients.js new file mode 100644 index 00000000..0946e437 --- /dev/null +++ b/client/src/reducers/clients.js @@ -0,0 +1,63 @@ +import { handleActions } from 'redux-actions'; + +import * as actions from '../actions/clients'; + +const clients = handleActions({ + [actions.addClientRequest]: state => ({ ...state, processingAdding: true }), + [actions.addClientFailure]: state => ({ ...state, processingAdding: false }), + [actions.addClientSuccess]: (state) => { + const newState = { + ...state, + processingAdding: false, + }; + return newState; + }, + + [actions.deleteClientRequest]: state => ({ ...state, processingDeleting: true }), + [actions.deleteClientFailure]: state => ({ ...state, processingDeleting: false }), + [actions.deleteClientSuccess]: (state) => { + const newState = { + ...state, + processingDeleting: false, + }; + return newState; + }, + + [actions.updateClientRequest]: state => ({ ...state, processingUpdating: true }), + [actions.updateClientFailure]: state => ({ ...state, processingUpdating: false }), + [actions.updateClientSuccess]: (state) => { + const newState = { + ...state, + processingUpdating: false, + }; + return newState; + }, + + [actions.toggleClientModal]: (state, { payload }) => { + if (payload) { + const newState = { + ...state, + modalType: payload.type || '', + modalClientName: payload.name || '', + isModalOpen: !state.isModalOpen, + }; + return newState; + } + + const newState = { + ...state, + isModalOpen: !state.isModalOpen, + }; + return newState; + }, +}, { + processing: true, + processingAdding: false, + processingDeleting: false, + processingUpdating: false, + isModalOpen: false, + modalClientName: '', + modalType: '', +}); + +export default clients; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 09c93a99..206a0de4 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -7,6 +7,7 @@ import versionCompare from '../helpers/versionCompare'; import * as actions from '../actions'; import toasts from './toasts'; import encryption from './encryption'; +import clients from './clients'; const settings = handleActions({ [actions.initSettingsRequest]: state => ({ ...state, processing: true }), @@ -209,6 +210,7 @@ const dashboard = handleActions({ dnsAddresses: [], dnsVersion: '', clients: [], + topStats: [], }); const queryLogs = handleActions({ @@ -361,6 +363,7 @@ export default combineReducers({ toasts, dhcp, encryption, + clients, loadingBar: loadingBarReducer, form: formReducer, });