Web console: fine grained capabilities / graceful degradation (#8805)

* fine grained capabilities

* fix tests

* configure all cards

* better detection

* update tests

* rename server to service

* node -> service

* remove console log

* better loader in data loader
This commit is contained in:
Vadim Ogievetsky 2019-11-05 23:39:14 -08:00 committed by GitHub
parent 6f7fbeb63a
commit 7addfc27da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 623 additions and 440 deletions

View File

@ -10,7 +10,7 @@ exports[`header bar matches snapshot 1`] = `
<a <a
href="#" href="#"
> >
<Logo /> <Memo(DruidLogo) />
</a> </a>
<Blueprint3.NavbarDivider /> <Blueprint3.NavbarDivider />
<Blueprint3.AnchorButton <Blueprint3.AnchorButton
@ -46,14 +46,15 @@ exports[`header bar matches snapshot 1`] = `
/> />
<Blueprint3.AnchorButton <Blueprint3.AnchorButton
active={false} active={false}
href="#servers" href="#services"
icon="database" icon="database"
minimal={true} minimal={true}
text="Servers" text="Services"
/> />
<Blueprint3.NavbarDivider /> <Blueprint3.NavbarDivider />
<Blueprint3.AnchorButton <Blueprint3.AnchorButton
active={false} active={false}
disabled={false}
href="#query" href="#query"
icon="application" icon="application"
minimal={true} minimal={true}
@ -66,7 +67,17 @@ exports[`header bar matches snapshot 1`] = `
<Blueprint3.Popover <Blueprint3.Popover
boundary="scrollParent" boundary="scrollParent"
captureDismiss={false} captureDismiss={false}
content={<LegacyMenu />} content={
<Memo(LegacyMenu)
capabilities={
Capabilities {
"coordinator": true,
"overlord": true,
"queryType": "nativeAndSql",
}
}
/>
}
defaultIsOpen={false} defaultIsOpen={false}
disabled={false} disabled={false}
fill={false} fill={false}
@ -85,7 +96,6 @@ exports[`header bar matches snapshot 1`] = `
wrapperTagName="span" wrapperTagName="span"
> >
<Blueprint3.Button <Blueprint3.Button
disabled={false}
icon="share" icon="share"
minimal={true} minimal={true}
text="Legacy" text="Legacy"
@ -153,7 +163,6 @@ exports[`header bar matches snapshot 1`] = `
wrapperTagName="span" wrapperTagName="span"
> >
<Blueprint3.Button <Blueprint3.Button
disabled={false}
icon="cog" icon="cog"
minimal={true} minimal={true}
/> />

View File

@ -21,7 +21,7 @@
.header-bar { .header-bar {
overflow: hidden; overflow: hidden;
.logo { .druid-logo {
position: relative; position: relative;
width: 100px; width: 100px;
height: $header-bar-height; height: $header-bar-height;

View File

@ -19,12 +19,14 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../utils/capabilities';
import { HeaderBar } from './header-bar'; import { HeaderBar } from './header-bar';
describe('header bar', () => { describe('header bar', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const headerBar = shallow( const headerBar = shallow(
<HeaderBar active={'load-data'} hideLegacy={false} capabilities="full" />, <HeaderBar active={'load-data'} hideLegacy={false} capabilities={Capabilities.FULL} />,
); );
expect(headerBar).toMatchSnapshot(); expect(headerBar).toMatchSnapshot();
}); });

View File

@ -55,12 +55,12 @@ export type HeaderActiveTab =
| 'datasources' | 'datasources'
| 'segments' | 'segments'
| 'tasks' | 'tasks'
| 'servers' | 'services'
| 'lookups'; | 'lookups';
function Logo() { const DruidLogo = React.memo(function DruidLogo() {
return ( return (
<div className="logo"> <div className="druid-logo">
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -113,9 +113,15 @@ function Logo() {
</svg> </svg>
</div> </div>
); );
});
interface LegacyMenuProps {
capabilities: Capabilities;
} }
function LegacyMenu() { const LegacyMenu = React.memo(function LegacyMenu(props: LegacyMenuProps) {
const { capabilities } = props;
return ( return (
<Menu> <Menu>
<MenuItem <MenuItem
@ -123,16 +129,18 @@ function LegacyMenu() {
text="Legacy coordinator console" text="Legacy coordinator console"
href={LEGACY_COORDINATOR_CONSOLE} href={LEGACY_COORDINATOR_CONSOLE}
target="_blank" target="_blank"
disabled={!capabilities.hasCoordinatorAccess()}
/> />
<MenuItem <MenuItem
icon={IconNames.MAP} icon={IconNames.MAP}
text="Legacy overlord console" text="Legacy overlord console"
href={LEGACY_OVERLORD_CONSOLE} href={LEGACY_OVERLORD_CONSOLE}
target="_blank" target="_blank"
disabled={!capabilities.hasOverlordAccess()}
/> />
</Menu> </Menu>
); );
} });
export interface HeaderBarProps { export interface HeaderBarProps {
active: HeaderActiveTab; active: HeaderActiveTab;
@ -171,22 +179,26 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
icon={IconNames.PULSE} icon={IconNames.PULSE}
text="Druid Doctor" text="Druid Doctor"
onClick={() => setDoctorDialogOpen(true)} onClick={() => setDoctorDialogOpen(true)}
disabled={!capabilities.hasEverything()}
/> />
<MenuItem <MenuItem
icon={IconNames.SETTINGS} icon={IconNames.SETTINGS}
text="Coordinator dynamic config" text="Coordinator dynamic config"
onClick={() => setCoordinatorDynamicConfigDialogOpen(true)} onClick={() => setCoordinatorDynamicConfigDialogOpen(true)}
disabled={!capabilities.hasCoordinatorAccess()}
/> />
<MenuItem <MenuItem
icon={IconNames.WRENCH} icon={IconNames.WRENCH}
text="Overlord dynamic config" text="Overlord dynamic config"
onClick={() => setOverlordDynamicConfigDialogOpen(true)} onClick={() => setOverlordDynamicConfigDialogOpen(true)}
disabled={!capabilities.hasOverlordAccess()}
/> />
<MenuItem <MenuItem
icon={IconNames.PROPERTIES} icon={IconNames.PROPERTIES}
active={active === 'lookups'} active={active === 'lookups'}
text="Lookups" text="Lookups"
href="#lookups" href="#lookups"
disabled={!capabilities.hasCoordinatorAccess()}
/> />
</Menu> </Menu>
); );
@ -195,7 +207,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
<Navbar className="header-bar"> <Navbar className="header-bar">
<NavbarGroup align={Alignment.LEFT}> <NavbarGroup align={Alignment.LEFT}>
<a href="#"> <a href="#">
<Logo /> <DruidLogo />
</a> </a>
<NavbarDivider /> <NavbarDivider />
@ -206,7 +218,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
href="#load-data" href="#load-data"
minimal={!loadDataPrimary} minimal={!loadDataPrimary}
intent={loadDataPrimary ? Intent.PRIMARY : Intent.NONE} intent={loadDataPrimary ? Intent.PRIMARY : Intent.NONE}
disabled={capabilities === 'no-proxy'} disabled={!capabilities.hasEverything()}
/> />
<NavbarDivider /> <NavbarDivider />
@ -233,10 +245,10 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
/> />
<AnchorButton <AnchorButton
minimal minimal
active={active === 'servers'} active={active === 'services'}
icon={IconNames.DATABASE} icon={IconNames.DATABASE}
text="Servers" text="Services"
href="#servers" href="#services"
/> />
<NavbarDivider /> <NavbarDivider />
@ -246,29 +258,20 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
icon={IconNames.APPLICATION} icon={IconNames.APPLICATION}
text="Query" text="Query"
href="#query" href="#query"
disabled={!capabilities.hasQuerying()}
/> />
</NavbarGroup> </NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}> <NavbarGroup align={Alignment.RIGHT}>
{!hideLegacy && ( {!hideLegacy && (
<Popover <Popover
content={<LegacyMenu />} content={<LegacyMenu capabilities={capabilities} />}
position={Position.BOTTOM_RIGHT} position={Position.BOTTOM_RIGHT}
disabled={capabilities === 'no-proxy'}
> >
<Button <Button minimal icon={IconNames.SHARE} text="Legacy" />
minimal
icon={IconNames.SHARE}
text="Legacy"
disabled={capabilities === 'no-proxy'}
/>
</Popover> </Popover>
)} )}
<Popover <Popover content={configMenu} position={Position.BOTTOM_RIGHT}>
content={configMenu} <Button minimal icon={IconNames.COG} />
position={Position.BOTTOM_RIGHT}
disabled={capabilities === 'no-proxy'}
>
<Button minimal icon={IconNames.COG} disabled={capabilities === 'no-proxy'} />
</Popover> </Popover>
<Popover content={helpMenu} position={Position.BOTTOM_RIGHT}> <Popover content={helpMenu} position={Position.BOTTOM_RIGHT}>
<Button minimal icon={IconNames.HELP} /> <Button minimal icon={IconNames.HELP} />

View File

@ -18,14 +18,13 @@
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import axios from 'axios';
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom'; import { HashRouter, Route, Switch } from 'react-router-dom';
import { ExternalLink, HeaderActiveTab, HeaderBar, Loader } from './components'; import { ExternalLink, HeaderActiveTab, HeaderBar, Loader } from './components';
import { AppToaster } from './singletons/toaster'; import { AppToaster } from './singletons/toaster';
import { localStorageGet, LocalStorageKeys, QueryManager } from './utils'; import { QueryManager } from './utils';
import { Capabilities } from './utils/capabilities'; import { Capabilities } from './utils/capabilities';
import { DRUID_DOCS_API, DRUID_DOCS_SQL, DRUID_DOCS_VERSION } from './variables'; import { DRUID_DOCS_API, DRUID_DOCS_SQL, DRUID_DOCS_VERSION } from './variables';
import { import {
@ -35,7 +34,7 @@ import {
LookupsView, LookupsView,
QueryView, QueryView,
SegmentsView, SegmentsView,
ServersView, ServicesView,
TasksView, TasksView,
} from './views'; } from './views';
@ -55,91 +54,49 @@ export class ConsoleApplication extends React.PureComponent<
ConsoleApplicationProps, ConsoleApplicationProps,
ConsoleApplicationState ConsoleApplicationState
> { > {
static STATUS_TIMEOUT = 2000;
private capabilitiesQueryManager: QueryManager<null, Capabilities>; private capabilitiesQueryManager: QueryManager<null, Capabilities>;
static async discoverCapabilities(): Promise<Capabilities> { static shownNotifications(capabilities: Capabilities | undefined) {
const capabilitiesOverride = localStorageGet(LocalStorageKeys.CAPABILITIES_OVERRIDE);
if (capabilitiesOverride) return capabilitiesOverride as Capabilities;
// Check SQL endpoint
try {
await axios.post(
'/druid/v2/sql',
{ query: 'SELECT 1337', context: { timeout: ConsoleApplication.STATUS_TIMEOUT } },
{ timeout: ConsoleApplication.STATUS_TIMEOUT },
);
} catch (e) {
const { response } = e;
if (response.status !== 405 || response.statusText !== 'Method Not Allowed') {
return 'full'; // other failure
}
try {
await axios.get('/status', { timeout: ConsoleApplication.STATUS_TIMEOUT });
} catch (e) {
return 'broken'; // total failure
}
// Status works but SQL 405s => the SQL endpoint is disabled
return 'no-sql';
}
// Check proxy
try {
await axios.get('/proxy/coordinator/status', { timeout: ConsoleApplication.STATUS_TIMEOUT });
} catch (e) {
const { response } = e;
if (response.status !== 404) {
console.log('response.statusText', response.statusText);
return 'full'; // other failure
}
return 'no-proxy';
}
return 'full';
}
static shownNotifications(capabilities: Capabilities) {
let message: JSX.Element; let message: JSX.Element;
switch (capabilities) { if (!capabilities) {
case 'no-sql': message = (
message = ( <>
<> It appears that the the service serving this console is not responding. The console will
It appears that the SQL endpoint is disabled. The console will fall back to{' '} not function at the moment
<ExternalLink href={DRUID_DOCS_API}>native Druid APIs</ExternalLink> and will be limited </>
in functionality. Look at{' '} );
<ExternalLink href={DRUID_DOCS_SQL}>the SQL docs</ExternalLink> to enable the SQL } else {
endpoint. switch (capabilities.getMode()) {
</> case 'no-sql':
); message = (
break; <>
It appears that the SQL endpoint is disabled. The console will fall back to{' '}
<ExternalLink href={DRUID_DOCS_API}>native Druid APIs</ExternalLink> and will be
limited in functionality. Look at{' '}
<ExternalLink href={DRUID_DOCS_SQL}>the SQL docs</ExternalLink> to enable the SQL
endpoint.
</>
);
break;
case 'no-proxy': case 'no-proxy':
message = ( message = (
<> <>
It appears that the management proxy is not enabled, the console will operate with It appears that the management proxy is not enabled, the console will operate with
limited functionality. Look at{' '} limited functionality. Look at{' '}
<ExternalLink <ExternalLink
href={`https://druid.apache.org/docs/${DRUID_DOCS_VERSION}/operations/management-uis.html#druid-console`} href={`https://druid.apache.org/docs/${DRUID_DOCS_VERSION}/operations/management-uis.html#druid-console`}
> >
the console docs the console docs
</ExternalLink>{' '} </ExternalLink>{' '}
for more info on how to enable the management proxy. for more info on how to enable the management proxy.
</> </>
); );
break; break;
case 'broken': default:
message = ( return;
<> }
It appears that the the Router node is not responding. The console will not function at
the moment
</>
);
break;
default:
return;
} }
AppToaster.show({ AppToaster.show({
@ -161,21 +118,19 @@ export class ConsoleApplication extends React.PureComponent<
constructor(props: ConsoleApplicationProps, context: any) { constructor(props: ConsoleApplicationProps, context: any) {
super(props, context); super(props, context);
this.state = { this.state = {
capabilities: 'full', capabilities: Capabilities.FULL,
capabilitiesLoading: true, capabilitiesLoading: true,
}; };
this.capabilitiesQueryManager = new QueryManager({ this.capabilitiesQueryManager = new QueryManager({
processQuery: async () => { processQuery: async () => {
const capabilities = await ConsoleApplication.discoverCapabilities(); const capabilities = await Capabilities.detectCapabilities();
if (capabilities !== 'full') { ConsoleApplication.shownNotifications(capabilities);
ConsoleApplication.shownNotifications(capabilities); return capabilities || Capabilities.FULL;
}
return capabilities;
}, },
onStateChange: ({ result, loading }) => { onStateChange: ({ result, loading }) => {
this.setState({ this.setState({
capabilities: result || 'full', capabilities: result || Capabilities.FULL,
capabilitiesLoading: loading, capabilitiesLoading: loading,
}); });
}, },
@ -238,7 +193,7 @@ export class ConsoleApplication extends React.PureComponent<
private goToMiddleManager = (middleManager: string) => { private goToMiddleManager = (middleManager: string) => {
this.middleManager = middleManager; this.middleManager = middleManager;
window.location.hash = 'servers'; window.location.hash = 'services';
this.resetInitialsWithDelay(); this.resetInitialsWithDelay();
}; };
@ -332,11 +287,11 @@ export class ConsoleApplication extends React.PureComponent<
); );
}; };
private wrappedServersView = () => { private wrappedServicesView = () => {
const { capabilities } = this.state; const { capabilities } = this.state;
return this.wrapInViewContainer( return this.wrapInViewContainer(
'servers', 'services',
<ServersView <ServicesView
middleManager={this.middleManager} middleManager={this.middleManager}
goToQuery={this.goToQuery} goToQuery={this.goToQuery}
goToTask={this.goToTaskWithTaskId} goToTask={this.goToTaskWithTaskId}
@ -369,7 +324,7 @@ export class ConsoleApplication extends React.PureComponent<
<Route path="/datasources" component={this.wrappedDatasourcesView} /> <Route path="/datasources" component={this.wrappedDatasourcesView} />
<Route path="/segments" component={this.wrappedSegmentsView} /> <Route path="/segments" component={this.wrappedSegmentsView} />
<Route path="/tasks" component={this.wrappedTasksView} /> <Route path="/tasks" component={this.wrappedTasksView} />
<Route path="/servers" component={this.wrappedServersView} /> <Route path="/services" component={this.wrappedServicesView} />
<Route path="/query" component={this.wrappedQueryView} /> <Route path="/query" component={this.wrappedQueryView} />

View File

@ -261,9 +261,9 @@ export class CoordinatorDynamicConfigDialog extends React.PureComponent<
type: 'string-array', type: 'string-array',
info: ( info: (
<> <>
List of historical servers to 'decommission'. Coordinator will not assign new List of historical services to 'decommission'. Coordinator will not assign new
segments to 'decommissioning' servers, and segments will be moved away from them segments to 'decommissioning' services, and segments will be moved away from them
to be placed on non-decommissioning servers at the maximum rate specified by{' '} to be placed on non-decommissioning services at the maximum rate specified by{' '}
<Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code>. <Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code>.
</> </>
), ),
@ -275,15 +275,15 @@ export class CoordinatorDynamicConfigDialog extends React.PureComponent<
info: ( info: (
<> <>
The maximum number of segments that may be moved away from 'decommissioning' The maximum number of segments that may be moved away from 'decommissioning'
servers to non-decommissioning (that is, active) servers during one Coordinator services to non-decommissioning (that is, active) services during one Coordinator
run. This value is relative to the total maximum segment movements allowed during run. This value is relative to the total maximum segment movements allowed during
one run which is determined by <Code>maxSegmentsToMove</Code>. If one run which is determined by <Code>maxSegmentsToMove</Code>. If
<Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code> is 0, segments will <Code>decommissioningMaxPercentOfMaxSegmentsToMove</Code> is 0, segments will
neither be moved from or to 'decommissioning' servers, effectively putting them in neither be moved from or to 'decommissioning' services, effectively putting them
a sort of "maintenance" mode that will not participate in balancing or assignment in a sort of "maintenance" mode that will not participate in balancing or
by load rules. Decommissioning can also become stalled if there are no available assignment by load rules. Decommissioning can also become stalled if there are no
active servers to place the segments. By leveraging the maximum percent of available active services to place the segments. By leveraging the maximum percent
decommissioning segment movements, an operator can prevent active servers from of decommissioning segment movements, an operator can prevent active services from
overload by prioritizing balancing, or decrease decommissioning time instead. The overload by prioritizing balancing, or decrease decommissioning time instead. The
value should be between 0 and 100. value should be between 0 and 100.
</> </>

View File

@ -38,7 +38,7 @@ const RUNTIME_PROPERTIES_ALL_NODES_MUST_AGREE_ON: string[] = [
'druid.zk.service.host', 'druid.zk.service.host',
]; ];
// In the future (when we can query other nodes) is will also be cool to check: // In the future (when we can query other services) is will also be cool to check:
// 'druid.storage.type' <=> historicals, overlords, mm // 'druid.storage.type' <=> historicals, overlords, mm
// 'druid.indexer.logs.type' <=> overlord, mm, + peons // 'druid.indexer.logs.type' <=> overlord, mm, + peons
@ -60,7 +60,7 @@ export const DOCTOR_CHECKS: DoctorCheck[] = [
status = (await axios.get(`/status`)).data; status = (await axios.get(`/status`)).data;
} catch (e) { } catch (e) {
controls.addIssue( controls.addIssue(
`Did not get a /status response from the Router node. Try confirming that it is running and accessible. Got: ${e.message}`, `Did not get a /status response from the Router service. Try confirming that it is running and accessible. Got: ${e.message}`,
); );
controls.terminateChecks(); controls.terminateChecks();
return; return;
@ -137,7 +137,7 @@ export const DOCTOR_CHECKS: DoctorCheck[] = [
coordinatorStatus = (await axios.get(`/proxy/coordinator/status`)).data; coordinatorStatus = (await axios.get(`/proxy/coordinator/status`)).data;
} catch (e) { } catch (e) {
controls.addIssue( controls.addIssue(
'Did not get a /status response from the Coordinator node. Try confirming that it is running and accessible.', 'Did not get a /status response from the Coordinator service. Try confirming that it is running and accessible.',
); );
return; return;
} }
@ -147,20 +147,20 @@ export const DOCTOR_CHECKS: DoctorCheck[] = [
overlordStatus = (await axios.get(`/proxy/overlord/status`)).data; overlordStatus = (await axios.get(`/proxy/overlord/status`)).data;
} catch (e) { } catch (e) {
controls.addIssue( controls.addIssue(
'Did not get a /status response from the Overlord node. Try confirming that it is running and accessible.', 'Did not get a /status response from the Overlord service. Try confirming that it is running and accessible.',
); );
return; return;
} }
if (myStatus.version !== coordinatorStatus.version) { if (myStatus.version !== coordinatorStatus.version) {
controls.addSuggestion( 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.`, `It looks like the Router and Coordinator services 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) { if (myStatus.version !== overlordStatus.version) {
controls.addSuggestion( 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.`, `It looks like the Router and Overlord services are on different versions of Druid. This may indicate a problem if you are not in the middle of a rolling upgrade.`,
); );
} }
}, },
@ -282,7 +282,7 @@ export const DOCTOR_CHECKS: DoctorCheck[] = [
sqlResult = await queryDruidSql({ query: `SELECT 1 + 1 AS "two"` }); sqlResult = await queryDruidSql({ query: `SELECT 1 + 1 AS "two"` });
} catch (e) { } catch (e) {
controls.addIssue( 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}`, `Could not query SQL ensure that "druid.sql.enable" is set to "true" and that there is a Broker service running. Got: ${e.message}`,
); );
controls.terminateChecks(); controls.terminateChecks();
return; return;
@ -294,9 +294,9 @@ export const DOCTOR_CHECKS: DoctorCheck[] = [
}, },
}, },
{ {
name: 'Verify that there are historical nodes', name: 'Verify that there are historical services',
check: async controls => { check: async controls => {
// Make sure that there are broker and historical nodes reported from sys.servers // Make sure that there are broker and historical services reported from sys.servers
let sqlResult: any[]; let sqlResult: any[];
try { try {
sqlResult = await queryDruidSql({ sqlResult = await queryDruidSql({
@ -311,19 +311,19 @@ WHERE "server_type" = 'historical'`,
} }
if (sqlResult.length === 1 && sqlResult[0]['historicals'] === 0) { if (sqlResult.length === 1 && sqlResult[0]['historicals'] === 0) {
controls.addIssue(`There do not appear to be any historical nodes.`); controls.addIssue(`There do not appear to be any historical services.`);
} }
}, },
}, },
{ {
name: 'Verify that the historicals are not overfilled', name: 'Verify that the historicals are not overfilled',
check: async controls => { check: async controls => {
// Make sure that no nodes are reported that are over 95% capacity // Make sure that no services are reported that are over 95% capacity
let sqlResult: any[]; let sqlResult: any[];
try { try {
sqlResult = await queryDruidSql({ sqlResult = await queryDruidSql({
query: `SELECT query: `SELECT
"server", "server" AS "service",
"curr_size" * 1.0 / "max_size" AS "fill" "curr_size" * 1.0 / "max_size" AS "fill"
FROM sys.servers FROM sys.servers
WHERE "server_type" = 'historical' AND "curr_size" * 1.0 / "max_size" > 0.9 WHERE "server_type" = 'historical' AND "curr_size" * 1.0 / "max_size" > 0.9
@ -334,21 +334,21 @@ ORDER BY "server" DESC`,
return; return;
} }
function formatPercent(server: any): string { function formatPercent(service: any): string {
return (server['fill'] * 100).toFixed(2); return (service['fill'] * 100).toFixed(2);
} }
for (const server of sqlResult) { for (const service of sqlResult) {
if (server['fill'] > 0.95) { if (service['fill'] > 0.95) {
controls.addIssue( controls.addIssue(
`Server "${server['server']}" appears to be over 95% full (is ${formatPercent( `Historical "${service['service']}" appears to be over 95% full (is ${formatPercent(
server, service,
)}%). Increase capacity.`, )}%). Increase capacity.`,
); );
} else { } else {
controls.addSuggestion( controls.addSuggestion(
`Server "${server['server']}" appears to be over 90% full (is ${formatPercent( `Historical "${service['service']}" appears to be over 90% full (is ${formatPercent(
server, service,
)}%)`, )}%)`,
); );
} }

View File

@ -16,4 +16,142 @@
* limitations under the License. * limitations under the License.
*/ */
export type Capabilities = 'full' | 'no-sql' | 'no-proxy' | 'broken'; import axios from 'axios';
import { localStorageGetJson, LocalStorageKeys } from './local-storage-keys';
export type CapabilitiesMode = 'full' | 'no-sql' | 'no-proxy';
export interface CapabilitiesOptions {
queryType: QueryType;
coordinator: boolean;
overlord: boolean;
}
export type QueryType = 'none' | 'nativeOnly' | 'nativeAndSql';
export class Capabilities {
static STATUS_TIMEOUT = 2000;
static FULL: Capabilities;
private queryType: QueryType;
private coordinator: boolean;
private overlord: boolean;
static async detectQueryType(): Promise<QueryType | undefined> {
// Check SQL endpoint
try {
await axios.post(
'/druid/v2/sql',
{ query: 'SELECT 1337', context: { timeout: Capabilities.STATUS_TIMEOUT } },
{ timeout: Capabilities.STATUS_TIMEOUT },
);
} catch (e) {
const { response } = e;
if (response.status !== 405 && response.status !== 404) {
return; // other failure
}
try {
await axios.get('/status', { timeout: Capabilities.STATUS_TIMEOUT });
} catch (e) {
return; // total failure
}
// Status works but SQL 405s => the SQL endpoint is disabled
try {
await axios.post(
'/druid/v2',
{
queryType: 'dataSourceMetadata',
dataSource: '__web_console_probe__',
context: { timeout: Capabilities.STATUS_TIMEOUT },
},
{ timeout: Capabilities.STATUS_TIMEOUT },
);
} catch (e) {
if (response.status !== 405 && response.status !== 404) {
return; // other failure
}
return 'none';
}
return 'nativeOnly';
}
return 'nativeAndSql';
}
static async detectNode(node: 'coordinator' | 'overlord'): Promise<boolean | undefined> {
try {
await axios.get(`/druid/${node === 'overlord' ? 'indexer' : node}/v1/isLeader`, {
timeout: Capabilities.STATUS_TIMEOUT,
});
} catch (e) {
const { response } = e;
if (response.status !== 404) {
return; // other failure
}
return false;
}
return true;
}
static async detectCapabilities(): Promise<Capabilities | undefined> {
const capabilitiesOverride = localStorageGetJson(LocalStorageKeys.CAPABILITIES_OVERRIDE);
if (capabilitiesOverride) return new Capabilities(capabilitiesOverride as any);
const queryType = await Capabilities.detectQueryType();
if (typeof queryType === 'undefined') return;
const coordinator = await Capabilities.detectNode('coordinator');
if (typeof coordinator === 'undefined') return;
const overlord = await Capabilities.detectNode('overlord');
if (typeof overlord === 'undefined') return;
return new Capabilities({
queryType,
coordinator,
overlord,
});
}
constructor(options: CapabilitiesOptions) {
this.queryType = options.queryType;
this.coordinator = options.coordinator;
this.overlord = options.overlord;
}
public getMode(): CapabilitiesMode {
if (!this.hasSql()) return 'no-sql';
if (!this.hasCoordinatorAccess()) return 'no-proxy';
return 'full';
}
public hasEverything(): boolean {
return this.queryType === 'nativeAndSql' && this.coordinator && this.overlord;
}
public hasQuerying(): boolean {
return this.queryType !== 'none';
}
public hasSql(): boolean {
return this.queryType === 'nativeAndSql';
}
public hasCoordinatorAccess(): boolean {
return this.coordinator;
}
public hasOverlordAccess(): boolean {
return this.overlord;
}
}
Capabilities.FULL = new Capabilities({
queryType: 'nativeAndSql',
coordinator: true,
overlord: true,
});

View File

@ -23,7 +23,7 @@ export const LocalStorageKeys = {
SEGMENT_TABLE_COLUMN_SELECTION: 'segment-table-column-selection' as 'segment-table-column-selection', SEGMENT_TABLE_COLUMN_SELECTION: 'segment-table-column-selection' as 'segment-table-column-selection',
SUPERVISOR_TABLE_COLUMN_SELECTION: 'supervisor-table-column-selection' as 'supervisor-table-column-selection', SUPERVISOR_TABLE_COLUMN_SELECTION: 'supervisor-table-column-selection' as 'supervisor-table-column-selection',
TASK_TABLE_COLUMN_SELECTION: 'task-table-column-selection' as 'task-table-column-selection', TASK_TABLE_COLUMN_SELECTION: 'task-table-column-selection' as 'task-table-column-selection',
SERVER_TABLE_COLUMN_SELECTION: 'historical-table-column-selection' as 'historical-table-column-selection', SERVICE_TABLE_COLUMN_SELECTION: 'service-table-column-selection' as 'service-table-column-selection',
LOOKUP_TABLE_COLUMN_SELECTION: 'lookup-table-column-selection' as 'lookup-table-column-selection', LOOKUP_TABLE_COLUMN_SELECTION: 'lookup-table-column-selection' as 'lookup-table-column-selection',
QUERY_KEY: 'druid-console-query' as 'druid-console-query', QUERY_KEY: 'druid-console-query' as 'druid-console-query',
QUERY_CONTEXT: 'query-context' as 'query-context', QUERY_CONTEXT: 'query-context' as 'query-context',
@ -32,7 +32,7 @@ export const LocalStorageKeys = {
TASKS_REFRESH_RATE: 'task-refresh-rate' as 'task-refresh-rate', TASKS_REFRESH_RATE: 'task-refresh-rate' as 'task-refresh-rate',
DATASOURCES_REFRESH_RATE: 'datasources-refresh-rate' as 'datasources-refresh-rate', DATASOURCES_REFRESH_RATE: 'datasources-refresh-rate' as 'datasources-refresh-rate',
SEGMENTS_REFRESH_RATE: 'segments-refresh-rate' as 'segments-refresh-rate', SEGMENTS_REFRESH_RATE: 'segments-refresh-rate' as 'segments-refresh-rate',
SERVERS_REFRESH_RATE: 'servers-refresh-rate' as 'servers-refresh-rate', SERVICES_REFRESH_RATE: 'services-refresh-rate' as 'services-refresh-rate',
SUPERVISORS_REFRESH_RATE: 'supervisors-refresh-rate' as 'supervisors-refresh-rate', SUPERVISORS_REFRESH_RATE: 'supervisors-refresh-rate' as 'supervisors-refresh-rate',
LOOKUPS_REFRESH_RATE: 'lookups-refresh-rate' as 'lookups-refresh-rate', LOOKUPS_REFRESH_RATE: 'lookups-refresh-rate' as 'lookups-refresh-rate',
QUERY_HISTORY: 'query-history' as 'query-history', QUERY_HISTORY: 'query-history' as 'query-history',

View File

@ -19,6 +19,8 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../utils/capabilities';
import { DatasourcesView } from './datasource-view'; import { DatasourcesView } from './datasource-view';
describe('data source view', () => { describe('data source view', () => {
@ -28,7 +30,7 @@ describe('data source view', () => {
goToQuery={() => {}} goToQuery={() => {}}
goToTask={() => null} goToTask={() => null}
goToSegments={() => {}} goToSegments={() => {}}
capabilities="full" capabilities={Capabilities.FULL}
/>, />,
); );
expect(dataSourceView).toMatchSnapshot(); expect(dataSourceView).toMatchSnapshot();

View File

@ -61,14 +61,14 @@ import {
QueryManager, QueryManager,
} from '../../utils'; } from '../../utils';
import { BasicAction } from '../../utils/basic-action'; import { BasicAction } from '../../utils/basic-action';
import { Capabilities } from '../../utils/capabilities'; import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
import { RuleUtil } from '../../utils/load-rule'; import { RuleUtil } from '../../utils/load-rule';
import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array'; import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
import { deepGet } from '../../utils/object-change'; import { deepGet } from '../../utils/object-change';
import './datasource-view.scss'; import './datasource-view.scss';
const tableColumns: Record<Capabilities, string[]> = { const tableColumns: Record<CapabilitiesMode, string[]> = {
full: [ full: [
'Datasource', 'Datasource',
'Availability', 'Availability',
@ -101,7 +101,6 @@ const tableColumns: Record<Capabilities, string[]> = {
'Num rows', 'Num rows',
ACTION_COLUMN_LABEL, ACTION_COLUMN_LABEL,
], ],
broken: ['Datasource'],
}; };
function formatLoadDrop(segmentsToLoad: number, segmentsToDrop: number): string { function formatLoadDrop(segmentsToLoad: number, segmentsToDrop: number): string {
@ -247,9 +246,9 @@ GROUP BY 1`;
this.datasourceQueryManager = new QueryManager({ this.datasourceQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
let datasources: DatasourceQueryResultRow[]; let datasources: DatasourceQueryResultRow[];
if (capabilities !== 'no-sql') { if (capabilities.hasSql()) {
datasources = await queryDruidSql({ query: DatasourcesView.DATASOURCE_SQL }); datasources = await queryDruidSql({ query: DatasourcesView.DATASOURCE_SQL });
} else { } else if (capabilities.hasCoordinatorAccess()) {
const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources?simple'); const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources?simple');
const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple'); const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple');
const loadstatus = loadstatusResp.data; const loadstatus = loadstatusResp.data;
@ -272,9 +271,11 @@ GROUP BY 1`;
}; };
}, },
); );
} else {
throw new Error(`must have SQL or coordinator access`);
} }
if (capabilities === 'no-proxy') { if (!capabilities.hasCoordinatorAccess()) {
datasources.forEach((ds: any) => { datasources.forEach((ds: any) => {
ds.rules = []; ds.rules = [];
}); });
@ -496,7 +497,7 @@ GROUP BY 1`;
const { goToQuery, capabilities } = this.props; const { goToQuery, capabilities } = this.props;
const bulkDatasourceActionsMenu = ( const bulkDatasourceActionsMenu = (
<Menu> <Menu>
{capabilities !== 'no-sql' && ( {capabilities.hasSql() && (
<MenuItem <MenuItem
icon={IconNames.APPLICATION} icon={IconNames.APPLICATION}
text="View SQL query for table" text="View SQL query for table"
@ -621,7 +622,7 @@ GROUP BY 1`;
}, },
]; ];
if (capabilities === 'no-proxy') { if (!capabilities.hasCoordinatorAccess()) {
return goToActions; return goToActions;
} }
@ -887,7 +888,7 @@ GROUP BY 1`;
</span> </span>
); );
}, },
show: capabilities !== 'no-proxy' && hiddenColumns.exists('Retention'), show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Retention'),
}, },
{ {
Header: 'Replicated size', Header: 'Replicated size',
@ -941,7 +942,7 @@ GROUP BY 1`;
</span> </span>
); );
}, },
show: capabilities !== 'no-proxy' && hiddenColumns.exists('Compaction'), show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Compaction'),
}, },
{ {
Header: 'Avg. segment size', Header: 'Avg. segment size',
@ -957,7 +958,7 @@ GROUP BY 1`;
filterable: false, filterable: false,
width: 100, width: 100,
Cell: row => formatNumber(row.value), Cell: row => formatNumber(row.value),
show: capabilities !== 'no-sql' && hiddenColumns.exists('Num rows'), show: capabilities.hasSql() && hiddenColumns.exists('Num rows'),
}, },
{ {
Header: ACTION_COLUMN_LABEL, Header: ACTION_COLUMN_LABEL,
@ -1030,7 +1031,7 @@ GROUP BY 1`;
label="Show segment timeline" label="Show segment timeline"
onChange={() => this.setState({ showChart: !showChart })} onChange={() => this.setState({ showChart: !showChart })}
/> />
{capabilities !== 'no-proxy' && ( {capabilities.hasCoordinatorAccess() && (
<Switch <Switch
checked={showDisabled} checked={showDisabled}
label="Show disabled" label="Show disabled"
@ -1038,7 +1039,7 @@ GROUP BY 1`;
/> />
)} )}
<TableColumnSelector <TableColumnSelector
columns={tableColumns[capabilities]} columns={tableColumns[capabilities.getMode()]}
onChange={column => onChange={column =>
this.setState(prevState => ({ this.setState(prevState => ({
hiddenColumns: prevState.hiddenColumns.toggle(column), hiddenColumns: prevState.hiddenColumns.toggle(column),

View File

@ -6,19 +6,49 @@ exports[`home view matches snapshot 1`] = `
> >
<StatusCard /> <StatusCard />
<DatasourcesCard <DatasourcesCard
capabilities="full" capabilities={
Capabilities {
"coordinator": true,
"overlord": true,
"queryType": "nativeAndSql",
}
}
/> />
<SegmentsCard <SegmentsCard
capabilities="full" capabilities={
Capabilities {
"coordinator": true,
"overlord": true,
"queryType": "nativeAndSql",
}
}
/> />
<SupervisorsCard <SupervisorsCard
capabilities="full" capabilities={
Capabilities {
"coordinator": true,
"overlord": true,
"queryType": "nativeAndSql",
}
}
/> />
<TasksCard <TasksCard
capabilities="full" capabilities={
Capabilities {
"coordinator": true,
"overlord": true,
"queryType": "nativeAndSql",
}
}
/> />
<ServersCard <ServicesCard
capabilities="full" capabilities={
Capabilities {
"coordinator": true,
"overlord": true,
"queryType": "nativeAndSql",
}
}
/> />
<LookupsCard /> <LookupsCard />
</div> </div>

View File

@ -19,11 +19,13 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../../utils/capabilities';
import { DatasourcesCard } from './datasources-card'; import { DatasourcesCard } from './datasources-card';
describe('datasources card', () => { describe('datasources card', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const datasourcesCard = <DatasourcesCard capabilities="full" />; const datasourcesCard = <DatasourcesCard capabilities={Capabilities.FULL} />;
const { container } = render(datasourcesCard); const { container } = render(datasourcesCard);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();

View File

@ -50,14 +50,17 @@ export class DatasourcesCard extends React.PureComponent<
this.datasourceQueryManager = new QueryManager({ this.datasourceQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
let datasources: string[]; let datasources: string[];
if (capabilities !== 'no-sql') { if (capabilities.hasSql()) {
datasources = await queryDruidSql({ datasources = await queryDruidSql({
query: `SELECT datasource FROM sys.segments GROUP BY 1`, query: `SELECT datasource FROM sys.segments GROUP BY 1`,
}); });
} else { } else if (capabilities.hasCoordinatorAccess()) {
const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources'); const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources');
datasources = datasourcesResp.data; datasources = datasourcesResp.data;
} else {
throw new Error(`must have SQL or coordinator access`);
} }
return datasources.length; return datasources.length;
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {

View File

@ -20,7 +20,7 @@
.home-view { .home-view {
display: grid; display: grid;
grid-gap: $standard-padding; gap: $standard-padding;
grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr;
& > a { & > a {

View File

@ -19,11 +19,13 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../utils/capabilities';
import { HomeView } from './home-view'; import { HomeView } from './home-view';
describe('home view', () => { describe('home view', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const homeView = shallow(<HomeView capabilities="full" />); const homeView = shallow(<HomeView capabilities={Capabilities.FULL} />);
expect(homeView).toMatchSnapshot(); expect(homeView).toMatchSnapshot();
}); });
}); });

View File

@ -23,7 +23,7 @@ import { Capabilities } from '../../utils/capabilities';
import { DatasourcesCard } from './datasources-card/datasources-card'; import { DatasourcesCard } from './datasources-card/datasources-card';
import { LookupsCard } from './lookups-card/lookups-card'; import { LookupsCard } from './lookups-card/lookups-card';
import { SegmentsCard } from './segments-card/segments-card'; import { SegmentsCard } from './segments-card/segments-card';
import { ServersCard } from './servers-card/servers-card'; import { ServicesCard } from './services-card/services-card';
import { StatusCard } from './status-card/status-card'; import { StatusCard } from './status-card/status-card';
import { SupervisorsCard } from './supervisors-card/supervisors-card'; import { SupervisorsCard } from './supervisors-card/supervisors-card';
import { TasksCard } from './tasks-card/tasks-card'; import { TasksCard } from './tasks-card/tasks-card';
@ -44,7 +44,7 @@ export const HomeView = React.memo(function HomeView(props: HomeViewProps) {
<SegmentsCard capabilities={capabilities} /> <SegmentsCard capabilities={capabilities} />
<SupervisorsCard capabilities={capabilities} /> <SupervisorsCard capabilities={capabilities} />
<TasksCard capabilities={capabilities} /> <TasksCard capabilities={capabilities} />
<ServersCard capabilities={capabilities} /> <ServicesCard capabilities={capabilities} />
<LookupsCard /> <LookupsCard />
</div> </div>
); );

View File

@ -19,11 +19,13 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../../utils/capabilities';
import { SegmentsCard } from './segments-card'; import { SegmentsCard } from './segments-card';
describe('segments card', () => { describe('segments card', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const segmentsCard = <SegmentsCard capabilities="full" />; const segmentsCard = <SegmentsCard capabilities={Capabilities.FULL} />;
const { container } = render(segmentsCard); const { container } = render(segmentsCard);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();

View File

@ -50,7 +50,15 @@ export class SegmentsCard extends React.PureComponent<SegmentsCardProps, Segment
this.segmentQueryManager = new QueryManager({ this.segmentQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
if (capabilities === 'no-sql') { if (capabilities.hasSql()) {
const segments = await queryDruidSql({
query: `SELECT
COUNT(*) as "count",
COUNT(*) FILTER (WHERE is_available = 0) as "unavailable"
FROM sys.segments`,
});
return segments.length === 1 ? segments[0] : null;
} else if (capabilities.hasCoordinatorAccess()) {
const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple'); const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple');
const loadstatus = loadstatusResp.data; const loadstatus = loadstatusResp.data;
const unavailableSegmentNum = sum(Object.keys(loadstatus), key => loadstatus[key]); const unavailableSegmentNum = sum(Object.keys(loadstatus), key => loadstatus[key]);
@ -66,13 +74,7 @@ export class SegmentsCard extends React.PureComponent<SegmentsCardProps, Segment
unavailable: unavailableSegmentNum, unavailable: unavailableSegmentNum,
}; };
} else { } else {
const segments = await queryDruidSql({ throw new Error(`must have SQL or coordinator access`);
query: `SELECT
COUNT(*) as "count",
COUNT(*) FILTER (WHERE is_available = 0) as "unavailable"
FROM sys.segments`,
});
return segments.length === 1 ? segments[0] : null;
} }
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {

View File

@ -1,9 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`servers card matches snapshot 1`] = ` exports[`services card matches snapshot 1`] = `
<a <a
class="home-view-card servers-card" class="home-view-card services-card"
href="#servers" href="#services"
> >
<div <div
class="bp3-card bp3-interactive bp3-elevation-0" class="bp3-card bp3-interactive bp3-elevation-0"
@ -32,7 +32,7 @@ exports[`servers card matches snapshot 1`] = `
</svg> </svg>
</span> </span>
   
Servers Services
</h5> </h5>
<p> <p>
Loading... Loading...

View File

@ -19,13 +19,15 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { ServersCard } from './servers-card'; import { Capabilities } from '../../../utils/capabilities';
describe('servers card', () => { import { ServicesCard } from './services-card';
describe('services card', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const serversCard = <ServersCard capabilities="full" />; const servicesCard = <ServicesCard capabilities={Capabilities.FULL} />;
const { container } = render(serversCard); const { container } = render(servicesCard);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
}); });
}); });

View File

@ -24,12 +24,12 @@ import { compact, lookupBy, pluralIfNeeded, queryDruidSql, QueryManager } from '
import { Capabilities } from '../../../utils/capabilities'; import { Capabilities } from '../../../utils/capabilities';
import { HomeViewCard } from '../home-view-card/home-view-card'; import { HomeViewCard } from '../home-view-card/home-view-card';
export interface ServersCardProps { export interface ServicesCardProps {
capabilities: Capabilities; capabilities: Capabilities;
} }
export interface ServersCardState { export interface ServicesCardState {
serverCountLoading: boolean; serviceCountLoading: boolean;
coordinatorCount: number; coordinatorCount: number;
overlordCount: number; overlordCount: number;
routerCount: number; routerCount: number;
@ -38,10 +38,10 @@ export interface ServersCardState {
middleManagerCount: number; middleManagerCount: number;
peonCount: number; peonCount: number;
indexerCount: number; indexerCount: number;
serverCountError?: string; serviceCountError?: string;
} }
export class ServersCard extends React.PureComponent<ServersCardProps, ServersCardState> { export class ServicesCard extends React.PureComponent<ServicesCardProps, ServicesCardState> {
static renderPluralIfNeededPair( static renderPluralIfNeededPair(
count1: number, count1: number,
singular1: string, singular1: string,
@ -56,12 +56,12 @@ export class ServersCard extends React.PureComponent<ServersCardProps, ServersCa
return <p>{text}</p>; return <p>{text}</p>;
} }
private serverQueryManager: QueryManager<Capabilities, any>; private serviceQueryManager: QueryManager<Capabilities, any>;
constructor(props: ServersCardProps, context: any) { constructor(props: ServicesCardProps, context: any) {
super(props, context); super(props, context);
this.state = { this.state = {
serverCountLoading: false, serviceCountLoading: false,
coordinatorCount: 0, coordinatorCount: 0,
overlordCount: 0, overlordCount: 0,
routerCount: 0, routerCount: 0,
@ -72,29 +72,37 @@ export class ServersCard extends React.PureComponent<ServersCardProps, ServersCa
indexerCount: 0, indexerCount: 0,
}; };
this.serverQueryManager = new QueryManager({ this.serviceQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
if (capabilities === 'no-sql') { if (capabilities.hasSql()) {
const serversResp = await axios.get('/druid/coordinator/v1/servers?simple'); const serviceCountsFromQuery: {
const middleManagerResp = await axios.get('/druid/indexer/v1/workers'); service_type: string;
return {
historical: serversResp.data.filter((s: any) => s.type === 'historical').length,
middle_manager: middleManagerResp.data.length,
peon: serversResp.data.filter((s: any) => s.type === 'indexer-executor').length,
};
} else {
const serverCountsFromQuery: {
server_type: string;
count: number; count: number;
}[] = await queryDruidSql({ }[] = await queryDruidSql({
query: `SELECT server_type, COUNT(*) as "count" FROM sys.servers GROUP BY 1`, query: `SELECT server_type AS "service_type", COUNT(*) as "count" FROM sys.servers GROUP BY 1`,
}); });
return lookupBy(serverCountsFromQuery, x => x.server_type, x => x.count); return lookupBy(serviceCountsFromQuery, x => x.service_type, x => x.count);
} else if (capabilities.hasCoordinatorAccess() || capabilities.hasOverlordAccess()) {
const services = capabilities.hasCoordinatorAccess()
? (await axios.get('/druid/coordinator/v1/servers?simple')).data
: [];
const middleManager = capabilities.hasOverlordAccess()
? (await axios.get('/druid/indexer/v1/workers')).data
: [];
return {
historical: services.filter((s: any) => s.type === 'historical').length,
middle_manager: middleManager.length,
peon: services.filter((s: any) => s.type === 'indexer-executor').length,
};
} else {
throw new Error(`must have SQL or coordinator/overlord access`);
} }
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {
this.setState({ this.setState({
serverCountLoading: loading, serviceCountLoading: loading,
coordinatorCount: result ? result.coordinator : 0, coordinatorCount: result ? result.coordinator : 0,
overlordCount: result ? result.overlord : 0, overlordCount: result ? result.overlord : 0,
routerCount: result ? result.router : 0, routerCount: result ? result.router : 0,
@ -103,7 +111,7 @@ export class ServersCard extends React.PureComponent<ServersCardProps, ServersCa
middleManagerCount: result ? result.middle_manager : 0, middleManagerCount: result ? result.middle_manager : 0,
peonCount: result ? result.peon : 0, peonCount: result ? result.peon : 0,
indexerCount: result ? result.indexer : 0, indexerCount: result ? result.indexer : 0,
serverCountError: error, serviceCountError: error,
}); });
}, },
}); });
@ -112,16 +120,16 @@ export class ServersCard extends React.PureComponent<ServersCardProps, ServersCa
componentDidMount(): void { componentDidMount(): void {
const { capabilities } = this.props; const { capabilities } = this.props;
this.serverQueryManager.runQuery(capabilities); this.serviceQueryManager.runQuery(capabilities);
} }
componentWillUnmount(): void { componentWillUnmount(): void {
this.serverQueryManager.terminate(); this.serviceQueryManager.terminate();
} }
render(): JSX.Element { render(): JSX.Element {
const { const {
serverCountLoading, serviceCountLoading,
coordinatorCount, coordinatorCount,
overlordCount, overlordCount,
routerCount, routerCount,
@ -130,31 +138,31 @@ export class ServersCard extends React.PureComponent<ServersCardProps, ServersCa
middleManagerCount, middleManagerCount,
peonCount, peonCount,
indexerCount, indexerCount,
serverCountError, serviceCountError,
} = this.state; } = this.state;
return ( return (
<HomeViewCard <HomeViewCard
className="servers-card" className="services-card"
href={'#servers'} href={'#services'}
icon={IconNames.DATABASE} icon={IconNames.DATABASE}
title={'Servers'} title={'Services'}
loading={serverCountLoading} loading={serviceCountLoading}
error={serverCountError} error={serviceCountError}
> >
{ServersCard.renderPluralIfNeededPair( {ServicesCard.renderPluralIfNeededPair(
overlordCount, overlordCount,
'overlord', 'overlord',
coordinatorCount, coordinatorCount,
'coordinator', 'coordinator',
)} )}
{ServersCard.renderPluralIfNeededPair(routerCount, 'router', brokerCount, 'broker')} {ServicesCard.renderPluralIfNeededPair(routerCount, 'router', brokerCount, 'broker')}
{ServersCard.renderPluralIfNeededPair( {ServicesCard.renderPluralIfNeededPair(
historicalCount, historicalCount,
'historical', 'historical',
middleManagerCount, middleManagerCount,
'middle manager', 'middle manager',
)} )}
{ServersCard.renderPluralIfNeededPair(peonCount, 'peon', indexerCount, 'indexer')} {ServicesCard.renderPluralIfNeededPair(peonCount, 'peon', indexerCount, 'indexer')}
</HomeViewCard> </HomeViewCard>
); );
} }

View File

@ -19,11 +19,13 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../../utils/capabilities';
import { SupervisorsCard } from './supervisors-card'; import { SupervisorsCard } from './supervisors-card';
describe('supervisors card', () => { describe('supervisors card', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const supervisorsCard = <SupervisorsCard capabilities="full" />; const supervisorsCard = <SupervisorsCard capabilities={Capabilities.FULL} />;
const { container } = render(supervisorsCard); const { container } = render(supervisorsCard);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();

View File

@ -51,14 +51,14 @@ export class SupervisorsCard extends React.PureComponent<
this.supervisorQueryManager = new QueryManager({ this.supervisorQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
if (capabilities !== 'no-sql') { if (capabilities.hasSql()) {
return (await queryDruidSql({ return (await queryDruidSql({
query: `SELECT query: `SELECT
COUNT(*) FILTER (WHERE "suspended" = 0) AS "runningSupervisorCount", COUNT(*) FILTER (WHERE "suspended" = 0) AS "runningSupervisorCount",
COUNT(*) FILTER (WHERE "suspended" = 1) AS "suspendedSupervisorCount" COUNT(*) FILTER (WHERE "suspended" = 1) AS "suspendedSupervisorCount"
FROM sys.supervisors`, FROM sys.supervisors`,
}))[0]; }))[0];
} else { } else if (capabilities.hasOverlordAccess()) {
const resp = await axios.get('/druid/indexer/v1/supervisor?full'); const resp = await axios.get('/druid/indexer/v1/supervisor?full');
const data = resp.data; const data = resp.data;
const runningSupervisorCount = data.filter((d: any) => d.spec.suspended === false).length; const runningSupervisorCount = data.filter((d: any) => d.spec.suspended === false).length;
@ -68,6 +68,8 @@ FROM sys.supervisors`,
runningSupervisorCount, runningSupervisorCount,
suspendedSupervisorCount, suspendedSupervisorCount,
}; };
} else {
throw new Error(`must have SQL or overlord access`);
} }
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {

View File

@ -19,11 +19,13 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../../utils/capabilities';
import { TasksCard } from './tasks-card'; import { TasksCard } from './tasks-card';
describe('tasks card', () => { describe('tasks card', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const tasksCard = <TasksCard capabilities="full" />; const tasksCard = <TasksCard capabilities={Capabilities.FULL} />;
const { container } = render(tasksCard); const { container } = render(tasksCard);
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();

View File

@ -54,7 +54,16 @@ export class TasksCard extends React.PureComponent<TasksCardProps, TasksCardStat
this.taskQueryManager = new QueryManager({ this.taskQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
if (capabilities === 'no-sql') { if (capabilities.hasSql()) {
const taskCountsFromQuery: { status: string; count: number }[] = await queryDruidSql({
query: `SELECT
CASE WHEN "status" = 'RUNNING' THEN "runner_status" ELSE "status" END AS "status",
COUNT (*) AS "count"
FROM sys.tasks
GROUP BY 1`,
});
return lookupBy(taskCountsFromQuery, x => x.status, x => x.count);
} else if (capabilities.hasOverlordAccess()) {
const completeTasksResp = await axios.get('/druid/indexer/v1/completeTasks'); const completeTasksResp = await axios.get('/druid/indexer/v1/completeTasks');
const runningTasksResp = await axios.get('/druid/indexer/v1/runningTasks'); const runningTasksResp = await axios.get('/druid/indexer/v1/runningTasks');
const pendingTasksResp = await axios.get('/druid/indexer/v1/pendingTasks'); const pendingTasksResp = await axios.get('/druid/indexer/v1/pendingTasks');
@ -67,14 +76,7 @@ export class TasksCard extends React.PureComponent<TasksCardProps, TasksCardStat
WAITING: waitingTasksResp.data.length, WAITING: waitingTasksResp.data.length,
}; };
} else { } else {
const taskCountsFromQuery: { status: string; count: number }[] = await queryDruidSql({ throw new Error(`must have SQL or overlord access`);
query: `SELECT
CASE WHEN "status" = 'RUNNING' THEN "runner_status" ELSE "status" END AS "status",
COUNT (*) AS "count"
FROM sys.tasks
GROUP BY 1`,
});
return lookupBy(taskCountsFromQuery, x => x.status, x => x.count);
} }
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {

View File

@ -21,6 +21,6 @@ export * from './home-view/home-view';
export * from './load-data-view/load-data-view'; export * from './load-data-view/load-data-view';
export * from './lookups-view/lookups-view'; export * from './lookups-view/lookups-view';
export * from './segments-view/segments-view'; export * from './segments-view/segments-view';
export * from './servers-view/servers-view'; export * from './services-view/services-view';
export * from './query-view/query-view'; export * from './query-view/query-view';
export * from './task-view/tasks-view'; export * from './task-view/tasks-view';

View File

@ -326,7 +326,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
let spec = parseJson(String(localStorageGet(LocalStorageKeys.INGESTION_SPEC))); let spec = parseJson(String(localStorageGet(LocalStorageKeys.INGESTION_SPEC)));
if (!spec || typeof spec !== 'object') spec = {}; if (!spec || typeof spec !== 'object') spec = {};
this.state = { this.state = {
step: 'welcome', step: 'loading',
spec, spec,
specPreview: spec, specPreview: spec,
@ -534,6 +534,8 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
return ( return (
<div className={classNames('load-data-view', 'app-view', step)}> <div className={classNames('load-data-view', 'app-view', step)}>
{this.renderStepNav()} {this.renderStepNav()}
{step === 'loading' && <Loader loading />}
{step === 'welcome' && this.renderWelcomeStep()} {step === 'welcome' && this.renderWelcomeStep()}
{step === 'connect' && this.renderConnectStep()} {step === 'connect' && this.renderConnectStep()}
{step === 'parser' && this.renderParserStep()} {step === 'parser' && this.renderParserStep()}
@ -548,7 +550,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
{step === 'publish' && this.renderPublishStep()} {step === 'publish' && this.renderPublishStep()}
{step === 'spec' && this.renderSpecStep()} {step === 'spec' && this.renderSpecStep()}
{step === 'loading' && this.renderLoading()}
{this.renderResetConfirm()} {this.renderResetConfirm()}
</div> </div>
@ -1071,7 +1072,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
{deepGet(spec, 'ioConfig.firehose.type') === 'local' && ( {deepGet(spec, 'ioConfig.firehose.type') === 'local' && (
<FormGroup> <FormGroup>
<Callout intent={Intent.WARNING}> <Callout intent={Intent.WARNING}>
This path must be available on the local filesystem of all Druid servers. This path must be available on the local filesystem of all Druid services.
</Callout> </Callout>
</FormGroup> </FormGroup>
)} )}
@ -3000,10 +3001,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
} }
}; };
renderLoading() {
return <Loader loading />;
}
renderSpecStep() { renderSpecStep() {
const { spec, submitting } = this.state; const { spec, submitting } = this.state;

View File

@ -19,6 +19,7 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../utils/capabilities';
import { SegmentsView } from '../segments-view/segments-view'; import { SegmentsView } from '../segments-view/segments-view';
describe('segments-view', () => { describe('segments-view', () => {
@ -28,7 +29,7 @@ describe('segments-view', () => {
datasource={'test'} datasource={'test'}
onlyUnavailable={false} onlyUnavailable={false}
goToQuery={() => {}} goToQuery={() => {}}
capabilities="full" capabilities={Capabilities.FULL}
/>, />,
); );
expect(segmentsView).toMatchSnapshot(); expect(segmentsView).toMatchSnapshot();

View File

@ -56,12 +56,12 @@ import {
sqlQueryCustomTableFilter, sqlQueryCustomTableFilter,
} from '../../utils'; } from '../../utils';
import { BasicAction } from '../../utils/basic-action'; import { BasicAction } from '../../utils/basic-action';
import { Capabilities } from '../../utils/capabilities'; import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array'; import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
import './segments-view.scss'; import './segments-view.scss';
const tableColumns: Record<Capabilities, string[]> = { const tableColumns: Record<CapabilitiesMode, string[]> = {
full: [ full: [
'Segment ID', 'Segment ID',
'Datasource', 'Datasource',
@ -103,7 +103,6 @@ const tableColumns: Record<Capabilities, string[]> = {
'Is available', 'Is available',
'Is overshadowed', 'Is overshadowed',
], ],
broken: ['Segment ID'],
}; };
export interface SegmentsViewProps { export interface SegmentsViewProps {
@ -339,7 +338,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
componentDidMount(): void { componentDidMount(): void {
const { capabilities } = this.props; const { capabilities } = this.props;
if (capabilities === 'no-sql') { if (!capabilities.hasSql() && capabilities.hasCoordinatorAccess()) {
this.segmentsNoSqlQueryManager.runQuery(null); this.segmentsNoSqlQueryManager.runQuery(null);
} }
} }
@ -426,21 +425,21 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
onFilteredChange={filtered => { onFilteredChange={filtered => {
this.setState({ segmentFilter: filtered }); this.setState({ segmentFilter: filtered });
}} }}
onFetchData={ onFetchData={state => {
capabilities === 'no-sql' if (capabilities.hasSql()) {
? this.fetchClientSideData this.setState({
: state => { page: state.page,
this.setState({ pageSize: state.pageSize,
page: state.page, filtered: state.filtered,
pageSize: state.pageSize, sorted: state.sorted,
filtered: state.filtered, });
sorted: state.sorted, if (this.segmentsSqlQueryManager.getLastQuery) {
}); this.fetchData(groupByInterval, state);
if (this.segmentsSqlQueryManager.getLastQuery) { }
this.fetchData(groupByInterval, state); } else if (capabilities.hasCoordinatorAccess()) {
} this.fetchClientSideData(state);
} }
} }}
showPageJump={false} showPageJump={false}
ofText="" ofText=""
pivotBy={groupByInterval ? ['interval'] : []} pivotBy={groupByInterval ? ['interval'] : []}
@ -556,7 +555,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
filterable: false, filterable: false,
defaultSortDesc: true, defaultSortDesc: true,
Cell: row => (row.original.is_available ? formatNumber(row.value) : <em>(unknown)</em>), Cell: row => (row.original.is_available ? formatNumber(row.value) : <em>(unknown)</em>),
show: capabilities !== 'no-sql' && hiddenColumns.exists('Num rows'), show: capabilities.hasSql() && hiddenColumns.exists('Num rows'),
}, },
{ {
Header: 'Replicas', Header: 'Replicas',
@ -564,35 +563,35 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
width: 60, width: 60,
filterable: false, filterable: false,
defaultSortDesc: true, defaultSortDesc: true,
show: capabilities !== 'no-sql' && hiddenColumns.exists('Replicas'), show: capabilities.hasSql() && hiddenColumns.exists('Replicas'),
}, },
{ {
Header: 'Is published', Header: 'Is published',
id: 'is_published', id: 'is_published',
accessor: row => String(Boolean(row.is_published)), accessor: row => String(Boolean(row.is_published)),
Filter: makeBooleanFilter(), Filter: makeBooleanFilter(),
show: capabilities !== 'no-sql' && hiddenColumns.exists('Is published'), show: capabilities.hasSql() && hiddenColumns.exists('Is published'),
}, },
{ {
Header: 'Is realtime', Header: 'Is realtime',
id: 'is_realtime', id: 'is_realtime',
accessor: row => String(Boolean(row.is_realtime)), accessor: row => String(Boolean(row.is_realtime)),
Filter: makeBooleanFilter(), Filter: makeBooleanFilter(),
show: capabilities !== 'no-sql' && hiddenColumns.exists('Is realtime'), show: capabilities.hasSql() && hiddenColumns.exists('Is realtime'),
}, },
{ {
Header: 'Is available', Header: 'Is available',
id: 'is_available', id: 'is_available',
accessor: row => String(Boolean(row.is_available)), accessor: row => String(Boolean(row.is_available)),
Filter: makeBooleanFilter(), Filter: makeBooleanFilter(),
show: capabilities !== 'no-sql' && hiddenColumns.exists('Is available'), show: capabilities.hasSql() && hiddenColumns.exists('Is available'),
}, },
{ {
Header: 'Is overshadowed', Header: 'Is overshadowed',
id: 'is_overshadowed', id: 'is_overshadowed',
accessor: row => String(Boolean(row.is_overshadowed)), accessor: row => String(Boolean(row.is_overshadowed)),
Filter: makeBooleanFilter(), Filter: makeBooleanFilter(),
show: capabilities !== 'no-sql' && hiddenColumns.exists('Is overshadowed'), show: capabilities.hasSql() && hiddenColumns.exists('Is overshadowed'),
}, },
{ {
Header: ACTION_COLUMN_LABEL, Header: ACTION_COLUMN_LABEL,
@ -618,7 +617,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
); );
}, },
Aggregated: () => '', Aggregated: () => '',
show: capabilities !== 'no-proxy' && hiddenColumns.exists(ACTION_COLUMN_LABEL), show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists(ACTION_COLUMN_LABEL),
}, },
]} ]}
defaultPageSize={SegmentsView.PAGE_SIZE} defaultPageSize={SegmentsView.PAGE_SIZE}
@ -663,7 +662,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
const bulkSegmentsActionsMenu = ( const bulkSegmentsActionsMenu = (
<Menu> <Menu>
{capabilities !== 'no-sql' && ( {capabilities.hasSql() && (
<MenuItem <MenuItem
icon={IconNames.APPLICATION} icon={IconNames.APPLICATION}
text="View SQL query for table" text="View SQL query for table"
@ -714,7 +713,11 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
active={!groupByInterval} active={!groupByInterval}
onClick={() => { onClick={() => {
this.setState({ groupByInterval: false }); this.setState({ groupByInterval: false });
capabilities === 'no-sql' ? this.fetchClientSideData() : this.fetchData(false); if (capabilities.hasSql()) {
this.fetchData(false);
} else {
this.fetchClientSideData();
}
}} }}
> >
None None
@ -731,7 +734,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
</ButtonGroup> </ButtonGroup>
{this.renderBulkSegmentsActions()} {this.renderBulkSegmentsActions()}
<TableColumnSelector <TableColumnSelector
columns={tableColumns[capabilities]} columns={tableColumns[capabilities.getMode()]}
onChange={column => onChange={column =>
this.setState(prevState => ({ this.setState(prevState => ({
hiddenColumns: prevState.hiddenColumns.toggle(column), hiddenColumns: prevState.hiddenColumns.toggle(column),

View File

@ -1,11 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`servers view action servers view 1`] = ` exports[`services view action services view 1`] = `
<div <div
className="servers-view app-view" className="services-view app-view"
> >
<Memo(ViewControlBar) <Memo(ViewControlBar)
label="Servers" label="Services"
> >
<Component> <Component>
Group by Group by
@ -31,7 +31,7 @@ exports[`servers view action servers view 1`] = `
</Blueprint3.Button> </Blueprint3.Button>
</Blueprint3.ButtonGroup> </Blueprint3.ButtonGroup>
<Memo(RefreshButton) <Memo(RefreshButton)
localStorageKey="servers-refresh-rate" localStorageKey="services-refresh-rate"
onRefresh={[Function]} onRefresh={[Function]}
/> />
<Blueprint3.Popover <Blueprint3.Popover
@ -74,7 +74,7 @@ exports[`servers view action servers view 1`] = `
<Memo(TableColumnSelector) <Memo(TableColumnSelector)
columns={ columns={
Array [ Array [
"Server", "Service",
"Type", "Type",
"Tier", "Tier",
"Host", "Host",
@ -149,15 +149,15 @@ exports[`servers view action servers view 1`] = `
Array [ Array [
Object { Object {
"Aggregated": [Function], "Aggregated": [Function],
"Header": "Server", "Header": "Service",
"accessor": "server", "accessor": "service",
"show": true, "show": true,
"width": 300, "width": 300,
}, },
Object { Object {
"Cell": [Function], "Cell": [Function],
"Header": "Type", "Header": "Type",
"accessor": "server_type", "accessor": "service_type",
"show": true, "show": true,
"width": 150, "width": 150,
}, },

View File

@ -19,18 +19,20 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { ServersView } from './servers-view'; import { Capabilities } from '../../utils/capabilities';
describe('servers view', () => { import { ServicesView } from './services-view';
it('action servers view', () => {
const serversView = shallow( describe('services view', () => {
<ServersView it('action services view', () => {
const servicesView = shallow(
<ServicesView
middleManager={'test'} middleManager={'test'}
goToQuery={() => {}} goToQuery={() => {}}
goToTask={() => {}} goToTask={() => {}}
capabilities="full" capabilities={Capabilities.FULL}
/>, />,
); );
expect(serversView).toMatchSnapshot(); expect(servicesView).toMatchSnapshot();
}); });
}); });

View File

@ -53,14 +53,14 @@ import {
QueryManager, QueryManager,
} from '../../utils'; } from '../../utils';
import { BasicAction } from '../../utils/basic-action'; import { BasicAction } from '../../utils/basic-action';
import { Capabilities } from '../../utils/capabilities'; import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array'; import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
import { deepGet } from '../../utils/object-change'; import { deepGet } from '../../utils/object-change';
import './servers-view.scss'; import './services-view.scss';
const allColumns: string[] = [ const allColumns: string[] = [
'Server', 'Service',
'Type', 'Type',
'Tier', 'Tier',
'Host', 'Host',
@ -72,11 +72,10 @@ const allColumns: string[] = [
ACTION_COLUMN_LABEL, ACTION_COLUMN_LABEL,
]; ];
const tableColumns: Record<Capabilities, string[]> = { const tableColumns: Record<CapabilitiesMode, string[]> = {
full: allColumns, full: allColumns,
'no-sql': allColumns, 'no-sql': allColumns,
'no-proxy': ['Server', 'Type', 'Tier', 'Host', 'Port', 'Curr size', 'Max size', 'Usage'], 'no-proxy': ['Service', 'Type', 'Tier', 'Host', 'Port', 'Curr size', 'Max size', 'Usage'],
broken: ['Server'],
}; };
function formatQueues( function formatQueues(
@ -99,19 +98,19 @@ function formatQueues(
return queueParts.join(', ') || 'Empty load/drop queues'; return queueParts.join(', ') || 'Empty load/drop queues';
} }
export interface ServersViewProps { export interface ServicesViewProps {
middleManager: string | undefined; middleManager: string | undefined;
goToQuery: (initSql: string) => void; goToQuery: (initSql: string) => void;
goToTask: (taskId: string) => void; goToTask: (taskId: string) => void;
capabilities: Capabilities; capabilities: Capabilities;
} }
export interface ServersViewState { export interface ServicesViewState {
serversLoading: boolean; servicesLoading: boolean;
servers?: any[]; services?: any[];
serversError?: string; servicesError?: string;
serverFilter: Filter[]; serviceFilter: Filter[];
groupServersBy?: 'server_type' | 'tier'; groupServicesBy?: 'service_type' | 'tier';
middleManagerDisableWorkerHost?: string; middleManagerDisableWorkerHost?: string;
middleManagerEnableWorkerHost?: string; middleManagerEnableWorkerHost?: string;
@ -119,9 +118,9 @@ export interface ServersViewState {
hiddenColumns: LocalStorageBackedArray<string>; hiddenColumns: LocalStorageBackedArray<string>;
} }
interface ServerQueryResultRow { interface ServiceQueryResultRow {
server: string; service: string;
server_type: string; service_type: string;
tier: string; tier: string;
curr_size: number; curr_size: number;
host: string; host: string;
@ -152,13 +151,13 @@ interface MiddleManagerQueryResultRow {
}; };
} }
interface ServerResultRow interface ServiceResultRow
extends ServerQueryResultRow, extends ServiceQueryResultRow,
Partial<LoadQueueStatus>, Partial<LoadQueueStatus>,
Partial<MiddleManagerQueryResultRow> {} Partial<MiddleManagerQueryResultRow> {}
export class ServersView extends React.PureComponent<ServersViewProps, ServersViewState> { export class ServicesView extends React.PureComponent<ServicesViewProps, ServicesViewState> {
private serverQueryManager: QueryManager<Capabilities, ServerResultRow[]>; private serviceQueryManager: QueryManager<Capabilities, ServiceResultRow[]>;
// Ranking // Ranking
// coordinator => 7 // coordinator => 7
@ -169,8 +168,8 @@ export class ServersView extends React.PureComponent<ServersViewProps, ServersVi
// middle_manager => 2 // middle_manager => 2
// peon => 1 // peon => 1
static SERVER_SQL = `SELECT static SERVICE_SQL = `SELECT
"server", "server_type", "tier", "host", "plaintext_port", "tls_port", "curr_size", "max_size", "server" AS "service", "server_type" AS "service_type", "tier", "host", "plaintext_port", "tls_port", "curr_size", "max_size",
( (
CASE "server_type" CASE "server_type"
WHEN 'coordinator' THEN 7 WHEN 'coordinator' THEN 7
@ -184,15 +183,15 @@ export class ServersView extends React.PureComponent<ServersViewProps, ServersVi
END END
) AS "rank" ) AS "rank"
FROM sys.servers FROM sys.servers
ORDER BY "rank" DESC, "server" DESC`; ORDER BY "rank" DESC, "service" DESC`;
static async getServers(): Promise<ServerQueryResultRow[]> { static async getServices(): Promise<ServiceQueryResultRow[]> {
const allServerResp = await axios.get('/druid/coordinator/v1/servers?simple'); const allServiceResp = await axios.get('/druid/coordinator/v1/servers?simple');
const allServers = allServerResp.data; const allServices = allServiceResp.data;
return allServers.map((s: any) => { return allServices.map((s: any) => {
return { return {
server: s.host, service: s.host,
server_type: s.type === 'indexer-executor' ? 'peon' : s.type, service_type: s.type === 'indexer-executor' ? 'peon' : s.type,
tier: s.tier, tier: s.tier,
host: s.host.split(':')[0], host: s.host.split(':')[0],
plaintext_port: parseInt(s.host.split(':')[1], 10), plaintext_port: parseInt(s.host.split(':')[1], 10),
@ -203,73 +202,77 @@ ORDER BY "rank" DESC, "server" DESC`;
}); });
} }
constructor(props: ServersViewProps, context: any) { constructor(props: ServicesViewProps, context: any) {
super(props, context); super(props, context);
this.state = { this.state = {
serversLoading: true, servicesLoading: true,
serverFilter: [], serviceFilter: [],
hiddenColumns: new LocalStorageBackedArray<string>( hiddenColumns: new LocalStorageBackedArray<string>(
LocalStorageKeys.SERVER_TABLE_COLUMN_SELECTION, LocalStorageKeys.SERVICE_TABLE_COLUMN_SELECTION,
), ),
}; };
this.serverQueryManager = new QueryManager({ this.serviceQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
let servers: ServerQueryResultRow[]; let services: ServiceQueryResultRow[];
if (capabilities !== 'no-sql') { if (capabilities.hasSql()) {
servers = await queryDruidSql({ query: ServersView.SERVER_SQL }); services = await queryDruidSql({ query: ServicesView.SERVICE_SQL });
} else if (capabilities.hasCoordinatorAccess()) {
services = await ServicesView.getServices();
} else { } else {
servers = await ServersView.getServers(); throw new Error(`must have SQL or coordinator access`);
} }
if (capabilities === 'no-proxy') { if (capabilities.hasCoordinatorAccess()) {
return servers; const loadQueueResponse = await axios.get('/druid/coordinator/v1/loadqueue?simple');
const loadQueues: Record<string, LoadQueueStatus> = loadQueueResponse.data;
services = services.map(s => {
const loadQueueInfo = loadQueues[s.service];
if (loadQueueInfo) {
s = Object.assign(s, loadQueueInfo);
}
return s;
});
} }
const loadQueueResponse = await axios.get('/druid/coordinator/v1/loadqueue?simple'); if (capabilities.hasOverlordAccess()) {
const loadQueues: Record<string, LoadQueueStatus> = loadQueueResponse.data; let middleManagers: MiddleManagerQueryResultRow[];
servers = servers.map((s: any) => { try {
const loadQueueInfo = loadQueues[s.server]; const middleManagerResponse = await axios.get('/druid/indexer/v1/workers');
if (loadQueueInfo) { middleManagers = middleManagerResponse.data;
s = Object.assign(s, loadQueueInfo); } catch (e) {
if (
e.response &&
typeof e.response.data === 'object' &&
e.response.data.error === 'Task Runner does not support worker listing'
) {
// Swallow this error because it simply a reflection of a local task runner.
middleManagers = [];
} else {
// Otherwise re-throw.
throw e;
}
} }
return s;
});
let middleManagers: MiddleManagerQueryResultRow[]; const middleManagersLookup = lookupBy(middleManagers, m => m.worker.host);
try {
const middleManagerResponse = await axios.get('/druid/indexer/v1/workers'); services = services.map(s => {
middleManagers = middleManagerResponse.data; const middleManagerInfo = middleManagersLookup[s.service];
} catch (e) { if (middleManagerInfo) {
if ( s = Object.assign(s, middleManagerInfo);
e.response && }
typeof e.response.data === 'object' && return s;
e.response.data.error === 'Task Runner does not support worker listing' });
) {
// Swallow this error because it simply a reflection of a local task runner.
middleManagers = [];
} else {
// Otherwise re-throw.
throw e;
}
} }
const middleManagersLookup = lookupBy(middleManagers, m => m.worker.host); return services;
return servers.map((s: any) => {
const middleManagerInfo = middleManagersLookup[s.server];
if (middleManagerInfo) {
s = Object.assign(s, middleManagerInfo);
}
return s;
});
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {
this.setState({ this.setState({
servers: result, services: result,
serversLoading: loading, servicesLoading: loading,
serversError: error, servicesError: error,
}); });
}, },
}); });
@ -277,21 +280,21 @@ ORDER BY "rank" DESC, "server" DESC`;
componentDidMount(): void { componentDidMount(): void {
const { capabilities } = this.props; const { capabilities } = this.props;
this.serverQueryManager.runQuery(capabilities); this.serviceQueryManager.runQuery(capabilities);
} }
componentWillUnmount(): void { componentWillUnmount(): void {
this.serverQueryManager.terminate(); this.serviceQueryManager.terminate();
} }
renderServersTable() { renderServicesTable() {
const { capabilities } = this.props; const { capabilities } = this.props;
const { const {
servers, services,
serversLoading, servicesLoading,
serversError, servicesError,
serverFilter, serviceFilter,
groupServersBy, groupServicesBy,
hiddenColumns, hiddenColumns,
} = this.state; } = this.state;
@ -308,36 +311,38 @@ ORDER BY "rank" DESC, "server" DESC`;
return ( return (
<ReactTable <ReactTable
data={servers || []} data={services || []}
loading={serversLoading} loading={servicesLoading}
noDataText={ noDataText={
!serversLoading && servers && !servers.length ? 'No historicals' : serversError || '' !servicesLoading && services && !services.length ? 'No historicals' : servicesError || ''
} }
filterable filterable
filtered={serverFilter} filtered={serviceFilter}
onFilteredChange={filtered => { onFilteredChange={filtered => {
this.setState({ serverFilter: filtered }); this.setState({ serviceFilter: filtered });
}} }}
pivotBy={groupServersBy ? [groupServersBy] : []} pivotBy={groupServicesBy ? [groupServicesBy] : []}
defaultPageSize={50} defaultPageSize={50}
columns={[ columns={[
{ {
Header: 'Server', Header: 'Service',
accessor: 'server', accessor: 'service',
width: 300, width: 300,
Aggregated: () => '', Aggregated: () => '',
show: hiddenColumns.exists('Server'), show: hiddenColumns.exists('Service'),
}, },
{ {
Header: 'Type', Header: 'Type',
accessor: 'server_type', accessor: 'service_type',
width: 150, width: 150,
Cell: row => { Cell: row => {
const value = row.value; const value = row.value;
return ( return (
<a <a
onClick={() => { onClick={() => {
this.setState({ serverFilter: addFilter(serverFilter, 'server_type', value) }); this.setState({
serviceFilter: addFilter(serviceFilter, 'service_type', value),
});
}} }}
> >
{value} {value}
@ -354,7 +359,7 @@ ORDER BY "rank" DESC, "server" DESC`;
return ( return (
<a <a
onClick={() => { onClick={() => {
this.setState({ serverFilter: addFilter(serverFilter, 'tier', value) }); this.setState({ serviceFilter: addFilter(serviceFilter, 'tier', value) });
}} }}
> >
{value} {value}
@ -398,7 +403,7 @@ ORDER BY "rank" DESC, "server" DESC`;
return formatBytes(totalCurr); return formatBytes(totalCurr);
}, },
Cell: row => { Cell: row => {
if (row.aggregated || row.original.server_type !== 'historical') return ''; if (row.aggregated || row.original.service_type !== 'historical') return '';
if (row.value === null) return ''; if (row.value === null) return '';
return formatBytes(row.value); return formatBytes(row.value);
}, },
@ -417,7 +422,7 @@ ORDER BY "rank" DESC, "server" DESC`;
return formatBytes(totalMax); return formatBytes(totalMax);
}, },
Cell: row => { Cell: row => {
if (row.aggregated || row.original.server_type !== 'historical') return ''; if (row.aggregated || row.original.service_type !== 'historical') return '';
if (row.value === null) return ''; if (row.value === null) return '';
return formatBytes(row.value); return formatBytes(row.value);
}, },
@ -429,7 +434,7 @@ ORDER BY "rank" DESC, "server" DESC`;
width: 100, width: 100,
filterable: false, filterable: false,
accessor: row => { accessor: row => {
if (row.server_type === 'middle_manager') { if (row.service_type === 'middle_manager') {
return row.worker ? row.currCapacityUsed / row.worker.capacity : null; return row.worker ? row.currCapacityUsed / row.worker.capacity : null;
} else { } else {
return row.max_size ? row.curr_size / row.max_size : null; return row.max_size ? row.curr_size / row.max_size : null;
@ -461,8 +466,8 @@ ORDER BY "rank" DESC, "server" DESC`;
}, },
Cell: row => { Cell: row => {
if (row.aggregated) return ''; if (row.aggregated) return '';
const { server_type } = row.original; const { service_type } = row.original;
switch (server_type) { switch (service_type) {
case 'historical': case 'historical':
return fillIndicator(row.value); return fillIndicator(row.value);
@ -487,7 +492,7 @@ ORDER BY "rank" DESC, "server" DESC`;
width: 400, width: 400,
filterable: false, filterable: false,
accessor: row => { accessor: row => {
if (row.server_type === 'middle_manager') { if (row.service_type === 'middle_manager') {
if (deepGet(row, 'worker.version') === '') return 'Disabled'; if (deepGet(row, 'worker.version') === '') return 'Disabled';
const details: string[] = []; const details: string[] = [];
@ -504,8 +509,8 @@ ORDER BY "rank" DESC, "server" DESC`;
}, },
Cell: row => { Cell: row => {
if (row.aggregated) return ''; if (row.aggregated) return '';
const { server_type } = row.original; const { service_type } = row.original;
switch (server_type) { switch (service_type) {
case 'historical': case 'historical':
const { const {
segmentsToLoad, segmentsToLoad,
@ -541,7 +546,7 @@ ORDER BY "rank" DESC, "server" DESC`;
segmentsToDropSize, segmentsToDropSize,
); );
}, },
show: capabilities !== 'no-proxy' && hiddenColumns.exists('Detail'), show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Detail'),
}, },
{ {
Header: ACTION_COLUMN_LABEL, Header: ACTION_COLUMN_LABEL,
@ -555,7 +560,7 @@ ORDER BY "rank" DESC, "server" DESC`;
const workerActions = this.getWorkerActions(row.value.host, disabled); const workerActions = this.getWorkerActions(row.value.host, disabled);
return <ActionCell actions={workerActions} />; return <ActionCell actions={workerActions} />;
}, },
show: capabilities !== 'no-proxy' && hiddenColumns.exists(ACTION_COLUMN_LABEL), show: capabilities.hasOverlordAccess() && hiddenColumns.exists(ACTION_COLUMN_LABEL),
}, },
]} ]}
/> />
@ -603,7 +608,7 @@ ORDER BY "rank" DESC, "server" DESC`;
this.setState({ middleManagerDisableWorkerHost: undefined }); this.setState({ middleManagerDisableWorkerHost: undefined });
}} }}
onSuccess={() => { onSuccess={() => {
this.serverQueryManager.rerunLastQuery(); this.serviceQueryManager.rerunLastQuery();
}} }}
> >
<p>{`Are you sure you want to disable worker '${middleManagerDisableWorkerHost}'?`}</p> <p>{`Are you sure you want to disable worker '${middleManagerDisableWorkerHost}'?`}</p>
@ -632,7 +637,7 @@ ORDER BY "rank" DESC, "server" DESC`;
this.setState({ middleManagerEnableWorkerHost: undefined }); this.setState({ middleManagerEnableWorkerHost: undefined });
}} }}
onSuccess={() => { onSuccess={() => {
this.serverQueryManager.rerunLastQuery(); this.serviceQueryManager.rerunLastQuery();
}} }}
> >
<p>{`Are you sure you want to enable worker '${middleManagerEnableWorkerHost}'?`}</p> <p>{`Are you sure you want to enable worker '${middleManagerEnableWorkerHost}'?`}</p>
@ -640,16 +645,16 @@ ORDER BY "rank" DESC, "server" DESC`;
); );
} }
renderBulkServersActions() { renderBulkServicesActions() {
const { goToQuery, capabilities } = this.props; const { goToQuery, capabilities } = this.props;
const bulkserversActionsMenu = ( const bulkservicesActionsMenu = (
<Menu> <Menu>
{capabilities !== 'no-sql' && ( {capabilities.hasSql() && (
<MenuItem <MenuItem
icon={IconNames.APPLICATION} icon={IconNames.APPLICATION}
text="View SQL query for table" text="View SQL query for table"
onClick={() => goToQuery(ServersView.SERVER_SQL)} onClick={() => goToQuery(ServicesView.SERVICE_SQL)}
/> />
)} )}
</Menu> </Menu>
@ -657,7 +662,7 @@ ORDER BY "rank" DESC, "server" DESC`;
return ( return (
<> <>
<Popover content={bulkserversActionsMenu} position={Position.BOTTOM_LEFT}> <Popover content={bulkservicesActionsMenu} position={Position.BOTTOM_LEFT}>
<Button icon={IconNames.MORE} /> <Button icon={IconNames.MORE} />
</Popover> </Popover>
</> </>
@ -666,39 +671,39 @@ ORDER BY "rank" DESC, "server" DESC`;
render(): JSX.Element { render(): JSX.Element {
const { capabilities } = this.props; const { capabilities } = this.props;
const { groupServersBy, hiddenColumns } = this.state; const { groupServicesBy, hiddenColumns } = this.state;
return ( return (
<div className="servers-view app-view"> <div className="services-view app-view">
<ViewControlBar label="Servers"> <ViewControlBar label="Services">
<Label>Group by</Label> <Label>Group by</Label>
<ButtonGroup> <ButtonGroup>
<Button <Button
active={!groupServersBy} active={!groupServicesBy}
onClick={() => this.setState({ groupServersBy: undefined })} onClick={() => this.setState({ groupServicesBy: undefined })}
> >
None None
</Button> </Button>
<Button <Button
active={groupServersBy === 'server_type'} active={groupServicesBy === 'service_type'}
onClick={() => this.setState({ groupServersBy: 'server_type' })} onClick={() => this.setState({ groupServicesBy: 'service_type' })}
> >
Type Type
</Button> </Button>
<Button <Button
active={groupServersBy === 'tier'} active={groupServicesBy === 'tier'}
onClick={() => this.setState({ groupServersBy: 'tier' })} onClick={() => this.setState({ groupServicesBy: 'tier' })}
> >
Tier Tier
</Button> </Button>
</ButtonGroup> </ButtonGroup>
<RefreshButton <RefreshButton
onRefresh={auto => this.serverQueryManager.rerunLastQuery(auto)} onRefresh={auto => this.serviceQueryManager.rerunLastQuery(auto)}
localStorageKey={LocalStorageKeys.SERVERS_REFRESH_RATE} localStorageKey={LocalStorageKeys.SERVICES_REFRESH_RATE}
/> />
{this.renderBulkServersActions()} {this.renderBulkServicesActions()}
<TableColumnSelector <TableColumnSelector
columns={tableColumns[capabilities]} columns={tableColumns[capabilities.getMode()]}
onChange={column => onChange={column =>
this.setState(prevState => ({ this.setState(prevState => ({
hiddenColumns: prevState.hiddenColumns.toggle(column), hiddenColumns: prevState.hiddenColumns.toggle(column),
@ -707,7 +712,7 @@ ORDER BY "rank" DESC, "server" DESC`;
tableColumnsHidden={hiddenColumns.storedArray} tableColumnsHidden={hiddenColumns.storedArray}
/> />
</ViewControlBar> </ViewControlBar>
{this.renderServersTable()} {this.renderServicesTable()}
{this.renderDisableWorkerAction()} {this.renderDisableWorkerAction()}
{this.renderEnableWorkerAction()} {this.renderEnableWorkerAction()}
</div> </div>

View File

@ -19,6 +19,8 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { Capabilities } from '../../utils/capabilities';
import { TasksView } from './tasks-view'; import { TasksView } from './tasks-view';
describe('tasks view', () => { describe('tasks view', () => {
@ -32,7 +34,7 @@ describe('tasks view', () => {
goToQuery={() => {}} goToQuery={() => {}}
goToMiddleManager={() => {}} goToMiddleManager={() => {}}
goToLoadData={() => {}} goToLoadData={() => {}}
capabilities="full" capabilities={Capabilities.FULL}
/>, />,
); );
expect(taskView).toMatchSnapshot(); expect(taskView).toMatchSnapshot();

View File

@ -259,11 +259,11 @@ ORDER BY "rank" DESC, "created_time" DESC`;
this.supervisorQueryManager = new QueryManager({ this.supervisorQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
if (capabilities !== 'no-sql') { if (capabilities.hasSql()) {
return await queryDruidSql({ return await queryDruidSql({
query: TasksView.SUPERVISOR_SQL, query: TasksView.SUPERVISOR_SQL,
}); });
} else { } else if (capabilities.hasOverlordAccess()) {
const supervisors = (await axios.get('/druid/indexer/v1/supervisor?full')).data; const supervisors = (await axios.get('/druid/indexer/v1/supervisor?full')).data;
if (!Array.isArray(supervisors)) throw new Error(`Unexpected results`); if (!Array.isArray(supervisors)) throw new Error(`Unexpected results`);
return supervisors.map((sup: any) => { return supervisors.map((sup: any) => {
@ -279,6 +279,8 @@ ORDER BY "rank" DESC, "created_time" DESC`;
suspended: Number(deepGet(sup, 'suspended')), suspended: Number(deepGet(sup, 'suspended')),
}; };
}); });
} else {
throw new Error(`must have SQL or overlord access`);
} }
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {
@ -292,11 +294,11 @@ ORDER BY "rank" DESC, "created_time" DESC`;
this.taskQueryManager = new QueryManager({ this.taskQueryManager = new QueryManager({
processQuery: async capabilities => { processQuery: async capabilities => {
if (capabilities !== 'no-sql') { if (capabilities.hasSql()) {
return await queryDruidSql({ return await queryDruidSql({
query: TasksView.TASK_SQL, query: TasksView.TASK_SQL,
}); });
} else { } else if (capabilities.hasOverlordAccess()) {
const taskEndpoints: string[] = [ const taskEndpoints: string[] = [
'completeTasks', 'completeTasks',
'runningTasks', 'runningTasks',
@ -310,6 +312,8 @@ ORDER BY "rank" DESC, "created_time" DESC`;
}), }),
); );
return result.flat(); return result.flat();
} else {
throw new Error(`must have SQL or overlord access`);
} }
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {
@ -934,7 +938,7 @@ ORDER BY "rank" DESC, "created_time" DESC`;
const bulkSupervisorActionsMenu = ( const bulkSupervisorActionsMenu = (
<Menu> <Menu>
{capabilities !== 'no-sql' && ( {capabilities.hasSql() && (
<MenuItem <MenuItem
icon={IconNames.APPLICATION} icon={IconNames.APPLICATION}
text="View SQL query for table" text="View SQL query for table"
@ -1060,7 +1064,7 @@ ORDER BY "rank" DESC, "created_time" DESC`;
const bulkTaskActionsMenu = ( const bulkTaskActionsMenu = (
<Menu> <Menu>
{capabilities !== 'no-sql' && ( {capabilities.hasSql() && (
<MenuItem <MenuItem
icon={IconNames.APPLICATION} icon={IconNames.APPLICATION}
text="View SQL query for table" text="View SQL query for table"