From 8dc010886867de62340d933df7edc31314234abc Mon Sep 17 00:00:00 2001
From: Artem Baskal <a.baskal@adguard.com>
Date: Sat, 5 Sep 2020 10:22:47 +0300
Subject: [PATCH] + client: Redesign query logs block/unblock buttons

Close #2050

Squashed commit of the following:

commit 3bc6a409034989b914306e1c33da274730ca623e
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Sep 4 20:58:09 2020 +0300

    Change dashboard block confirm message

commit d4d47c3557e2166ee04db25a71b782bfbfe3b865
Merge: e8865827 fc43e2ac
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Sep 4 14:56:34 2020 +0300

    Merge branch 'master' into feature/2050

commit e8865827879955b1ef62c9ff85798d07bfa4627d
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Sep 4 13:46:10 2020 +0300

    Rename classname

commit 648151c54e493c63622e014cb9cd1cb450f25478
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Sep 3 19:09:21 2020 +0300

    Decrease arrow size

commit 4feadab707c613d31225dfa9443a9a836db37ba1
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Sep 3 18:27:41 2020 +0300

    Rename button class

commit c3919d8ae8d1431657ce61afad2c20e5806f279a
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Sep 3 10:35:15 2020 +0300

    Review changes: extract variables

commit 0ac809584c391e41a1749a844bc1075e05a92345
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Sep 3 10:13:57 2020 +0300

    Display dashboard button on hover

commit 1395287c2383e2248a2a5d39451403bd73141e55
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 21:24:04 2020 +0300

    Do not hide button on option open

commit 947f254b7aea26f289b66b66fac46dba11ea3952
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 21:20:19 2020 +0300

    Add buttons for mobile screen

commit df05697f87163a2b716d82653884e631f2fa6cf3
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 20:18:20 2020 +0300

    Change dashboard button styles

commit 16655f2d6b0d79d1fa027ec2310bb0268fffaf6a
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 20:04:28 2020 +0300

    Change button styles, rename button options

commit 1ac22e875d8b26c16830bf6edb85dadcc19ff287
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 19:30:16 2020 +0300

    Review changes

commit c590119875439d85927bdd334658e003bc1f0563
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 17:58:08 2020 +0300

    Remove default query logs form values

commit 141329563417f5337f5659d5500f4cbe16d64bd2
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 17:41:23 2020 +0300

    Update blocking buttons options logic, fix button svg size

commit 9e4f39aa6cb8e134d80d496b8a248b2fe6aceb99
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Sep 2 16:30:48 2020 +0300

    Fix button position

commit 8aabff7daccb87ae02c2302e62e296b3cfc17608
Merge: 415a0334 6b614295
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 17:29:55 2020 +0300

    Merge branch 'master' into feature/2050

commit 415a0334561733d92a0f7badd68101ef554dc689
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 17:05:51 2020 +0300

    Add blocking options

commit bc6aed92b6e12f27c2604501275b53bb8159d5bc
Merge: 0de4fb3a 40b74522
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 15:49:06 2020 +0300

    Merge branch 'feature/infinite_scroll_query_logs' into feature/2050

commit 40b745225112cf8d664220ed8f484b0aa16e997c
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 15:46:27 2020 +0300

    Remove dynamic translation of toasts

commit 0de4fb3a4cd785c6b52e860e204c6e13d356b178
Merge: 1ab14471 f08fa7b8
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 15:07:30 2020 +0300

    Merge branch 'feature/infinite_scroll_query_logs' into feature/2050

... and 51 more commits
---
 client/src/__locales/en.json                  |   7 +-
 client/src/actions/index.js                   |  19 ++-
 client/src/components/App/index.css           |   9 ++
 client/src/components/Dashboard/Clients.js    |  15 +--
 client/src/components/Header/Header.css       |   4 +
 .../src/components/Logs/Cells/ClientCell.js   | 101 ++++++++++++---
 .../src/components/Logs/Cells/IconTooltip.js  |  16 ++-
 .../components/Logs/Cells/helpers/index.js    |  19 +++
 client/src/components/Logs/Cells/index.js     |  42 ++++++-
 client/src/components/Logs/Logs.css           | 115 +++++++++++++++---
 client/src/components/Logs/index.js           |   5 +-
 client/src/components/ui/Icons.js             | Bin 42118 -> 42479 bytes
 client/src/components/ui/Tooltip.js           |   7 +-
 client/src/helpers/helpers.js                 |  18 +++
 14 files changed, 319 insertions(+), 58 deletions(-)
 create mode 100644 client/src/components/Logs/Cells/helpers/index.js

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index e8e984e2..6cce0d91 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -194,6 +194,10 @@
     "dns_test_not_ok_toast": "Server \"{{key}}\": could not be used, please check that you've written it correctly",
     "unblock": "Unblock",
     "block": "Block",
