| /* |
| * 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 } from '@blueprintjs/core'; |
| import { IconNames } from '@blueprintjs/icons'; |
| import axios from 'axios'; |
| import { sum } from 'd3-array'; |
| import React from 'react'; |
| import ReactTable from 'react-table'; |
| import { Filter } from 'react-table'; |
| |
| import { |
| ACTION_COLUMN_ID, |
| ACTION_COLUMN_LABEL, |
| ACTION_COLUMN_WIDTH, |
| ActionCell, |
| MoreButton, |
| RefreshButton, |
| TableColumnSelector, |
| ViewControlBar, |
| } from '../../components'; |
| import { AsyncActionDialog } from '../../dialogs'; |
| import { |
| addFilter, |
| formatBytes, |
| formatBytesCompact, |
| LocalStorageKeys, |
| lookupBy, |
| queryDruidSql, |
| QueryManager, |
| QueryState, |
| } from '../../utils'; |
| import { BasicAction } from '../../utils/basic-action'; |
| import { Capabilities, CapabilitiesMode } from '../../utils/capabilities'; |
| import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array'; |
| import { deepGet } from '../../utils/object-change'; |
| |
| import './services-view.scss'; |
| |
| const allColumns: string[] = [ |
| 'Service', |
| 'Type', |
| 'Tier', |
| 'Host', |
| 'Port', |
| 'Curr size', |
| 'Max size', |
| 'Usage', |
| 'Detail', |
| ACTION_COLUMN_LABEL, |
| ]; |
| |
| const tableColumns: Record<CapabilitiesMode, string[]> = { |
| full: allColumns, |
| 'no-sql': allColumns, |
| 'no-proxy': ['Service', 'Type', 'Tier', 'Host', 'Port', 'Curr size', 'Max size', 'Usage'], |
| }; |
| |
| function formatQueues( |
| segmentsToLoad: number, |
| segmentsToLoadSize: number, |
| segmentsToDrop: number, |
| segmentsToDropSize: number, |
| ): string { |
| const queueParts: string[] = []; |
| if (segmentsToLoad) { |
| queueParts.push( |
| `${segmentsToLoad} segments to load (${formatBytesCompact(segmentsToLoadSize)})`, |
| ); |
| } |
| if (segmentsToDrop) { |
| queueParts.push( |
| `${segmentsToDrop} segments to drop (${formatBytesCompact(segmentsToDropSize)})`, |
| ); |
| } |
| return queueParts.join(', ') || 'Empty load/drop queues'; |
| } |
| |
| export interface ServicesViewProps { |
| middleManager: string | undefined; |
| goToQuery: (initSql: string) => void; |
| goToTask: (taskId: string) => void; |
| capabilities: Capabilities; |
| } |
| |
| export interface ServicesViewState { |
| servicesState: QueryState<ServiceResultRow[]>; |
| serviceFilter: Filter[]; |
| groupServicesBy?: 'service_type' | 'tier'; |
| |
| middleManagerDisableWorkerHost?: string; |
| middleManagerEnableWorkerHost?: string; |
| |
| hiddenColumns: LocalStorageBackedArray<string>; |
| } |
| |
| interface ServiceQueryResultRow { |
| service: string; |
| service_type: string; |
| tier: string; |
| curr_size: number; |
| host: string; |
| max_size: number; |
| plaintext_port: number; |
| tls_port: number; |
| } |
| |
| interface LoadQueueStatus { |
| segmentsToDrop: number; |
| segmentsToDropSize: number; |
| segmentsToLoad: number; |
| segmentsToLoadSize: number; |
| } |
| |
| interface MiddleManagerQueryResultRow { |
| availabilityGroups: string[]; |
| blacklistedUntil: string | null; |
| currCapacityUsed: number; |
| lastCompletedTaskTime: string; |
| category: string; |
| runningTasks: string[]; |
| worker: { |
| capacity: number; |
| host: string; |
| ip: string; |
| scheme: string; |
| version: string; |
| category: string; |
| }; |
| } |
| |
| interface ServiceResultRow |
| extends ServiceQueryResultRow, |
| Partial<LoadQueueStatus>, |
| Partial<MiddleManagerQueryResultRow> {} |
| |
| export class ServicesView extends React.PureComponent<ServicesViewProps, ServicesViewState> { |
| private serviceQueryManager: QueryManager<Capabilities, ServiceResultRow[]>; |
| |
| // 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", |
| ( |
| 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 |
| ) AS "rank" |
| FROM sys.servers |
| ORDER BY "rank" DESC, "service" DESC`; |
| |
| static async getServices(): Promise<ServiceQueryResultRow[]> { |
| const allServiceResp = await axios.get('/druid/coordinator/v1/servers?simple'); |
| const allServices = allServiceResp.data; |
| return allServices.map((s: any) => { |
| return { |
| service: s.host, |
| service_type: s.type === 'indexer-executor' ? 'peon' : s.type, |
| tier: s.tier, |
| host: s.host.split(':')[0], |
| plaintext_port: parseInt(s.host.split(':')[1], 10), |
| curr_size: s.currSize, |
| max_size: s.maxSize, |
| tls_port: -1, |
| }; |
| }); |
| } |
| |
| constructor(props: ServicesViewProps, context: any) { |
| super(props, context); |
| this.state = { |
| servicesState: QueryState.INIT, |
| serviceFilter: [], |
| |
| hiddenColumns: new LocalStorageBackedArray<string>( |
| LocalStorageKeys.SERVICE_TABLE_COLUMN_SELECTION, |
| ), |
| }; |
| |
| this.serviceQueryManager = new QueryManager({ |
| processQuery: async capabilities => { |
| let services: ServiceQueryResultRow[]; |
| if (capabilities.hasSql()) { |
| services = await queryDruidSql({ query: ServicesView.SERVICE_SQL }); |
| } else if (capabilities.hasCoordinatorAccess()) { |
| services = await ServicesView.getServices(); |
| } else { |
| throw new Error(`must have SQL or coordinator access`); |
| } |
| |
| if (capabilities.hasCoordinatorAccess()) { |
| 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; |
| }); |
| } |
| |
| if (capabilities.hasOverlordAccess()) { |
| let middleManagers: MiddleManagerQueryResultRow[]; |
| try { |
| const middleManagerResponse = await axios.get('/druid/indexer/v1/workers'); |
| middleManagers = middleManagerResponse.data; |
| } 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; |
| } |
| } |
| |
| const middleManagersLookup: Record<string, MiddleManagerQueryResultRow> = lookupBy( |
| middleManagers, |
| m => m.worker.host, |
| ); |
| |
| services = services.map(s => { |
| const middleManagerInfo = middleManagersLookup[s.service]; |
| if (middleManagerInfo) { |
| s = Object.assign(s, middleManagerInfo); |
| } |
| return s; |
| }); |
| } |
| |
| return services; |
| }, |
| onStateChange: servicesState => { |
| this.setState({ |
| servicesState, |
| }); |
| }, |
| }); |
| } |
| |
| componentDidMount(): void { |
| const { capabilities } = this.props; |
| this.serviceQueryManager.runQuery(capabilities); |
| } |
| |
| componentWillUnmount(): void { |
| this.serviceQueryManager.terminate(); |
| } |
| |
| renderServicesTable() { |
| const { capabilities } = this.props; |
| const { servicesState, serviceFilter, groupServicesBy, hiddenColumns } = this.state; |
| |
| const fillIndicator = (value: number) => { |
| let formattedValue = (value * 100).toFixed(1); |
| if (formattedValue === '0.0' && value > 0) formattedValue = '~' + formattedValue; |
| return ( |
| <div className="fill-indicator"> |
| <div className="bar" style={{ width: `${value * 100}%` }} /> |
| <div className="label">{formattedValue + '%'}</div> |
| </div> |
| ); |
| }; |
| |
| const services = servicesState.data; |
| return ( |
| <ReactTable |
| data={services || []} |
| loading={servicesState.loading} |
| noDataText={ |
| servicesState.isEmpty() ? 'No historicals' : servicesState.getErrorMessage() || '' |
| } |
| filterable |
| filtered={serviceFilter} |
| onFilteredChange={filtered => { |
| this.setState({ serviceFilter: filtered }); |
| }} |
| pivotBy={groupServicesBy ? [groupServicesBy] : []} |
| defaultPageSize={50} |
| columns={[ |
| { |
| Header: 'Service', |
| show: hiddenColumns.exists('Service'), |
| accessor: 'service', |
| width: 300, |
| Aggregated: () => '', |
| }, |
| { |
| Header: 'Type', |
| show: hiddenColumns.exists('Type'), |
| accessor: 'service_type', |
| width: 150, |
| Cell: row => { |
| const value = row.value; |
| return ( |
| <a |
| onClick={() => { |
| this.setState({ |
| serviceFilter: addFilter(serviceFilter, 'service_type', value), |
| }); |
| }} |
| > |
| {value} |
| </a> |
| ); |
| }, |
| }, |
| { |
| Header: 'Tier', |
| show: hiddenColumns.exists('Tier'), |
| id: 'tier', |
| accessor: row => { |
| return row.tier ? row.tier : row.worker ? row.worker.category : null; |
| }, |
| Cell: row => { |
| const value = row.value; |
| return ( |
| <a |
| onClick={() => { |
| this.setState({ serviceFilter: addFilter(serviceFilter, 'tier', value) }); |
| }} |
| > |
| {value} |
| </a> |
| ); |
| }, |
| }, |
| { |
| Header: 'Host', |
| show: hiddenColumns.exists('Host'), |
| accessor: 'host', |
| Aggregated: () => '', |
| }, |
| { |
| Header: 'Port', |
| show: hiddenColumns.exists('Port'), |
| id: 'port', |
| 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'; |
| }, |
| Aggregated: () => '', |
| }, |
| { |
| Header: 'Curr size', |
| show: hiddenColumns.exists('Curr size'), |
| id: 'curr_size', |
| width: 100, |
| filterable: false, |
| accessor: 'curr_size', |
| Aggregated: row => { |
| if (row.row._pivotVal !== 'historical') return ''; |
| const originals = row.subRows.map(r => r._original); |
| const totalCurr = sum(originals, s => s.curr_size); |
| return formatBytes(totalCurr); |
| }, |
| Cell: row => { |
| if (row.aggregated || row.original.service_type !== 'historical') return ''; |
| if (row.value === null) return ''; |
| return formatBytes(row.value); |
| }, |
| }, |
| { |
| Header: 'Max size', |
| show: hiddenColumns.exists('Max size'), |
| id: 'max_size', |
| width: 100, |
| filterable: false, |
| accessor: 'max_size', |
| Aggregated: row => { |
| if (row.row._pivotVal !== 'historical') return ''; |
| const originals = row.subRows.map(r => r._original); |
| const totalMax = sum(originals, s => s.max_size); |
| return formatBytes(totalMax); |
| }, |
| Cell: row => { |
| if (row.aggregated || row.original.service_type !== 'historical') return ''; |
| if (row.value === null) return ''; |
| return formatBytes(row.value); |
| }, |
| }, |
| { |
| Header: 'Usage', |
| show: hiddenColumns.exists('Usage'), |
| id: 'usage', |
| width: 100, |
| filterable: false, |
| accessor: row => { |
| if (row.service_type === 'middle_manager' || row.service_type === 'indexer') { |
| return row.worker ? (row.currCapacityUsed || 0) / row.worker.capacity : null; |
| } else { |
| return row.max_size ? row.curr_size / row.max_size : null; |
| } |
| }, |
| Aggregated: row => { |
| switch (row.row._pivotVal) { |
| case 'historical': |
| const originalHistoricals = row.subRows.map(r => r._original); |
| const totalCurr = sum(originalHistoricals, s => s.curr_size); |
| const totalMax = sum(originalHistoricals, s => s.max_size); |
| return fillIndicator(totalCurr / totalMax); |
| |
| case 'indexer': |
| case 'middle_manager': |
| const originalMiddleManagers = row.subRows.map(r => r._original); |
| const totalCurrCapacityUsed = sum( |
| originalMiddleManagers, |
| s => s.currCapacityUsed || 0, |
| ); |
| const totalWorkerCapacity = sum( |
| originalMiddleManagers, |
| s => deepGet(s, 'worker.capacity') || 0, |
| ); |
| return `${totalCurrCapacityUsed} / ${totalWorkerCapacity} (total slots)`; |
| |
| default: |
| return ''; |
| } |
| }, |
| Cell: row => { |
| if (row.aggregated) return ''; |
| const { service_type } = row.original; |
| switch (service_type) { |
| case 'historical': |
| return fillIndicator(row.value); |
| |
| case 'indexer': |
| case 'middle_manager': |
| const currCapacityUsed = deepGet(row, 'original.currCapacityUsed') || 0; |
| const capacity = deepGet(row, 'original.worker.capacity'); |
| if (typeof capacity === 'number') { |
| return `${currCapacityUsed} / ${capacity} (slots)`; |
| } else { |
| return '- / -'; |
| } |
| |
| default: |
| return ''; |
| } |
| }, |
| }, |
| { |
| Header: 'Detail', |
| show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Detail'), |
| id: 'queue', |
| width: 400, |
| filterable: false, |
| accessor: row => { |
| if (row.service_type === 'middle_manager' || row.service_type === 'indexer') { |
| if (deepGet(row, 'worker.version') === '') return 'Disabled'; |
| |
| const details: string[] = []; |
| if (row.lastCompletedTaskTime) { |
| details.push(`Last completed task: ${row.lastCompletedTaskTime}`); |
| } |
| if (row.blacklistedUntil) { |
| details.push(`Blacklisted until: ${row.blacklistedUntil}`); |
| } |
| return details.join(' '); |
| } else { |
| return (row.segmentsToLoad || 0) + (row.segmentsToDrop || 0); |
| } |
| }, |
| Cell: row => { |
| if (row.aggregated) return ''; |
| const { service_type } = row.original; |
| switch (service_type) { |
| case 'historical': |
| const { |
| segmentsToLoad, |
| segmentsToLoadSize, |
| segmentsToDrop, |
| segmentsToDropSize, |
| } = row.original; |
| return formatQueues( |
| segmentsToLoad, |
| segmentsToLoadSize, |
| segmentsToDrop, |
| segmentsToDropSize, |
| ); |
| |
| case 'indexer': |
| case 'middle_manager': |
| return row.value; |
| |
| default: |
| return ''; |
| } |
| }, |
| Aggregated: row => { |
| if (row.row._pivotVal !== 'historical') return ''; |
| const originals = row.subRows.map(r => r._original); |
| const segmentsToLoad = sum(originals, s => s.segmentsToLoad); |
| const segmentsToLoadSize = sum(originals, s => s.segmentsToLoadSize); |
| const segmentsToDrop = sum(originals, s => s.segmentsToDrop); |
| const segmentsToDropSize = sum(originals, s => s.segmentsToDropSize); |
| return formatQueues( |
| segmentsToLoad, |
| segmentsToLoadSize, |
| segmentsToDrop, |
| segmentsToDropSize, |
| ); |
| }, |
| }, |
| { |
| Header: ACTION_COLUMN_LABEL, |
| show: capabilities.hasOverlordAccess() && hiddenColumns.exists(ACTION_COLUMN_LABEL), |
| id: ACTION_COLUMN_ID, |
| width: ACTION_COLUMN_WIDTH, |
| accessor: row => row.worker, |
| filterable: false, |
| Cell: row => { |
| if (!row.value) return null; |
| const disabled = row.value.version === ''; |
| const workerActions = this.getWorkerActions(row.value.host, disabled); |
| return <ActionCell actions={workerActions} />; |
| }, |
| }, |
| ]} |
| /> |
| ); |
| } |
| |
| 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 axios.post( |
| `/druid/indexer/v1/worker/${middleManagerDisableWorkerHost}/disable`, |
| {}, |
| ); |
| return resp.data; |
| }} |
| confirmButtonText="Disable worker" |
| successText="Worker has been disabled" |
| failText="Could not disable worker" |
| 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 axios.post( |
| `/druid/indexer/v1/worker/${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={IconNames.APPLICATION} |
| text="View SQL query for table" |
| onClick={() => goToQuery(ServicesView.SERVICE_SQL)} |
| /> |
| )} |
| </MoreButton> |
| ); |
| } |
| |
| render(): JSX.Element { |
| const { capabilities } = this.props; |
| const { groupServicesBy, hiddenColumns } = 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 => this.serviceQueryManager.rerunLastQuery(auto)} |
| localStorageKey={LocalStorageKeys.SERVICES_REFRESH_RATE} |
| /> |
| {this.renderBulkServicesActions()} |
| <TableColumnSelector |
| columns={tableColumns[capabilities.getMode()]} |
| onChange={column => |
| this.setState(prevState => ({ |
| hiddenColumns: prevState.hiddenColumns.toggle(column), |
| })) |
| } |
| tableColumnsHidden={hiddenColumns.storedArray} |
| /> |
| </ViewControlBar> |
| {this.renderServicesTable()} |
| {this.renderDisableWorkerAction()} |
| {this.renderEnableWorkerAction()} |
| </div> |
| ); |
| } |
| } |