Druid Doctor (#8672)

* adding Druid doctor

* better meesage

* feedback changes

* add icons

* feedback fixes

* spelling

* add file.encoding check

* feedback changes
This commit is contained in:
Vadim Ogievetsky 2019-10-23 16:50:08 -07:00 committed by Fangjin Yang
parent a8b674e00d
commit d9c9aef3d1
14 changed files with 867 additions and 90 deletions

View File

@ -839,16 +839,17 @@
}
},
"@blueprintjs/core": {
"version": "3.18.0",
"resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.18.0.tgz",
"integrity": "sha512-dr3A6uhpAAWmf5muY6PFQp5EgEzinRLZa/TGQMA05q1P2xrOn/LYlsqJWJBUJ3j9tmh1RP8VPeu1HmosQb3J7w==",
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.19.1.tgz",
"integrity": "sha512-O+p/Jbu9kyrTE4Yy5phd7Xp9NJoiHCQ9ycO+QSR2fiuhwnVMO1xjY269QXxylzEswgPyjhST1euqHpyQQ7qnUw==",
"requires": {
"@blueprintjs/icons": "^3.10.0",
"@blueprintjs/icons": "^3.11.0",
"@types/dom4": "^2.0.1",
"classnames": "^2.2",
"dom4": "^2.1.5",
"normalize.css": "^8.0.1",
"popper.js": "^1.15.0",
"react-lifecycles-compat": "^3.0.4",
"react-popper": "^1.3.3",
"react-transition-group": "^2.9.0",
"resize-observer-polyfill": "^1.5.1",
@ -863,9 +864,9 @@
}
},
"@blueprintjs/icons": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.10.0.tgz",
"integrity": "sha512-lyAUpkr3qEStPcJpMnxRKuVAPvaRNSce1ySPbkE58zPmD4WBya2gNrWex41xoqRYM0GsiBSwH9CnpY8t6fZKUA==",
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.11.0.tgz",
"integrity": "sha512-HGS652gFc057t9cr8NyuWFyZ1gcSqG3uuexpzhZm81W35hGfh9vdC9GR+mbHJNawAuKXtu+xw4VWWkv1UGQ0Vg==",
"requires": {
"classnames": "^2.2",
"tslib": "~1.9.0"
@ -2243,7 +2244,8 @@
"asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=",
"dev": true
},
"asn1": {
"version": "0.2.4",
@ -3556,12 +3558,12 @@
}
},
"create-react-context": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.2.tgz",
"integrity": "sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A==",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.3.0.tgz",
"integrity": "sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==",
"requires": {
"fbjs": "^0.8.0",
"gud": "^1.0.0"
"gud": "^1.0.0",
"warning": "^4.0.3"
}
},
"cross-spawn": {
@ -4574,14 +4576,6 @@
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
"dev": true
},
"encoding": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
"integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
"requires": {
"iconv-lite": "~0.4.13"
}
},
"end-of-stream": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz",
@ -5176,27 +5170,6 @@
"bser": "^2.0.0"
}
},
"fbjs": {
"version": "0.8.17",
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz",
"integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=",
"requires": {
"core-js": "^1.0.0",
"isomorphic-fetch": "^2.1.1",
"loose-envify": "^1.0.0",
"object-assign": "^4.1.0",
"promise": "^7.1.1",
"setimmediate": "^1.0.5",
"ua-parser-js": "^0.7.18"
},
"dependencies": {
"core-js": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
"integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY="
}
}
},
"figgy-pudding": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz",
@ -7245,7 +7218,8 @@
"is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
"dev": true
},
"is-string": {
"version": "1.0.4",
@ -7321,15 +7295,6 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true
},
"isomorphic-fetch": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz",
"integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=",
"requires": {
"node-fetch": "^1.0.1",
"whatwg-fetch": ">=0.10.0"
}
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@ -8970,15 +8935,6 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node-fetch": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
"requires": {
"encoding": "^0.1.11",
"is-stream": "^1.0.1"
}
},
"node-forge": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.5.tgz",
@ -10691,14 +10647,6 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
},
"promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"requires": {
"asap": "~2.0.3"
}
},
"promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@ -10965,12 +10913,12 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-popper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz",
"integrity": "sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.4.tgz",
"integrity": "sha512-9AcQB29V+WrBKk6X7p0eojd1f25/oJajVdMZkywIoAV6Ag7hzE1Mhyeup2Q1QnvFRtGQFQvtqfhlEoDAPfKAVA==",
"requires": {
"@babel/runtime": "^7.1.2",
"create-react-context": "<=0.2.2",
"create-react-context": "^0.3.0",
"popper.js": "^1.14.4",
"prop-types": "^15.6.1",
"typed-styles": "^0.0.7",
@ -12099,7 +12047,8 @@
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=",
"dev": true
},
"setprototypeof": {
"version": "1.1.1",
@ -13931,11 +13880,6 @@
"integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==",
"dev": true
},
"ua-parser-js": {
"version": "0.7.20",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.20.tgz",
"integrity": "sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw=="
},
"uglify-js": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz",
@ -14767,11 +14711,6 @@
"iconv-lite": "0.4.24"
}
},
"whatwg-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz",
"integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q=="
},
"whatwg-mimetype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",