+    "disallow_this_client": "Disallow this client",
+    "allow_this_client": "Allow this client",
+    "block_for_this_client_only": "Block for this client only",
+    "unblock_for_this_client_only": "Unblock for this client only",
     "time_table_header": "Time",
     "date": "Date",
     "domain_name_table_header": "Domain name",
@@ -569,5 +573,6 @@
     "setup_config_to_enable_dhcp_server": "Setup config to enable DHCP server",
     "original_response": "Original response",
     "click_to_view_queries": "Click to view queries",
-    "port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction</0> on how to resolve this."
+    "port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction</0> on how to resolve this.",
+    "adg_will_drop_dns_queries": "AdGuard Home will be dropping all DNS queries from this client."
 }
diff --git a/client/src/actions/index.js b/client/src/actions/index.js
index ff512883..d4018bb0 100644
--- a/client/src/actions/index.js
+++ b/client/src/actions/index.js
@@ -545,15 +545,17 @@ export const removeStaticLease = (config) => async (dispatch) => {
 
 export const removeToast = createAction('REMOVE_TOAST');
 
-export const toggleBlocking = (type, domain) => async (dispatch, getState) => {
+export const toggleBlocking = (
+    type, domain, baseRule, baseUnblocking,
+) => async (dispatch, getState) => {
+    const baseBlockingRule = baseRule || `||${domain}^$important`;
+    const baseUnblockingRule = baseUnblocking || `@@${baseBlockingRule}`;
     const { userRules } = getState().filtering;
 
     const lineEnding = !endsWith(userRules, '\n') ? '\n' : '';
-    const baseRule = `||${domain}^$important`;
-    const baseUnblocking = `@@${baseRule}`;
 
-    const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblocking : baseRule;
-    const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseRule : baseUnblocking;
+    const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblockingRule : baseBlockingRule;
+    const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseBlockingRule : baseUnblockingRule;
     const preparedBlockingRule = new RegExp(`(^|\n)${escapeRegExp(blockingRule)}($|\n)`);
     const preparedUnblockingRule = new RegExp(`(^|\n)${escapeRegExp(unblockingRule)}($|\n)`);
 
@@ -576,3 +578,10 @@ export const toggleBlocking = (type, domain) => async (dispatch, getState) => {
 
     dispatch(getFilteringStatus());
 };
+
+export const toggleBlockingForClient = (type, domain, client) => {
+    const baseRule = `||${domain}^$client='${client.replace(/'/g, '/\'')}'`;
+    const baseUnblocking = `@@${baseRule}`;
+
+    return toggleBlocking(type, domain, baseRule, baseUnblocking);
+};
diff --git a/client/src/components/App/index.css b/client/src/components/App/index.css
index 091a6612..e2b0304d 100644
--- a/client/src/components/App/index.css
+++ b/client/src/components/App/index.css
@@ -66,3 +66,12 @@ body {
 .select--no-warning {
     margin-bottom: 1.375rem;
 }
+
+.button-action {
+    visibility: hidden;
+}
+
+.logs__row:hover .button-action,
+.button-action--active {
+    visibility: visible;
+}
diff --git a/client/src/components/Dashboard/Clients.js b/client/src/components/Dashboard/Clients.js
index 24b278f4..3c163035 100644
--- a/client/src/components/Dashboard/Clients.js
+++ b/client/src/components/Dashboard/Clients.js
@@ -51,15 +51,16 @@ const renderBlockingButton = (ip) => {
     const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK;
     const text = type;
 
-    const className = classNames('btn btn-sm', {
-        'btn-outline-danger': isNotFound,
-        'btn-outline-secondary': !isNotFound,
+    const buttonClass = classNames('button-action button-action--main', {
+        'button-action--unblock': !isNotFound,
     });
 
     const toggleClientStatus = (type, ip) => {
-        const confirmMessage = type === BLOCK_ACTIONS.BLOCK ? 'client_confirm_block' : 'client_confirm_unblock';
+        const confirmMessage = type === BLOCK_ACTIONS.BLOCK
+            ? `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}`
+            : t('client_confirm_unblock', { ip });
 
-        if (window.confirm(t(confirmMessage, { ip }))) {
+        if (window.confirm(confirmMessage)) {
             dispatch(toggleClientBlock(type, ip));
         }
     };
@@ -69,7 +70,7 @@ const renderBlockingButton = (ip) => {
     return <div className="table__action pl-4">
                 <button
                         type="button"
-                        className={className}
+                        className={buttonClass}
                         onClick={onClick}
                         disabled={processingSet}
                 >
@@ -82,7 +83,7 @@ const ClientCell = (row) => {
     const { value, original: { info } } = row;
 
     return <>
-        <div className="logs__row logs__row--overflow logs__row--column d-flex">
+        <div className="logs__row logs__row--overflow logs__row--column d-flex align-items-center">
             {renderFormattedClientCell(value, info, true)}
             {renderBlockingButton(value)}
         </div>
diff --git a/client/src/components/Header/Header.css b/client/src/components/Header/Header.css
index b67840f3..a5fe802e 100644
--- a/client/src/components/Header/Header.css
+++ b/client/src/components/Header/Header.css
@@ -164,6 +164,10 @@
         color: #9aa0ac;
     }
 
+    .nav-icon--white {
+        color: #fff;
+    }
+
     .header-brand-img {
         height: 32px;
     }
diff --git a/client/src/components/Logs/Cells/ClientCell.js b/client/src/components/Logs/Cells/ClientCell.js
index 93830faa..fd9875bf 100644
--- a/client/src/components/Logs/Cells/ClientCell.js
+++ b/client/src/components/Logs/Cells/ClientCell.js
@@ -1,14 +1,16 @@
-import React from 'react';
+import React, { useState } from 'react';
 import { shallowEqual, useDispatch, useSelector } from 'react-redux';
 import { nanoid } from 'nanoid';
 import classNames from 'classnames';
 import { useTranslation } from 'react-i18next';
 import propTypes from 'prop-types';
-import { checkFiltered } from '../../../helpers/helpers';
+import { checkFiltered, getBlockingClientName } from '../../../helpers/helpers';
 import { BLOCK_ACTIONS } from '../../../helpers/constants';
-import { toggleBlocking } from '../../../actions';
+import { toggleBlocking, toggleBlockingForClient } from '../../../actions';
 import IconTooltip from './IconTooltip';
 import { renderFormattedClientCell } from '../../../helpers/renderFormattedClientCell';
+import { toggleClientBlock } from '../../../actions/access';
+import { getBlockClientInfo } from './helpers';
 
 const ClientCell = ({
     client,
@@ -22,6 +24,12 @@ const ClientCell = ({
     const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
     const processingRules = useSelector((state) => state.filtering.processingRules);
     const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
+    const [isOptionsOpened, setOptionsOpened] = useState(false);
+
+    const disallowed_clients = useSelector(
+        (state) => state.access.disallowed_clients,
+        shallowEqual,
+    );
 
     const autoClient = autoClients.find((autoClient) => autoClient.name === client);
     const source = autoClient?.source;
@@ -53,22 +61,81 @@ const ClientCell = ({
 
     const renderBlockingButton = (isFiltered, domain) => {
         const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
+        const clients = useSelector((state) => state.dashboard.clients);
 
-        const buttonClass = classNames('btn btn-sm logs__cell--block-button', {
-            'btn-outline-secondary': isFiltered,
-            'btn-outline-danger': !isFiltered,
-        });
+        const {
+            confirmMessage,
+            buttonKey: blockingClientKey,
+            type,
+        } = getBlockClientInfo(client, disallowed_clients);
+
+        const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only';
+        const clientNameBlockingFor = getBlockingClientName(clients, client);
+
+        const BUTTON_OPTIONS_TO_ACTION_MAP = {
+            [blockingForClientKey]: () => {
+                dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor));
+            },
+            [blockingClientKey]: () => {
+                const message = `${type === BLOCK_ACTIONS.BLOCK ? t('adg_will_drop_dns_queries') : ''} ${t(confirmMessage, { ip: client })}`;
+                if (window.confirm(message)) {
+                    dispatch(toggleClientBlock(type, client));
+                }
+            },
+        };
 
         const onClick = () => dispatch(toggleBlocking(buttonType, domain));
 
-        return <button
-                type="button"
-                className={buttonClass}
-                onClick={onClick}
-                disabled={processingRules}
-        >
-            {t(buttonType)}
-        </button>;
+        const getOptions = (optionToActionMap) => {
+            const options = Object.entries(optionToActionMap);
+            if (options.length === 0) {
+                return null;
+            }
+            return <>{options
+                .map(([name, onClick]) => <div
+                    key={name}
+                    className="button-action--arrow-option px-4 py-2"
+                    onClick={onClick}
+            >{t(name)}
+            </div>)}</>;
+        };
+
+        const content = getOptions(BUTTON_OPTIONS_TO_ACTION_MAP);
+
+        const buttonClass = classNames('button-action button-action--main', {
+            'button-action--unblock': isFiltered,
+            'button-action--with-options': content,
+            'button-action--active': isOptionsOpened,
+        });
+
+        const buttonArrowClass = classNames('button-action button-action--arrow', {
+            'button-action--unblock': isFiltered,
+            'button-action--active': isOptionsOpened,
+        });
+
+        const containerClass = classNames('button-action__container', {
+            'button-action__container--detailed': isDetailed,
+        });
+
+        return <div className={containerClass}>
+            <button type="button"
+                    className={buttonClass}
+                    onClick={onClick}
+                    disabled={processingRules}
+            >
+                {t(buttonType)}
+            </button>
+            {content && <button className={buttonArrowClass} disabled={processingRules}>
+                <IconTooltip
+                        className='h-100'
+                        tooltipClass='button-action--arrow-option-container'
+                        xlinkHref='chevron-down'
+                        triggerClass='button-action--icon'
+                        content={content} placement="bottom-end" trigger="click"
+                        onVisibilityChange={setOptionsOpened}
+                />
+            </button>}
+        </div>;
     };
 
     return <div className="o-hidden h-100 logs__cell logs__cell--client" role="gridcell">
@@ -81,9 +148,7 @@ const ClientCell = ({
             </div>
             {isDetailed && name && !whoisAvailable
             && <div className="detailed-info d-none d-sm-block logs__text"
-                    title={name}>
-                {name}
-            </div>}
+                    title={name}>{name}</div>}
         </div>
         {renderBlockingButton(isFiltered, domain)}
     </div>;
diff --git a/client/src/components/Logs/Cells/IconTooltip.js b/client/src/components/Logs/Cells/IconTooltip.js
index 5b9cc2cb..8bb3d624 100644
--- a/client/src/components/Logs/Cells/IconTooltip.js
+++ b/client/src/components/Logs/Cells/IconTooltip.js
@@ -6,17 +6,21 @@ import { processContent } from '../../../helpers/helpers';
 import Tooltip from '../../ui/Tooltip';
 import 'react-popper-tooltip/dist/styles.css';
 import './IconTooltip.css';
+import { SHOW_TOOLTIP_DELAY } from '../../../helpers/constants';
 
 const IconTooltip = ({
     className,
     contentItemClass,
     columnClass,
+    triggerClass,
     canShowTooltip = true,
     xlinkHref,
     title,
     placement,
     tooltipClass,
     content,
+    trigger,
+    onVisibilityChange,
     renderContent = content ? React.Children.map(
         processContent(content),
         (item, idx) => <div key={idx} className={contentItemClass}>
@@ -36,6 +40,10 @@ const IconTooltip = ({
         className={tooltipClassName}
         content={tooltipContent}
         placement={placement}
+        triggerClass={triggerClass}
+        trigger={trigger}
+        onVisibilityChange={onVisibilityChange}
+        delayShow={trigger === 'click' ? 0 : SHOW_TOOLTIP_DELAY}
     >
         {xlinkHref && <svg className={className}>
             <use xlinkHref={`#${xlinkHref}`} />
@@ -45,6 +53,8 @@ const IconTooltip = ({
 
 IconTooltip.propTypes = {
     className: PropTypes.string,
+    trigger: PropTypes.string,
+    triggerClass: PropTypes.string,
     contentItemClass: PropTypes.string,
     columnClass: PropTypes.string,
     tooltipClass: PropTypes.string,
@@ -52,11 +62,9 @@ IconTooltip.propTypes = {
     placement: PropTypes.string,
     canShowTooltip: PropTypes.bool,
     xlinkHref: PropTypes.string,
-    content: PropTypes.oneOfType([
-        PropTypes.string,
-        PropTypes.array,
-    ]),
+    content: PropTypes.node,
     renderContent: PropTypes.arrayOf(PropTypes.element),
+    onVisibilityChange: PropTypes.func,
 };
 
 export default IconTooltip;
diff --git a/client/src/components/Logs/Cells/helpers/index.js b/client/src/components/Logs/Cells/helpers/index.js
new file mode 100644
index 00000000..61e7ff5c
--- /dev/null
+++ b/client/src/components/Logs/Cells/helpers/index.js
@@ -0,0 +1,19 @@
+import { getIpMatchListStatus } from '../../../../helpers/helpers';
+import { BLOCK_ACTIONS, IP_MATCH_LIST_STATUS } from '../../../../helpers/constants';
+
+export const BUTTON_PREFIX = 'btn_';
+
+export const getBlockClientInfo = (client, disallowed_clients) => {
+    const ipMatchListStatus = getIpMatchListStatus(client, disallowed_clients);
+
+    const isNotFound = ipMatchListStatus === IP_MATCH_LIST_STATUS.NOT_FOUND;
+    const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK;
+
+    const confirmMessage = isNotFound ? 'client_confirm_block' : 'client_confirm_unblock';
+    const buttonKey = isNotFound ? 'disallow_this_client' : 'allow_this_client';
+    return {
+        confirmMessage,
+        buttonKey,
+        type,
+    };
+};
diff --git a/client/src/components/Logs/Cells/index.js b/client/src/components/Logs/Cells/index.js
index c2d7968b..8a0fced3 100644
--- a/client/src/components/Logs/Cells/index.js
+++ b/client/src/components/Logs/Cells/index.js
@@ -9,6 +9,7 @@ import {
     formatDateTime,
     formatElapsedMs,
     formatTime,
+    getBlockingClientName,
     getFilterName,
     processContent,
 } from '../../../helpers/helpers';
@@ -22,12 +23,14 @@ import {
     SCHEME_TO_PROTOCOL_MAP,
 } from '../../../helpers/constants';
 import { getSourceData } from '../../../helpers/trackers/trackers';
-import { toggleBlocking } from '../../../actions';
+import { toggleBlocking, toggleBlockingForClient } from '../../../actions';
 import DateCell from './DateCell';
 import DomainCell from './DomainCell';
 import ResponseCell from './ResponseCell';
 import ClientCell from './ClientCell';
 import '../Logs.css';
+import { toggleClientBlock } from '../../../actions/access';
+import { getBlockClientInfo, BUTTON_PREFIX } from './helpers';
 
 const Row = memo(({
     style,
@@ -45,6 +48,13 @@ const Row = memo(({
     const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual);
     const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
 
+    const disallowed_clients = useSelector(
+        (state) => state.access.disallowed_clients,
+        shallowEqual,
+    );
+
+    const clients = useSelector((state) => state.dashboard.clients);
+
     const onClick = () => {
         if (!isSmallScreen) { return; }
         const {
@@ -98,6 +108,26 @@ const Row = memo(({
 
         const filter = getFilterName(filters, whitelistFilters, filterId);
 
+        const {
+            confirmMessage,
+            buttonKey: blockingClientKey,
+            type: blockType,
+        } = getBlockClientInfo(client, disallowed_clients);
+
+        const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only';
+        const clientNameBlockingFor = getBlockingClientName(clients, client);
+
+        const onBlockingForClientClick = () => {
+            dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor));
+        };
+
+        const onBlockingClientClick = () => {
+            const message = `${blockType === BLOCK_ACTIONS.BLOCK ? t('adg_will_drop_dns_queries') : ''} ${t(confirmMessage, { ip: client })}`;
+            if (window.confirm(message)) {
+                dispatch(toggleClientBlock(blockType, client));
+            }
+        };
+
         const detailedData = {
             time_table_header: formatTime(time, LONG_TIME_FORMAT),
             date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
@@ -132,10 +162,12 @@ const Row = memo(({
             source_label: source,
             validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false,
             original_response: originalResponse?.join('\n'),
-            [buttonType]: <div onClick={onToggleBlock}
-                                   className={classNames('title--border text-center', {
-                                       'bg--danger': isBlocked,
-                                   })}>{t(buttonType)}</div>,
+            [BUTTON_PREFIX + buttonType]: <div onClick={onToggleBlock}
+                                               className={classNames('title--border text-center', {
+                                                   'bg--danger': isBlocked,
+                                               })}>{t(buttonType)}</div>,
+            [BUTTON_PREFIX + blockingForClientKey]: <div onClick={onBlockingForClientClick} className='text-center font-weight-bold py-2'>{t(blockingForClientKey)}</div>,
+            [BUTTON_PREFIX + blockingClientKey]: <div onClick={onBlockingClientClick} className='text-center font-weight-bold py-2'>{t(blockingClientKey)}</div>,
         };
 
         setDetailedDataCurrent(processContent(detailedData));
diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css
index 857fd466..fd088b79 100644
--- a/client/src/components/Logs/Logs.css
+++ b/client/src/components/Logs/Logs.css
@@ -10,9 +10,20 @@
     --size-client: 123;
     --gray-216: rgba(216, 216, 216, 0.23);
     --gray-4d: #4D4D4D;
+    --gray-f3: #F3F3F3;
     --gray-8: #888;
     --danger: #DF3812;
     --white80: rgba(255, 255, 255, 0.8);
+
+    --btn-block: #C23814;
+    --btn-block-disabled: #E3B3A6;
+    --btn-block-active: #A62200;
+
+    --btn-unblock: #888888;
+    --btn-unblock-disabled: #D8D8D8;
+    --btn-unblock-active: #4D4D4D;
+
+    --option-border-radius: 4px;
 }
 
 .logs__text {
@@ -191,6 +202,7 @@
     width: 7.6875rem;
     flex: var(--size-client) 0 auto;
     padding-right: 0;
+    position: relative;
 }
 
 .logs__cell--header__container > .logs__cell--header__item {
@@ -202,12 +214,95 @@
     padding-right: 0;
 }
 
-.logs__cell--block-button {
-    max-height: 1.75rem;
-    position: relative;
-    left: 10%;
-    top: 40%;
-    visibility: hidden;
+.button-action__container {
+    display: flex;
+    position: absolute;
+    right: 0;
+    bottom: 0.5rem;
+    height: 1.6rem;
+}
+
+.button-action__container--detailed {
+    bottom: 1.3rem;
+}
+
+.button-action {
+    outline: 0 !important;
+    background: var(--btn-block);
+    border-radius: var(--option-border-radius);
+    font-size: 0.8rem;
+    color: var(--white);
+    letter-spacing: 0;
+    text-align: center;
+    line-height: 28px;
+    border: 0;
+}
+
+.button-action--unblock {
+    background: var(--btn-unblock);
+}
+
+.button-action--main {
+    padding: 0 1rem;
+    display: flex;
+    align-items: center;
+}
+
+.button-action--with-options {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+}
+
+.button-action--arrow {
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+    border-left: 1px solid var(--white);
+    width: 1.5625rem;
+    padding: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.button-action:hover {
+    cursor: pointer;
+}
+
+.button-action--arrow .button-action--icon {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+}
+
+.button-action:active {
+    background: var(--btn-block-active);
+}
+
+.button-action--unblock:active {
+    background: var(--btn-unblock-active);
+}
+
+.button-action:disabled {
+    background: var(--btn-block-disabled);
+    cursor: default;
+}
+
+.button-action--unblock:disabled {
+    background: var(--btn-unblock-disabled);
+}
+
+.button-action--arrow-option:hover {
+    cursor: pointer;
+    background: var(--gray-f3);
+    overflow: hidden;
+}
+
+.button-action--arrow-option-container {
+    overflow: visible;
+    transform-origin: left;
+    padding: 1rem 0;
 }
 
 .logs__row {
@@ -222,14 +317,6 @@
     border-bottom: 2px solid var(--gray-216);
 }
 
-.logs__table .logs__row:hover .logs__cell--block-button {
-    visibility: visible;
-}
-
-.logs__table .logs__row .logs__cell--block-button:disabled {
-    background-color: var(--white) !important;
-}
-
 /* QUERY_STATUS_COLORS */
 .logs__row--blue {
     background-color: var(--blue);
diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js
index 13fa697c..bcc9a94d 100644
--- a/client/src/components/Logs/index.js
+++ b/client/src/components/Logs/index.js
@@ -23,15 +23,16 @@ import {
 } from '../../actions/queryLogs';
 import InfiniteTable from './InfiniteTable';
 import './Logs.css';
+import { BUTTON_PREFIX } from './Cells/helpers';
 
-const processContent = (data, buttonType) => Object.entries(data)
+const processContent = (data) => Object.entries(data)
     .map(([key, value]) => {
         if (!value) {
             return null;
         }
 
         const isTitle = value === 'title';
-        const isButton = key === buttonType;
+        const isButton = key.startsWith(BUTTON_PREFIX);
         const isBoolean = typeof value === 'boolean';
         const isHidden = isBoolean && value === false;
 
diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js
index f29bcc21aaf23ebc611b2329d1785e21dd900b3b..3851ff014c13df989d2c8e8f44bd8a64b4946674 100644
GIT binary patch
delta 169
zcmZoW$@Km-(}u()lMU2Gd6F|y%Zl>zbW`%n^CmA;78S7LQa}PW>60V6j3ysg$f;|f
zV31*CQf6f0VNj){kd~Q~W2>ZWVGe>2u75#da%M@Tt&*OB;p7WlQj;f66`R};$v$~)
z7tiFwwrZ0LdRZrbh-8!3GcYz&Ff`D!G|n*w5(*}I2Ie5b%t9B$pWNQ7F*$LGDgYIv
BH2eSn

delta 13
VcmaEVnyKw1(}u()lTR&G1pqRN2I>F+

diff --git a/client/src/components/ui/Tooltip.js b/client/src/components/ui/Tooltip.js
index 9f34b3fe..87b353de 100644
--- a/client/src/components/ui/Tooltip.js
+++ b/client/src/components/ui/Tooltip.js
@@ -20,6 +20,7 @@ const Tooltip = ({
     trigger = 'hover',
     delayShow = SHOW_TOOLTIP_DELAY,
     delayHide = HIDE_TOOLTIP_DELAY,
+    onVisibilityChange,
 }) => {
     const { t } = useTranslation();
     const touchEventsAvailable = 'ontouchstart' in window;
@@ -73,6 +74,7 @@ const Tooltip = ({
             delayHide={delayHideValue}
             delayShow={delayShowValue}
             tooltip={renderTooltip}
+            onVisibilityChange={onVisibilityChange}
         >
             {renderTrigger}
         </TooltipTrigger>
@@ -90,10 +92,11 @@ Tooltip.propTypes = {
     ).isRequired,
     placement: propTypes.string,
     trigger: propTypes.string,
-    delayHide: propTypes.string,
-    delayShow: propTypes.string,
+    delayHide: propTypes.number,
+    delayShow: propTypes.number,
     className: propTypes.string,
     triggerClass: propTypes.string,
+    onVisibilityChange: propTypes.func,
 };
 
 export default Tooltip;
diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js
index fa3a5046..bafd230e 100644
--- a/client/src/helpers/helpers.js
+++ b/client/src/helpers/helpers.js
@@ -836,3 +836,21 @@ export const isScrolledIntoView = (el) => {
 
     return elemTop < window.innerHeight && elemBottom >= 0;
 };
+
+/**
+ * If this is a manually created client, return its name.
+ * If this is a "runtime" client, return it's IP address.
+ * @param clients {Array.<object>}
+ * @param ip {string}
+ * @returns {string}
+ */
+export const getBlockingClientName = (clients, ip) => {
+    for (let i = 0; i < clients.length; i += 1) {
+        const client = clients[i];
+
+        if (client.ids.includes(ip)) {
+            return client.name;
+        }
+    }
+    return ip;
+};