blob: f0d97f356127eacf703b518b94f638878a4455b1 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, ButtonGroup, Intent, Label, MenuItem, Tag } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { max, sum } from 'd3-array';
import memoize from 'memoize-one';
import React, { createContext, useContext } from 'react';
import type { Column, Filter } from 'react-table';
import ReactTable from 'react-table';
import {
ACTION_COLUMN_ID,
ACTION_COLUMN_LABEL,
ACTION_COLUMN_WIDTH,
ActionCell,
MoreButton,
RefreshButton,
TableColumnSelector,
type TableColumnSelectorColumn,
TableFilterableCell,
ViewControlBar,
} from '../../components';
import { AsyncActionDialog } from '../../dialogs';
import type { QueryWithContext } from '../../druid-models';
import { getConsoleViewIcon } from '../../druid-models';
import type { Capabilities, CapabilitiesMode } from '../../helpers';
import {
booleanCustomTableFilter,
combineModeAndNeedle,
DEFAULT_TABLE_CLASS_NAME,
parseFilterModeAndNeedle,
STANDARD_TABLE_PAGE_SIZE,
STANDARD_TABLE_PAGE_SIZE_OPTIONS,
suggestibleFilterInput,
} from '../../react-table';
import { Api, AppToaster } from '../../singletons';
import type { AuxiliaryQueryFn, NumberLike } from '../../utils';
import {
assemble,
deepGet,
filterMap,
formatBytes,
formatBytesCompact,
formatDate,
formatDurationWithMsIfNeeded,
getApiArray,
hasOverlayOpen,
LocalStorageBackedVisibility,
LocalStorageKeys,
lookupBy,
oneOf,
pluralIfNeeded,
queryDruidSql,
QueryManager,
QueryState,
ResultWithAuxiliaryWork,
} from '../../utils';
import type { BasicAction } from '../../utils/basic-action';
import { FillIndicator } from './fill-indicator/fill-indicator';
import './services-view.scss';
const TABLE_COLUMNS_BY_MODE: Record<CapabilitiesMode, TableColumnSelectorColumn[]> = {
'full': [
'Service',
'Type',
'Tier',
'Host',
'Port',
'Current size',
'Max size',
'Usage',
'Start time',
'Version',
'Labels',
'Detail',
],
'no-sql': [
'Service',
'Type',
'Tier',
'Host',
'Port',
'Current size',
'Max size',
'Usage',
'Detail',
],
'no-proxy': [
'Service',
'Type',
'Tier',
'Host',
'Port',
'Current size',
'Max size',
'Usage',
'Start time',
'Version',
],
};
interface ServicesQuery {
capabilities: Capabilities;
visibleColumns: LocalStorageBackedVisibility;
}
export interface ServicesViewProps {
filters: Filter[];
onFiltersChange(filters: Filter[]): void;
goToQuery(queryWithContext: QueryWithContext): void;
capabilities: Capabilities;
}
export interface ServicesViewState {
servicesState: QueryState<ServicesWithAuxiliaryInfo>;
groupServicesBy?: 'service_type' | 'tier';
middleManagerDisableWorkerHost?: string;
middleManagerEnableWorkerHost?: string;
visibleColumns: LocalStorageBackedVisibility;
}
interface ServiceResultRow {
readonly service: string;
readonly service_type: string;
readonly tier: string;
readonly is_leader: number;
readonly host: string;
readonly curr_size: NumberLike;
readonly max_size: NumberLike;
readonly plaintext_port: number;
readonly tls_port: number;
readonly start_time: string;
readonly version: string;
readonly labels: string | null;
}
interface ServicesWithAuxiliaryInfo {
readonly services: ServiceResultRow[];
readonly loadQueueInfo: Record<string, LoadQueueInfo>;
readonly workerInfo: Record<string, WorkerInfo>;
}
export const LoadQueueInfoContext = createContext<Record<string, LoadQueueInfo>>({});
interface LoadQueueInfo {
readonly segmentsToDrop: NumberLike;
readonly segmentsToDropSize: NumberLike;
readonly segmentsToLoad: NumberLike;
readonly segmentsToLoadSize: NumberLike;
readonly expectedLoadTimeMillis: NumberLike;
}
function formatLoadQueueInfo({
segmentsToDrop,
segmentsToDropSize,
segmentsToLoad,
segmentsToLoadSize,
expectedLoadTimeMillis,
}: LoadQueueInfo): string {
return (
assemble(
segmentsToLoad
? `${pluralIfNeeded(segmentsToLoad, 'segment')} to load (${formatBytesCompact(
segmentsToLoadSize,
)}${
expectedLoadTimeMillis
? `, ${formatDurationWithMsIfNeeded(expectedLoadTimeMillis)}`
: ''
})`
: undefined,
segmentsToDrop
? `${pluralIfNeeded(segmentsToDrop, 'segment')} to drop (${formatBytesCompact(
segmentsToDropSize,
)})`
: undefined,
).join(', ') || 'Empty load/drop queues'
);
}
function aggregateLoadQueueInfos(loadQueueInfos: LoadQueueInfo[]): LoadQueueInfo {
return {
segmentsToLoad: sum(loadQueueInfos, s => Number(s.segmentsToLoad) || 0),
segmentsToLoadSize: sum(loadQueueInfos, s => Number(s.segmentsToLoadSize) || 0),
segmentsToDrop: sum(loadQueueInfos, s => Number(s.segmentsToDrop) || 0),
segmentsToDropSize: sum(loadQueueInfos, s => Number(s.segmentsToDropSize) || 0),
expectedLoadTimeMillis: max(loadQueueInfos, s => Number(s.expectedLoadTimeMillis) || 0) || 0,
};
}
function defaultDisplayFn(value: any): string {
if (value === undefined || value === null) return '';
return String(value);
}
interface WorkerInfo {
readonly availabilityGroups: string[];
readonly blacklistedUntil: string | null;
readonly currCapacityUsed: NumberLike;
readonly lastCompletedTaskTime: string;
readonly category: string;
readonly runningTasks: string[];
readonly worker: {
readonly capacity: NumberLike;
readonly host: string;
readonly ip: string;
readonly scheme: string;
readonly version: string;
readonly category: string;
};
}
export class ServicesView extends React.PureComponent<ServicesViewProps, ServicesViewState> {
private readonly serviceQueryManager: QueryManager<ServicesQuery, ServicesWithAuxiliaryInfo>;
// Ranking
// coordinator => 8
// overlord => 7
// router => 6
// broker => 5
// historical => 4
// indexer => 3
// middle_manager => 2
// peon => 1
static SERVICE_SQL = `SELECT
"server" AS "service",
"server_type" AS "service_type",
"tier",
"host",
"plaintext_port",
"tls_port",
"curr_size",
"max_size",
"is_leader",
"start_time",
"version",
"labels"
FROM sys.servers
ORDER BY
(
CASE "server_type"
WHEN 'coordinator' THEN 8
WHEN 'overlord' THEN 7
WHEN 'router' THEN 6
WHEN 'broker' THEN 5
WHEN 'historical' THEN 4
WHEN 'indexer' THEN 3
WHEN 'middle_manager' THEN 2
WHEN 'peon' THEN 1
ELSE 0
END
) DESC,
"service" DESC`;
constructor(props: ServicesViewProps) {
super(props);
this.state = {
servicesState: QueryState.INIT,
visibleColumns: new LocalStorageBackedVisibility(
LocalStorageKeys.SERVICE_TABLE_COLUMN_SELECTION,
),
};
this.serviceQueryManager = new QueryManager({
processQuery: async ({ capabilities, visibleColumns }, signal) => {
let services: ServiceResultRow[];
if (capabilities.hasSql()) {
services = await queryDruidSql({ query: ServicesView.SERVICE_SQL }, signal);
} else if (capabilities.hasCoordinatorAccess()) {
services = (await getApiArray('/druid/coordinator/v1/servers?simple', signal)).map(
(s: any): ServiceResultRow => {
const hostParts = s.host.split(':');
const port = parseInt(hostParts[1], 10);
return {
service: s.host,
service_type: s.type === 'indexer-executor' ? 'peon' : s.type,
tier: s.tier,
host: hostParts[0],
plaintext_port: port < 9000 ? port : -1,
tls_port: port < 9000 ? -1 : port,
curr_size: s.currSize,
max_size: s.maxSize,
start_time: '1970:01:01T00:00:00Z',
is_leader: 0,
version: '',
labels: null,
};
},
);
} else {
throw new Error(`must have SQL or coordinator access`);
}
const auxiliaryQueries: AuxiliaryQueryFn<ServicesWithAuxiliaryInfo>[] = [];
if (capabilities.hasCoordinatorAccess() && visibleColumns.shown('Detail')) {
auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => {
try {
const loadQueueInfos = (
await Api.instance.get<Record<string, LoadQueueInfo>>(
'/druid/coordinator/v1/loadqueue?simple',
{ signal },
)
).data;
return {
...servicesWithAuxiliaryInfo,
loadQueueInfo: loadQueueInfos,
};
} catch {
AppToaster.show({
icon: IconNames.ERROR,
intent: Intent.DANGER,
message: 'There was an error getting the load queue info',
});
return servicesWithAuxiliaryInfo;
}
});
}
if (capabilities.hasOverlordAccess()) {
auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => {
try {
const workerInfos = await getApiArray<WorkerInfo>(
'/druid/indexer/v1/workers',
signal,
);
const workerInfoLookup: Record<string, WorkerInfo> = lookupBy(
workerInfos,
m => m.worker?.host,
);
return {
...servicesWithAuxiliaryInfo,
workerInfo: workerInfoLookup,
};
} catch (e) {
// Swallow this error because it simply a reflection of a local task runner.
if (
deepGet(e, 'response.data.error') !== 'Task Runner does not support worker listing'
) {
AppToaster.show({
icon: IconNames.ERROR,
intent: Intent.DANGER,
message: 'There was an error getting the worker info',
});
}
return servicesWithAuxiliaryInfo;
}
});
}
return new ResultWithAuxiliaryWork<ServicesWithAuxiliaryInfo>(
{ services, loadQueueInfo: {}, workerInfo: {} },
auxiliaryQueries,
);
},
onStateChange: servicesState => {
this.setState({
servicesState,
});
},
});
}
componentDidMount(): void {
this.fetchData();
}
componentWillUnmount(): void {
this.serviceQueryManager.terminate();
}
private readonly fetchData = () => {
const { capabilities } = this.props;
const { visibleColumns } = this.state;
this.serviceQueryManager.runQuery({ capabilities, visibleColumns });
};
private renderFilterableCell(
field: string,
displayFn: (value: string) => string = defaultDisplayFn,
) {
const { filters, onFiltersChange } = this.props;
return function FilterableCell(row: { value: any }) {
return (
<TableFilterableCell
field={field}
value={row.value}
filters={filters}
onFiltersChange={onFiltersChange}
displayValue={displayFn(row.value)}
>
{displayFn(row.value)}
</TableFilterableCell>
);
};
}
renderServicesTable() {
const { filters, onFiltersChange } = this.props;
const { servicesState, groupServicesBy, visibleColumns } = this.state;
const { services, loadQueueInfo, workerInfo } = servicesState.data || {
services: [],
loadQueueInfo: {},
workerInfo: {},
};
return (
<LoadQueueInfoContext.Provider value={loadQueueInfo}>
<ReactTable
data={services}
loading={servicesState.loading}
noDataText={
servicesState.isEmpty() ? 'No services' : servicesState.getErrorMessage() || ''
}
filterable
filtered={filters}
className={`centered-table ${DEFAULT_TABLE_CLASS_NAME}`}
onFilteredChange={onFiltersChange}
pivotBy={groupServicesBy ? [groupServicesBy] : []}
defaultPageSize={STANDARD_TABLE_PAGE_SIZE}
pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS}
showPagination={services.length > STANDARD_TABLE_PAGE_SIZE}
columns={this.getTableColumns(visibleColumns, filters, onFiltersChange, workerInfo)}
/>
</LoadQueueInfoContext.Provider>
);
}
private readonly getTableColumns = memoize(
(
visibleColumns: LocalStorageBackedVisibility,
_filters: Filter[],
_onFiltersChange: (filters: Filter[]) => void,
workerInfoLookup: Record<string, WorkerInfo>,
): Column<ServiceResultRow>[] => {
const { capabilities } = this.props;
return [
{
Header: 'Service',
show: visibleColumns.shown('Service'),
accessor: 'service',
width: 300,
Cell: this.renderFilterableCell('service'),
Aggregated: () => '',
},
{
Header: 'Type',
show: visibleColumns.shown('Type'),
Filter: suggestibleFilterInput([
'coordinator',
'overlord',
'router',
'broker',
'historical',
'indexer',
'middle_manager',
'peon',
]),
accessor: 'service_type',
width: 150,
Cell: this.renderFilterableCell('service_type'),
},
{
Header: 'Tier',
show: visibleColumns.shown('Tier'),
id: 'tier',
width: 180,
accessor: row => {
if (row.tier) return row.tier;
return workerInfoLookup[row.service]?.worker?.category;
},
Cell: this.renderFilterableCell('tier'),
},
{
Header: 'Host',
show: visibleColumns.shown('Host'),
accessor: 'host',
width: 200,
Cell: this.renderFilterableCell('host'),
Aggregated: () => '',
},
{
Header: 'Port',
show: visibleColumns.shown('Port'),
id: 'port',
width: 100,
accessor: row => {
const ports: string[] = [];
if (row.plaintext_port !== -1) {
ports.push(`${row.plaintext_port} (plain)`);
}
if (row.tls_port !== -1) {
ports.push(`${row.tls_port} (TLS)`);
}
return ports.join(', ') || 'No port';
},
Cell: this.renderFilterableCell('port'),
Aggregated: () => '',
},
{
Header: 'Current size',
show: visibleColumns.shown('Current size'),
id: 'curr_size',
width: 100,
filterable: false,
accessor: 'curr_size',
className: 'padded',
Aggregated: ({ subRows }) => {
const originalRows = subRows.map(r => r._original);
if (!originalRows.some(r => r.service_type === 'historical')) return '';
const totalCurr = sum(originalRows, s => s.curr_size);
return formatBytes(totalCurr);
},
Cell: ({ value, aggregated, original }) => {
if (aggregated || original.service_type !== 'historical') return '';
if (value === null) return '';
return formatBytes(value);
},
},
{
Header: 'Max size',
show: visibleColumns.shown('Max size'),
id: 'max_size',
width: 100,
filterable: false,
accessor: 'max_size',
className: 'padded',
Aggregated: ({ subRows }) => {
const originalRows = subRows.map(r => r._original);
if (!originalRows.some(r => r.service_type === 'historical')) return '';
const totalMax = sum(originalRows, s => s.max_size);
return formatBytes(totalMax);
},
Cell: ({ value, aggregated, original }) => {
if (aggregated || original.service_type !== 'historical') return '';
if (value === null) return '';
return formatBytes(value);
},
},
{
Header: 'Usage',
show: visibleColumns.shown('Usage'),
id: 'usage',
width: 140,
filterable: false,
className: 'padded',
accessor: row => {
if (oneOf(row.service_type, 'middle_manager', 'indexer')) {
const workerInfo = workerInfoLookup[row.service];
if (!workerInfo) return 0;
return (
(Number(workerInfo.currCapacityUsed) || 0) / Number(workerInfo.worker?.capacity)
);
} else {
return row.max_size ? Number(row.curr_size) / Number(row.max_size) : null;
}
},
Aggregated: ({ subRows }) => {
const originalRows = subRows.map(r => r._original);
if (originalRows.some(r => r.service_type === 'historical')) {
const totalCurr = sum(originalRows, s => Number(s.curr_size));
const totalMax = sum(originalRows, s => Number(s.max_size));
return <FillIndicator value={totalCurr / totalMax} />;
} else if (
originalRows.some(
r => r.service_type === 'indexer' || r.service_type === 'middle_manager',
)
) {
const workerInfos: WorkerInfo[] = filterMap(
originalRows,
r => workerInfoLookup[r.service],
);
if (!workerInfos.length) return '';
const totalCurrCapacityUsed = sum(workerInfos, w => Number(w.currCapacityUsed) || 0);
const totalWorkerCapacity = sum(workerInfos, s => deepGet(s, 'worker.capacity') || 0);
return `Slots used: ${totalCurrCapacityUsed} of ${totalWorkerCapacity}`;
} else {
return '';
}
},
Cell: ({ value, aggregated, original }) => {
if (aggregated) return '';
const { service_type } = original;
switch (service_type) {
case 'historical':
return <FillIndicator value={value} />;
case 'indexer':
case 'middle_manager': {
const workerInfo = workerInfoLookup[original.service];
if (!workerInfo) return '';
const currCapacityUsed = workerInfo.currCapacityUsed || 0;
const capacity = deepGet(workerInfo, 'worker.capacity');
if (typeof capacity === 'number') {
return `Slots used: ${currCapacityUsed} of ${capacity}`;
} else {
return 'Slots used: -';
}
}
default:
return '';
}
},
},
{
Header: 'Start time',
show: visibleColumns.shown('Start time'),
accessor: 'start_time',
id: 'start_time',
width: 220,
Cell: this.renderFilterableCell('start_time', formatDate),
Aggregated: () => '',
filterMethod: (filter: Filter, row: ServiceResultRow) => {
const modeAndNeedle = parseFilterModeAndNeedle(filter);
if (!modeAndNeedle) return true;
const parsedRowTime = formatDate(row.start_time);
if (modeAndNeedle.mode === '~') {
return booleanCustomTableFilter(filter, parsedRowTime);
}
const parsedFilterTime = formatDate(modeAndNeedle.needle);
filter.value = combineModeAndNeedle(modeAndNeedle.mode, parsedFilterTime);
return booleanCustomTableFilter(filter, parsedRowTime);
},
},
{
Header: 'Version',
show: visibleColumns.shown('Version'),
accessor: 'version',
width: 200,
Cell: this.renderFilterableCell('version'),
Aggregated: () => '',
},
{
Header: 'Labels',
show: visibleColumns.shown('Labels'),
accessor: 'labels',
className: 'padded',
filterable: false,
width: 200,
Cell: ({ value }: { value: string | null }) => {
if (!value) return '';
return (
<ul className="labels-list">
{Object.entries(JSON.parse(value)).map(([key, val]) => {
return (
<li key={key}>
{key}: {String(val)}
</li>
);
})}
</ul>
);
},
Aggregated: () => '',
},
{
Header: 'Detail',
show: visibleColumns.shown('Detail'),
id: 'queue',
width: 400,
filterable: false,
className: 'padded',
accessor: 'service',
Cell: ({ original }) => {
const { service_type, service, is_leader } = original;
const loadQueueInfoContext = useContext(LoadQueueInfoContext);
switch (service_type) {
case 'middle_manager':
case 'indexer': {
const workerInfo = workerInfoLookup[service];
if (!workerInfo) return null;
if (workerInfo.worker.version === '') return 'Disabled';
const details: string[] = [];
if (workerInfo.lastCompletedTaskTime) {
details.push(
`Last completed task: ${formatDate(workerInfo.lastCompletedTaskTime)}`,
);
}
if (workerInfo.blacklistedUntil) {
details.push(`Blacklisted until: ${formatDate(workerInfo.blacklistedUntil)}`);
}
return details.join(' ') || null;
}
case 'coordinator':
case 'overlord':
return is_leader === 1 ? 'Leader' : '';
case 'historical': {
const loadQueueInfo = loadQueueInfoContext[service];
if (!loadQueueInfo) return null;
return formatLoadQueueInfo(loadQueueInfo);
}
default:
return null;
}
},
Aggregated: ({ subRows }) => {
const loadQueueInfoContext = useContext(LoadQueueInfoContext);
const originalRows = subRows.map(r => r._original);
if (!originalRows.some(r => r.service_type === 'historical')) return '';
const loadQueueInfos: LoadQueueInfo[] = filterMap(
originalRows,
r => loadQueueInfoContext[r.service],
);
return loadQueueInfos.length
? formatLoadQueueInfo(aggregateLoadQueueInfos(loadQueueInfos))
: '';
},
},
{
Header: ACTION_COLUMN_LABEL,
show: capabilities.hasOverlordAccess(),
id: ACTION_COLUMN_ID,
width: ACTION_COLUMN_WIDTH,
accessor: 'service',
filterable: false,
sortable: false,
Cell: ({ value, aggregated }) => {
if (aggregated) return '';
const workerInfo = workerInfoLookup[value];
if (!workerInfo) return null;
const { worker } = workerInfo;
const disabled = worker.version === '';
const workerActions = this.getWorkerActions(worker.host, disabled);
return <ActionCell actions={workerActions} menuTitle={worker.host} />;
},
Aggregated: () => '',
},
];
},
);
private getWorkerActions(workerHost: string, disabled: boolean): BasicAction[] {
if (disabled) {
return [
{
icon: IconNames.TICK,
title: 'Enable',
onAction: () => this.setState({ middleManagerEnableWorkerHost: workerHost }),
},
];
} else {
return [
{
icon: IconNames.DISABLE,
title: 'Disable',
onAction: () => this.setState({ middleManagerDisableWorkerHost: workerHost }),
},
];
}
}
renderDisableWorkerAction() {
const { middleManagerDisableWorkerHost } = this.state;
if (!middleManagerDisableWorkerHost) return;
return (
<AsyncActionDialog
action={async () => {
const resp = await Api.instance.post(
`/druid/indexer/v1/worker/${Api.encodePath(middleManagerDisableWorkerHost)}/disable`,
{},
);
return resp.data;
}}
confirmButtonText="Disable worker"
successText={
<>
Worker <Tag minimal>{middleManagerDisableWorkerHost}</Tag> has been disabled
</>
}
failText={
<>
Could not disable worker <Tag minimal>{middleManagerDisableWorkerHost}</Tag>
</>
}
intent={Intent.DANGER}
onClose={() => {
this.setState({ middleManagerDisableWorkerHost: undefined });
}}
onSuccess={() => {
this.serviceQueryManager.rerunLastQuery();
}}
>
<p>{`Are you sure you want to disable worker '${middleManagerDisableWorkerHost}'?`}</p>
</AsyncActionDialog>
);
}
renderEnableWorkerAction() {
const { middleManagerEnableWorkerHost } = this.state;
if (!middleManagerEnableWorkerHost) return;
return (
<AsyncActionDialog
action={async () => {
const resp = await Api.instance.post(
`/druid/indexer/v1/worker/${Api.encodePath(middleManagerEnableWorkerHost)}/enable`,
{},
);
return resp.data;
}}
confirmButtonText="Enable worker"
successText="Worker has been enabled"
failText="Could not enable worker"
intent={Intent.PRIMARY}
onClose={() => {
this.setState({ middleManagerEnableWorkerHost: undefined });
}}
onSuccess={() => {
this.serviceQueryManager.rerunLastQuery();
}}
>
<p>{`Are you sure you want to enable worker '${middleManagerEnableWorkerHost}'?`}</p>
</AsyncActionDialog>
);
}
renderBulkServicesActions() {
const { goToQuery, capabilities } = this.props;
return (
<MoreButton>
{capabilities.hasSql() && (
<MenuItem
icon={getConsoleViewIcon('workbench')}
text="View SQL query for table"
onClick={() => goToQuery({ queryString: ServicesView.SERVICE_SQL })}
/>
)}
</MoreButton>
);
}
render() {
const { capabilities } = this.props;
const { groupServicesBy, visibleColumns } = this.state;
return (
<div className="services-view app-view">
<ViewControlBar label="Services">
<Label>Group by</Label>
<ButtonGroup>
<Button
active={!groupServicesBy}
onClick={() => this.setState({ groupServicesBy: undefined })}
>
None
</Button>
<Button
active={groupServicesBy === 'service_type'}
onClick={() => this.setState({ groupServicesBy: 'service_type' })}
>
Type
</Button>
<Button
active={groupServicesBy === 'tier'}
onClick={() => this.setState({ groupServicesBy: 'tier' })}
>
Tier
</Button>
</ButtonGroup>
<RefreshButton
onRefresh={auto => {
if (auto && hasOverlayOpen()) return;
this.serviceQueryManager.rerunLastQuery(auto);
}}
localStorageKey={LocalStorageKeys.SERVICES_REFRESH_RATE}
/>
{this.renderBulkServicesActions()}
<TableColumnSelector
columns={TABLE_COLUMNS_BY_MODE[capabilities.getMode()]}
onChange={column =>
this.setState(prevState => ({
visibleColumns: prevState.visibleColumns.toggle(column),
}))
}
onClose={this.fetchData}
tableColumnsHidden={visibleColumns.getHiddenColumns()}
/>
</ViewControlBar>
{this.renderServicesTable()}
{this.renderDisableWorkerAction()}
{this.renderEnableWorkerAction()}
</div>
);
}
}