View File

@ -53,8 +53,8 @@
"start": "webpack-dev-server --hot --open"
},
"dependencies": {
"@blueprintjs/core": "^3.18.0",
"@blueprintjs/icons": "^3.10.0",
"@blueprintjs/core": "^3.19.1",
"@blueprintjs/icons": "^3.11.0",
"axios": "^0.19.0",
"brace": "^0.11.1",
"classnames": "^2.2.6",

View File

@ -94,6 +94,15 @@ exports[`header bar matches snapshot 1`] = `
captureDismiss={false}
content={
<Blueprint3.Menu>
<Blueprint3.MenuItem
disabled={false}
icon="pulse"
multiline={false}
onClick={[Function]}
popoverProps={Object {}}
shouldDismissPopover={true}
text="Druid Doctor"
/>
<Blueprint3.MenuItem
disabled={false}
icon="settings"

View File

@ -34,6 +34,7 @@ import React, { useState } from 'react';
import { AboutDialog } from '../../dialogs/about-dialog/about-dialog';
import { CoordinatorDynamicConfigDialog } from '../../dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog';
import { DoctorDialog } from '../../dialogs/doctor-dialog/doctor-dialog';
import { OverlordDynamicConfigDialog } from '../../dialogs/overlord-dynamic-config-dialog/overlord-dynamic-config-dialog';
import {
DRUID_ASF_SLACK,
@ -134,6 +135,7 @@ export interface HeaderBarProps {
export function HeaderBar(props: HeaderBarProps) {
const { active, hideLegacy } = props;
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
const [doctorDialogOpen, setDoctorDialogOpen] = useState(false);
const [coordinatorDynamicConfigDialogOpen, setCoordinatorDynamicConfigDialogOpen] = useState(
false,
);
@ -157,6 +159,11 @@ export function HeaderBar(props: HeaderBarProps) {
const configMenu = (
<Menu>
<MenuItem
icon={IconNames.PULSE}
text="Druid Doctor"
onClick={() => setDoctorDialogOpen(true)}
/>
<MenuItem
icon={IconNames.SETTINGS}
text="Coordinator dynamic config"
@ -246,6 +253,7 @@ export function HeaderBar(props: HeaderBarProps) {
</Popover>
</NavbarGroup>
{aboutDialogOpen && <AboutDialog onClose={() => setAboutDialogOpen(false)} />}
{doctorDialogOpen && <DoctorDialog onClose={() => setDoctorDialogOpen(false)} />}
{coordinatorDynamicConfigDialogOpen && (
<CoordinatorDynamicConfigDialog
onClose={() => setCoordinatorDynamicConfigDialogOpen(false)}

View File

@ -16,7 +16,7 @@ exports[`about dialog matches snapshot 1`] = `
tabindex="0"
>
<div
class="bp3-dialog"
class="bp3-dialog about-dialog"
>
<div
class="bp3-dialog-header"

View File

@ -36,7 +36,14 @@ export function AboutDialog(props: AboutDialogProps) {
const { onClose } = props;
return (
<Dialog icon={IconNames.GRAPH} onClose={onClose} title="Apache Druid" isOpen canEscapeKeyClose>
<Dialog
className="about-dialog"
icon={IconNames.GRAPH}
onClose={onClose}
title="Apache Druid"
isOpen
canEscapeKeyClose
>
<div className={Classes.DIALOG_BODY}>
<p>
<strong>

View File

@ -0,0 +1,118 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`doctor dialog matches snapshot 1`] = `
<div
class="bp3-portal"
>
<div
class="bp3-overlay bp3-overlay-open bp3-overlay-scroll-container"
>
<div
class="bp3-overlay-backdrop bp3-overlay-appear bp3-overlay-appear-active"
/>
<div
class="bp3-dialog-container bp3-overlay-content bp3-overlay-appear bp3-overlay-appear-active"
tabindex="0"
>
<div
class="bp3-dialog doctor-dialog"
>
<div
class="bp3-dialog-header"
>
<span
class="bp3-icon bp3-icon-pulse"
icon="pulse"
>
<svg
data-icon="pulse"
height="20"
viewBox="0 0 20 20"
width="20"
>
<desc>
pulse
</desc>
<path
d="M19 10h-2.38L14.9 6.55h-.01c-.17-.32-.5-.55-.89-.55-.43 0-.79.28-.93.66h-.01l-2.75 7.57L7.98 1.82h-.02A.978.978 0 007 1c-.44 0-.8.29-.94.69h-.01L3.28 10H1c-.55 0-1 .45-1 1s.45 1 1 1h3c.44 0 .8-.29.94-.69h.01l1.78-5.34 2.29 12.21h.02c.08.46.47.82.96.82.43 0 .79-.28.93-.66h.01l3.21-8.82.96 1.92h.01c.16.33.49.56.88.56h3c.55 0 1-.45 1-1s-.45-1-1-1z"
fill-rule="evenodd"
/>
</svg>
</span>
<h4
class="bp3-heading"
>
Druid Doctor
</h4>
<button
aria-label="Close"
class="bp3-button bp3-minimal bp3-dialog-close-button"
type="button"
>
<span
class="bp3-icon bp3-icon-small-cross"
icon="small-cross"
>
<svg
data-icon="small-cross"
height="20"
viewBox="0 0 20 20"
width="20"
>
<desc>
small-cross
</desc>
<path
d="M11.41 10l3.29-3.29c.19-.18.3-.43.3-.71a1.003 1.003 0 00-1.71-.71L10 8.59l-3.29-3.3a1.003 1.003 0 00-1.42 1.42L8.59 10 5.3 13.29c-.19.18-.3.43-.3.71a1.003 1.003 0 001.71.71l3.29-3.3 3.29 3.29c.18.19.43.3.71.3a1.003 1.003 0 00.71-1.71L11.41 10z"
fill-rule="evenodd"
/>
</svg>
</span>
</button>
</div>
<div
class="bp3-dialog-body"
>
<div
class="analyze-bar"
>
<div
class="bp3-callout diagnosis"
>
Automated checks to troubleshoot issues with the cluster.
</div>
<button
class="bp3-button bp3-fill bp3-intent-primary"
type="button"
>
<span
class="bp3-button-text"
>
Analyze Druid cluster
</span>
</button>
</div>
</div>
<div
class="bp3-dialog-footer"
>
<div
class="bp3-dialog-footer-actions"
>
<button
class="bp3-button"
type="button"
>
<span
class="bp3-button-text"
>
Close
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,423 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import axios from 'axios';
import { pluralIfNeeded, queryDruidSql } from '../../utils';
import { deepGet } from '../../utils/object-change';
import { postToSampler } from '../../utils/sampler';
export interface CheckControls {
addSuggestion: (message: string) => void;
addIssue: (message: string) => void;
terminateChecks: () => void;
}
export interface DoctorCheck {
name: string;
check: (controls: CheckControls) => Promise<void>;
}
const RUNTIME_PROPERTIES_ALL_NODES_MUST_AGREE_ON: string[] = [
'user.timezone',
'druid.zk.service.host',
];
// In the future (when we can query other nodes) is will also be cool to check:
// 'druid.storage.type' <=> historicals, overlords, mm
// 'druid.indexer.logs.type' <=> overlord, mm, + peons
const RUNTIME_PROPERTIES_MASTER_NODES_SHOULD_AGREE_ON: string[] = [
'druid.metadata.storage.type', // overlord + coordinator
'druid.metadata.storage.connector.connectURI',
];
export const DOCTOR_CHECKS: DoctorCheck[] = [
// -------------------------------------
// Self (router) checks
// -------------------------------------
{
name: 'Verify own status',
check: async controls => {
// Make sure that the router responds to /status and gives some valid info back
let status: any;
try {
status = (await axios.get(`/status`)).data;
} catch (e) {
controls.addIssue(
`Did not get a /status response from the Router node. Try confirming that it is running and accessible. Got: ${e.message}`,
);
controls.terminateChecks();
return;
}
if (typeof status.version !== 'string') {
controls.addIssue('Could not get a valid /status response from the Router.');
}
},
},
{
name: 'Verify own runtime properties',
check: async controls => {
// Make sure that everything in /status/properties is above board
let properties: Record<string, string>;
try {
properties = (await axios.get(`/status/properties`)).data;
} catch (e) {
controls.addIssue(
`Did not get a /status/properties response from the Router. Message: ${e.message}`,
);
return;
}
// Check that the management proxy is on, it really should be for someone to access the console in the first place but everything could happen
if (properties['druid.router.managementProxy.enabled'] !== 'true') {
controls.addIssue(
`The Router's "druid.router.managementProxy.enabled" is not reported as "true". This means that the Coordinator and Overlord will not be accessible from the Router (and this console).`,
);
}
// Check that the underlying Java is Java 8 the only officially supported Java version at the moment.
if (
properties['java.specification.version'] &&
properties['java.specification.version'] !== '1.8'
) {
controls.addSuggestion(
`It looks like are running Java ${properties['java.runtime.version']}. Druid only officially supports Java 1.8.x`,
);
}
// Check "file.encoding"
if (properties['file.encoding'] && properties['file.encoding'] !== 'UTF-8') {
controls.addSuggestion(
`It looks like "file.encoding" is set to ${properties['file.encoding']}, it is recommended to set this to "UTF-8"`,
);
}
// Check "user.timezone"
if (properties['user.timezone'] && properties['user.timezone'] !== 'UTC') {
controls.addSuggestion(
`It looks like "user.timezone" is set to ${properties['user.timezone']}, it is recommended to set this to "UTC"`,
);
}
},
},
// -------------------------------------
// Coordinator and Overlord
// -------------------------------------
{
name: 'Verify the Coordinator and Overlord status',
check: async controls => {
// Make sure that everything in Coordinator's /status is good
let myStatus: any;
try {
myStatus = (await axios.get(`/status`)).data;
} catch {
return;
}
let coordinatorStatus: any;
try {
coordinatorStatus = (await axios.get(`/proxy/coordinator/status`)).data;
} catch (e) {
controls.addIssue(
'Did not get a /status response from the Coordinator node. Try confirming that it is running and accessible.',
);
return;
}
let overlordStatus: any;
try {
overlordStatus = (await axios.get(`/proxy/overlord/status`)).data;
} catch (e) {
controls.addIssue(
'Did not get a /status response from the Overlord node. Try confirming that it is running and accessible.',
);
return;
}
if (myStatus.version !== coordinatorStatus.version) {
controls.addSuggestion(
`It looks like the Router and Coordinator nodes are on different versions of Druid. This may indicate a problem if you are not in the middle of a rolling upgrade.`,
);
}
if (myStatus.version !== overlordStatus.version) {
controls.addSuggestion(
`It looks like the Router and Overlord nodes are on different versions of Druid. This may indicate a problem if you are not in the middle of a rolling upgrade.`,
);
}
},
},
{
name: 'Verify the Coordinator and Overlord runtime properties',
check: async controls => {
// Make sure that everything in coordinator and overlord /status/properties is good and matches where needed
let myProperties: Record<string, string>;
try {
myProperties = (await axios.get(`/status/properties`)).data;
} catch {
return;
}
let coordinatorProperties: Record<string, string>;
try {
coordinatorProperties = (await axios.get(`/proxy/coordinator/status/properties`)).data;
} catch (e) {
controls.addIssue(
'Did not get a /status response from the coordinator. Try confirming that it is running and accessible.',
);
return;
}
let overlordProperties: Record<string, string>;
try {
overlordProperties = (await axios.get(`/proxy/overlord/status/properties`)).data;
} catch (e) {
controls.addIssue(
'Did not get a /status response from the overlord. Try confirming that it is running and accessible.',
);
return;
}
for (const prop of RUNTIME_PROPERTIES_ALL_NODES_MUST_AGREE_ON) {
if (myProperties[prop] !== coordinatorProperties[prop]) {
controls.addIssue(
`The Router and Coordinator do not agree on the "${prop}" runtime property ("${myProperties[prop]}" vs "${coordinatorProperties[prop]}")`,
);
}
if (myProperties[prop] !== overlordProperties[prop]) {
controls.addIssue(
`The Router and Overlord do not agree on the "${prop}" runtime property ("${myProperties[prop]}" vs "${overlordProperties[prop]}")`,
);
}
}
for (const prop of RUNTIME_PROPERTIES_MASTER_NODES_SHOULD_AGREE_ON) {
if (coordinatorProperties[prop] !== overlordProperties[prop]) {
controls.addSuggestion(
`The Coordinator and Overlord do not agree on the "${prop}" runtime property ("${coordinatorProperties[prop]}" vs "${overlordProperties[prop]}")`,
);
}
}
},
},
// -------------------------------------
// Check sampler
// -------------------------------------
{
name: 'Verify that the sampler works',
check: async controls => {
// Make sure that everything in Coordinator's /status is good
let testSampledData: any;
try {
testSampledData = await postToSampler(
{
type: 'index',
spec: {
type: 'index',
ioConfig: { type: 'index', firehose: { type: 'inline', data: '{"test":"Data"}' } },
dataSchema: {
dataSource: 'sample',
parser: {
type: 'string',
parseSpec: {
format: 'json',
timestampSpec: {
column: '!!!_no_such_column_!!!',
missingValue: '2010-01-01T00:00:00Z',
},
dimensionsSpec: { dimensions: ['test'] },
},
},
transformSpec: {},
metricsSpec: [],
granularitySpec: { queryGranularity: 'NONE' },
},
},
samplerConfig: {
numRows: 50,
timeoutMs: 1000,
},
},
'doctor',
);
} catch {
controls.addIssue(`Could not use the sampler.`);
return;
}
if (deepGet(testSampledData, 'data.0.parsed.test') !== 'Data') {
controls.addIssue(`Sampler returned incorrect data.`);
}
},
},
// -------------------------------------
// Check SQL
// -------------------------------------
{
name: 'Verify that SQL works',
check: async controls => {
// Make sure that we can run the simplest query
let sqlResult: any[];
try {
sqlResult = await queryDruidSql({ query: `SELECT 1 + 1 AS "two"` });
} catch (e) {
controls.addIssue(
`Could not query SQL ensure that "druid.sql.enable" is set to "true" and that there is a Broker node running. Got: ${e.message}`,
);
controls.terminateChecks();
return;
}
if (sqlResult.length !== 1 || sqlResult[0]['two'] !== 2) {
controls.addIssue(`Got incorrect results from a basic SQL query.`);
}
},
},
{
name: 'Verify that there are historical nodes',
check: async controls => {
// Make sure that there are broker and historical nodes reported from sys.servers
let sqlResult: any[];
try {
sqlResult = await queryDruidSql({
query: `SELECT
COUNT(*) AS "historicals"
FROM sys.servers
WHERE "server_type" = 'historical'`,
});
} catch (e) {
controls.addIssue(`Could not run a sys.servers query. Got: ${e.message}`);
return;
}
if (sqlResult.length === 1 && sqlResult[0]['historicals'] === 0) {
controls.addIssue(`There do not appear to be any historical nodes.`);
}
},
},
{
name: 'Verify that the historicals are not overfilled',
check: async controls => {
// Make sure that no nodes are reported that are over 95% capacity
let sqlResult: any[];
try {
sqlResult = await queryDruidSql({
query: `SELECT
"server",
"curr_size" * 1.0 / "max_size" AS "fill"
FROM sys.servers
WHERE "server_type" = 'historical' AND "curr_size" * 1.0 / "max_size" > 0.9
ORDER BY "server" DESC`,
});
} catch (e) {
controls.addIssue(`Could not run a sys.servers query. Got: ${e.message}`);
return;
}
function formatPercent(server: any): string {
return (server['fill'] * 100).toFixed(2);
}
for (const server of sqlResult) {
if (server['fill'] > 0.95) {
controls.addIssue(
`Server "${server['server']}" appears to be over 95% full (is ${formatPercent(
server,
)}%). Increase capacity.`,
);
} else {
controls.addSuggestion(
`Server "${server['server']}" appears to be over 90% full (is ${formatPercent(
server,
)}%)`,
);
}
}
},
},
{
name: 'Look for time chunks that could benefit from compaction',
check: async controls => {
// Check for any time chunks where there is more than 1 segment and avg segment size is less than 100MB
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
let sqlResult: any[];
try {
sqlResult = await queryDruidSql({
query: `SELECT
"datasource",
COUNT(*) AS "num_bad_time_chunks"
FROM (
SELECT
"datasource", "start", "end",
AVG("size") AS "avg_segment_size_in_time_chunk",
SUM("size") AS "total_size",
COUNT(*) AS "num_segments"
FROM sys.segments
WHERE is_published = 1 AND "start" < '${dayAgo}'
GROUP BY 1, 2, 3
HAVING "num_segments" > 1 AND "total_size" > 1 AND "avg_segment_size_in_time_chunk" < 100000000
)
GROUP BY 1
ORDER BY "num_bad_time_chunks"`,
});
} catch (e) {
return;
}
if (sqlResult.length) {
// Grab the auto-compaction definitions and ignore dataSources that already have auto-compaction
let compactionResult: any;
try {
compactionResult = (await axios.get('/druid/coordinator/v1/config/compaction')).data;
} catch (e) {
controls.addIssue(`Could not get compaction config. Something is wrong.`);
return;
}
if (!compactionResult.compactionConfigs) return;
if (!Array.isArray(compactionResult.compactionConfigs)) {
controls.addIssue(`Got invalid value from compaction config. Something is wrong.`);
return;
}
const dataSourcesWithCompaction = compactionResult.compactionConfigs.map(
(d: any) => d.dataSource,
);
sqlResult = sqlResult.filter(d => !dataSourcesWithCompaction.includes(d['datasource']));
for (const datasource of sqlResult) {
controls.addSuggestion(
`Datasource "${
datasource['datasource']
}" could benefit from auto-compaction as it has ${pluralIfNeeded(
datasource['num_bad_time_chunks'],
'time chunk',
)} that have multiple small segments that could be compacted.`,
);
}
}
},
},
];

View File

@ -0,0 +1,33 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.doctor-dialog {
&.bp3-dialog {
margin-top: 5vh;
top: 5%;
}
.bp3-dialog-body {
height: 70vh;
overflow: scroll;
}
.diagnosis {
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,31 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react';
import React from 'react';
import { DoctorDialog } from './doctor-dialog';
describe('doctor dialog', () => {
it('matches snapshot', () => {
const doctorDialog = <DoctorDialog onClose={() => {}} />;
render(doctorDialog);
expect(document.body.lastChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,200 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Callout, Classes, Dialog, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import React from 'react';
import { delay, pluralIfNeeded } from '../../utils';
import { DOCTOR_CHECKS } from './doctor-checks';
import './doctor-dialog.scss';
interface Diagnosis {
type: 'suggestion' | 'issue';
check: string;
message: string;
}
export interface DoctorDialogProps {
onClose: () => void;
}
export interface DoctorDialogState {
currentCheckIndex?: number;
diagnoses?: Diagnosis[];
earlyTermination?: string;
}
export class DoctorDialog extends React.PureComponent<DoctorDialogProps, DoctorDialogState> {
private mounted = false;
constructor(props: DoctorDialogProps, context: any) {
super(props, context);
this.state = {};
}
componentDidMount(): void {
this.mounted = true;
}
componentWillUnmount(): void {
this.mounted = false;
}
async doChecks() {
this.setState({ currentCheckIndex: 0, diagnoses: [] });
const addToDiagnoses = (diagnosis: Diagnosis) => {
if (!this.mounted) return;
this.setState(oldState => ({
diagnoses: (oldState.diagnoses || []).concat(diagnosis),
}));
};
for (let i = 0; i < DOCTOR_CHECKS.length; i++) {
if (!this.mounted) return;
this.setState({ currentCheckIndex: i });
const check = DOCTOR_CHECKS[i];
let terminateChecks = false;
try {
await Promise.all([
await delay(450), // Make sure that a test takes at least this long so that the user can read the test name in the GUI,
check.check({
addSuggestion: (message: string) => {
addToDiagnoses({
type: 'suggestion',
check: check.name,
message,
});
},
addIssue: (message: string) => {
addToDiagnoses({
type: 'issue',
check: check.name,
message,
});
},
terminateChecks: () => {
if (!this.mounted) return;
this.setState({
earlyTermination: `The check "${check.name}" early terminated the check suite as it has encountered a condition that would make the rest of the tests meaningless.`,
});
terminateChecks = true;
},
}),
]);
} catch (e) {
addToDiagnoses({
type: 'issue',
check: check.name,
message: `The check "${check.name}" encountered an unhandled exception. Please report this issue. Message: ${e.message}`,
});
}
if (terminateChecks) break;
}
if (!this.mounted) return;
this.setState({ currentCheckIndex: undefined });
}
renderContent() {
const { diagnoses, currentCheckIndex, earlyTermination } = this.state;
if (diagnoses) {
let note: string;
if (typeof currentCheckIndex === 'number') {
note = `Running check ${currentCheckIndex + 1}/${DOCTOR_CHECKS.length}: ${
DOCTOR_CHECKS[currentCheckIndex].name
}`;
} else if (earlyTermination) {
note = `Checks stopped abruptly`;
} else {
note = `All ${pluralIfNeeded(DOCTOR_CHECKS.length, 'check')} completed`;
}
return (
<>
<Callout className="diagnosis">{note}</Callout>
{diagnoses.map((diagnosis, i) => {
return (
<Callout
key={i}
className="diagnosis"
icon={diagnosis.type === 'suggestion' ? IconNames.FLAG : IconNames.WARNING_SIGN}
intent={diagnosis.type === 'suggestion' ? Intent.NONE : Intent.WARNING}
>
{diagnosis.message}
</Callout>
);
})}
{earlyTermination && (
<Callout className="diagnosis" intent={Intent.DANGER}>
{earlyTermination}
</Callout>
)}
{!earlyTermination && currentCheckIndex == null && diagnoses.length === 0 && (
<Callout className="diagnosis" intent={Intent.SUCCESS}>
No issues detected
</Callout>
)}
</>
);
} else {
return (
<div className="analyze-bar">
<Callout className="diagnosis">
Automated checks to troubleshoot issues with the cluster.
</Callout>
<Button
text="Analyze Druid cluster"
intent={Intent.PRIMARY}
fill
onClick={() => this.doChecks()}
/>
</div>
);
}
}
render(): JSX.Element {
const { onClose } = this.props;
return (
<Dialog
className="doctor-dialog"
icon={IconNames.PULSE}
onClose={onClose}
title="Druid Doctor"
isOpen
canEscapeKeyClose={false}
canOutsideClickClose={false}
>
<div className={Classes.DIALOG_BODY}>{this.renderContent()}</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={onClose}>Close</Button>
</div>
</div>
</Dialog>
);
}
}

View File

@ -338,3 +338,9 @@ export function copyAndAlert(copyString: string, alertMessage: string): void {
intent: Intent.SUCCESS,
});
}
export function delay(ms: number) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}

View File

@ -547,7 +547,7 @@ export function getFlattenFieldFormFields() {
}
export interface TransformSpec {
transforms: Transform[];
transforms?: Transform[];
filter?: any;
}

View File

@ -151,7 +151,10 @@ export async function getOverlordModules(): Promise<string[]> {
return statusResp.data.modules.map((m: any) => m.artifact);
}
async function postToSampler(sampleSpec: SampleSpec, forStr: string): Promise<SampleResponse> {
export async function postToSampler(
sampleSpec: SampleSpec,
forStr: string,
): Promise<SampleResponse> {
let sampleResp: any;
try {
sampleResp = await axios.post(`${SAMPLER_URL}?for=${forStr}`, sampleSpec);