| /* |
| * 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 { FormGroup, InputGroup, Intent, MenuItem, Switch } from '@blueprintjs/core'; |
| import { IconNames } from '@blueprintjs/icons'; |
| import axios from 'axios'; |
| import classNames from 'classnames'; |
| import { SqlQuery, SqlRef } from 'druid-query-toolkit'; |
| import React from 'react'; |
| import ReactTable, { Filter } from 'react-table'; |
| |
| import { |
| ACTION_COLUMN_ID, |
| ACTION_COLUMN_LABEL, |
| ACTION_COLUMN_WIDTH, |
| ActionCell, |
| ActionIcon, |
| BracedText, |
| MoreButton, |
| RefreshButton, |
| SegmentTimeline, |
| TableColumnSelector, |
| ViewControlBar, |
| } from '../../components'; |
| import { AsyncActionDialog, CompactionDialog, RetentionDialog } from '../../dialogs'; |
| import { DatasourceTableActionDialog } from '../../dialogs/datasource-table-action-dialog/datasource-table-action-dialog'; |
| import { AppToaster } from '../../singletons/toaster'; |
| import { |
| addFilter, |
| CompactionConfig, |
| CompactionStatus, |
| countBy, |
| formatBytes, |
| formatCompactionConfigAndStatus, |
| formatInteger, |
| formatMegabytes, |
| formatPercent, |
| getDruidErrorMessage, |
| LocalStorageKeys, |
| lookupBy, |
| pluralIfNeeded, |
| queryDruidSql, |
| QueryManager, |
| QueryState, |
| zeroCompactionStatus, |
| } from '../../utils'; |
| import { BasicAction } from '../../utils/basic-action'; |
| import { Capabilities, CapabilitiesMode } from '../../utils/capabilities'; |
| import { Rule, RuleUtil } from '../../utils/load-rule'; |
| import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array'; |
| import { deepGet } from '../../utils/object-change'; |
| |
| import './datasource-view.scss'; |
| |
| const tableColumns: Record<CapabilitiesMode, string[]> = { |
| full: [ |
| 'Datasource name', |
| 'Availability', |
| 'Segment load/drop queues', |
| 'Total data size', |
| 'Segment size', |
| 'Total rows', |
| 'Avg. row size', |
| 'Replicated size', |
| 'Compaction', |
| '% Compacted', |
| 'Left to be compacted', |
| 'Retention', |
| ACTION_COLUMN_LABEL, |
| ], |
| 'no-sql': [ |
| 'Datasource name', |
| 'Availability', |
| 'Segment load/drop queues', |
| 'Total data size', |
| 'Segment size', |
| 'Compaction', |
| '% Compacted', |
| 'Left to be compacted', |
| 'Retention', |
| ACTION_COLUMN_LABEL, |
| ], |
| 'no-proxy': [ |
| 'Datasource name', |
| 'Availability', |
| 'Segment load/drop queues', |
| 'Total data size', |
| 'Segment size', |
| 'Total rows', |
| 'Avg. row size', |
| 'Replicated size', |
| ACTION_COLUMN_LABEL, |
| ], |
| }; |
| |
| function formatLoadDrop(segmentsToLoad: number, segmentsToDrop: number): string { |
| const loadDrop: string[] = []; |
| if (segmentsToLoad) { |
| loadDrop.push(`${pluralIfNeeded(segmentsToLoad, 'segment')} to load`); |
| } |
| if (segmentsToDrop) { |
| loadDrop.push(`${pluralIfNeeded(segmentsToDrop, 'segment')} to drop`); |
| } |
| return loadDrop.join(', ') || 'No segments to load/drop'; |
| } |
| |
| const formatTotalDataSize = formatBytes; |
| const formatSegmentSize = formatMegabytes; |
| const formatTotalRows = formatInteger; |
| const formatAvgRowSize = formatInteger; |
| const formatReplicatedSize = formatBytes; |
| const formatLeftToBeCompacted = formatBytes; |
| |
| function twoLines(line1: string, line2: string) { |
| return ( |
| <> |
| {line1} |
| <br /> |
| {line2} |
| </> |
| ); |
| } |
| |
| function progress(done: number, awaiting: number): number { |
| const d = done + awaiting; |
| if (!d) return 0; |
| return done / d; |
| } |
| |
| const PERCENT_BRACES = [formatPercent(1)]; |
| |
| interface Datasource { |
| datasource: string; |
| rules: Rule[]; |
| compactionConfig?: CompactionConfig; |
| compactionStatus?: CompactionStatus; |
| [key: string]: any; |
| } |
| |
| interface DatasourcesAndDefaultRules { |
| datasources: Datasource[]; |
| defaultRules: Rule[]; |
| } |
| |
| interface DatasourceQueryResultRow { |
| datasource: string; |
| num_segments: number; |
| num_available_segments: number; |
| num_segments_to_load: number; |
| num_segments_to_drop: number; |
| total_data_size: number; |
| replicated_size: number; |
| min_segment_size: number; |
| avg_segment_size: number; |
| max_segment_size: number; |
| total_rows: number; |
| avg_row_size: number; |
| } |
| |
| interface RetentionDialogOpenOn { |
| datasource: string; |
| rules: Rule[]; |
| } |
| |
| interface CompactionDialogOpenOn { |
| datasource: string; |
| compactionConfig: CompactionConfig; |
| } |
| |
| export interface DatasourcesViewProps { |
| goToQuery: (initSql: string) => void; |
| goToTask: (datasource?: string, openDialog?: string) => void; |
| goToSegments: (datasource: string, onlyUnavailable?: boolean) => void; |
| capabilities: Capabilities; |
| initDatasource?: string; |
| } |
| |
| export interface DatasourcesViewState { |
| datasourceFilter: Filter[]; |
| datasourcesAndDefaultRulesState: QueryState<DatasourcesAndDefaultRules>; |
| |
| tiersState: QueryState<string[]>; |
| |
| showUnused: boolean; |
| retentionDialogOpenOn?: RetentionDialogOpenOn; |
| compactionDialogOpenOn?: CompactionDialogOpenOn; |
| datasourceToMarkAsUnusedAllSegmentsIn?: string; |
| datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn?: string; |
| killDatasource?: string; |
| datasourceToMarkSegmentsByIntervalIn?: string; |
| useUnuseAction: 'use' | 'unuse'; |
| useUnuseInterval: string; |
| showForceCompact: boolean; |
| hiddenColumns: LocalStorageBackedArray<string>; |
| showChart: boolean; |
| chartWidth: number; |
| chartHeight: number; |
| |
| datasourceTableActionDialogId?: string; |
| actions: BasicAction[]; |
| } |
| |
| export class DatasourcesView extends React.PureComponent< |
| DatasourcesViewProps, |
| DatasourcesViewState |
| > { |
| static UNUSED_COLOR = '#0a1500'; |
| static FULLY_AVAILABLE_COLOR = '#57d500'; |
| static PARTIALLY_AVAILABLE_COLOR = '#ffbf00'; |
| |
| static DATASOURCE_SQL = `SELECT |
| datasource, |
| COUNT(*) FILTER (WHERE (is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AS num_segments, |
| COUNT(*) FILTER (WHERE is_available = 1 AND ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1)) AS num_available_segments, |
| COUNT(*) FILTER (WHERE is_published = 1 AND is_overshadowed = 0 AND is_available = 0) AS num_segments_to_load, |
| COUNT(*) FILTER (WHERE is_available = 1 AND NOT ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1)) AS num_segments_to_drop, |
| SUM("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS total_data_size, |
| SUM("size" * "num_replicas") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS replicated_size, |
| MIN("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS min_segment_size, |
| ( |
| SUM("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) / |
| COUNT(*) FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) |
| ) AS avg_segment_size, |
| MAX("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS max_segment_size, |
| SUM("num_rows") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AS total_rows, |
| ( |
| SUM("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) / |
| SUM("num_rows") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) |
| ) AS avg_row_size |
| FROM sys.segments |
| GROUP BY 1`; |
| |
| static formatRules(rules: Rule[]): string { |
| if (rules.length === 0) { |
| return 'No rules'; |
| } else if (rules.length <= 2) { |
| return rules.map(RuleUtil.ruleToString).join(', '); |
| } else { |
| return `${RuleUtil.ruleToString(rules[0])} +${rules.length - 1} more rules`; |
| } |
| } |
| |
| private datasourceQueryManager: QueryManager<Capabilities, DatasourcesAndDefaultRules>; |
| private tiersQueryManager: QueryManager<Capabilities, string[]>; |
| |
| constructor(props: DatasourcesViewProps, context: any) { |
| super(props, context); |
| |
| const datasourceFilter: Filter[] = []; |
| if (props.initDatasource) { |
| datasourceFilter.push({ id: 'datasource', value: `"${props.initDatasource}"` }); |
| } |
| |
| this.state = { |
| datasourceFilter, |
| datasourcesAndDefaultRulesState: QueryState.INIT, |
| |
| tiersState: QueryState.INIT, |
| |
| showUnused: false, |
| useUnuseAction: 'unuse', |
| useUnuseInterval: '', |
| showForceCompact: false, |
| hiddenColumns: new LocalStorageBackedArray<string>( |
| LocalStorageKeys.DATASOURCE_TABLE_COLUMN_SELECTION, |
| ), |
| showChart: false, |
| chartWidth: window.innerWidth * 0.85, |
| chartHeight: window.innerHeight * 0.4, |
| |
| actions: [], |
| }; |
| |
| this.datasourceQueryManager = new QueryManager({ |
| processQuery: async capabilities => { |
| let datasources: DatasourceQueryResultRow[]; |
| if (capabilities.hasSql()) { |
| datasources = await queryDruidSql({ query: DatasourcesView.DATASOURCE_SQL }); |
| } else if (capabilities.hasCoordinatorAccess()) { |
| const datasourcesResp = await axios.get('/druid/coordinator/v1/datasources?simple'); |
| const loadstatusResp = await axios.get('/druid/coordinator/v1/loadstatus?simple'); |
| const loadstatus = loadstatusResp.data; |
| datasources = datasourcesResp.data.map( |
| (d: any): DatasourceQueryResultRow => { |
| const totalDataSize = deepGet(d, 'properties.segments.size') || -1; |
| const segmentsToLoad = Number(loadstatus[d.name] || 0); |
| const availableSegments = Number(deepGet(d, 'properties.segments.count')); |
| const numSegments = availableSegments + segmentsToLoad; |
| return { |
| datasource: d.name, |
| num_available_segments: availableSegments, |
| num_segments: numSegments, |
| num_segments_to_load: segmentsToLoad, |
| num_segments_to_drop: 0, |
| replicated_size: -1, |
| total_data_size: totalDataSize, |
| min_segment_size: -1, |
| avg_segment_size: totalDataSize / numSegments, |
| max_segment_size: -1, |
| total_rows: -1, |
| avg_row_size: -1, |
| }; |
| }, |
| ); |
| } else { |
| throw new Error(`must have SQL or coordinator access`); |
| } |
| |
| if (!capabilities.hasCoordinatorAccess()) { |
| datasources.forEach((ds: any) => { |
| ds.rules = []; |
| }); |
| return { |
| datasources, |
| defaultRules: [], |
| }; |
| } |
| |
| const seen = countBy(datasources, x => x.datasource); |
| |
| let unused: string[] = []; |
| if (this.state.showUnused) { |
| // Using 'includeDisabled' parameter for compatibility. |
| // Should be changed to 'includeUnused' in Druid 0.17 |
| const unusedResp = await axios.get( |
| '/druid/coordinator/v1/metadata/datasources?includeDisabled', |
| ); |
| unused = unusedResp.data.filter((d: string) => !seen[d]); |
| } |
| |
| const rulesResp = await axios.get('/druid/coordinator/v1/rules'); |
| const rules = rulesResp.data; |
| |
| const compactionConfigsResp = await axios.get('/druid/coordinator/v1/config/compaction'); |
| const compactionConfigs = lookupBy( |
| compactionConfigsResp.data.compactionConfigs || [], |
| (c: CompactionConfig) => c.dataSource, |
| ); |
| |
| const compactionStatusesResp = await axios.get('/druid/coordinator/v1/compaction/status'); |
| const compactionStatuses = lookupBy( |
| compactionStatusesResp.data.latestStatus || [], |
| (c: CompactionStatus) => c.dataSource, |
| ); |
| |
| const allDatasources = (datasources as any).concat( |
| unused.map(d => ({ datasource: d, unused: true })), |
| ); |
| allDatasources.forEach((ds: Datasource) => { |
| ds.rules = rules[ds.datasource] || []; |
| ds.compactionConfig = compactionConfigs[ds.datasource]; |
| ds.compactionStatus = compactionStatuses[ds.datasource]; |
| }); |
| |
| return { |
| datasources: allDatasources, |
| defaultRules: rules['_default'], |
| }; |
| }, |
| onStateChange: datasourcesAndDefaultRulesState => { |
| this.setState({ |
| datasourcesAndDefaultRulesState, |
| }); |
| }, |
| }); |
| |
| this.tiersQueryManager = new QueryManager({ |
| processQuery: async capabilities => { |
| if (capabilities.hasCoordinatorAccess()) { |
| const tiersResp = await axios.get('/druid/coordinator/v1/tiers'); |
| return tiersResp.data; |
| } else { |
| throw new Error(`must have coordinator access`); |
| } |
| }, |
| onStateChange: tiersState => { |
| this.setState({ tiersState }); |
| }, |
| }); |
| } |
| |
| private handleResize = () => { |
| this.setState({ |
| chartWidth: window.innerWidth * 0.85, |
| chartHeight: window.innerHeight * 0.4, |
| }); |
| }; |
| |
| private refresh = (auto: any): void => { |
| this.datasourceQueryManager.rerunLastQuery(auto); |
| this.tiersQueryManager.rerunLastQuery(auto); |
| }; |
| |
| componentDidMount(): void { |
| const { capabilities } = this.props; |
| this.datasourceQueryManager.runQuery(capabilities); |
| this.tiersQueryManager.runQuery(capabilities); |
| window.addEventListener('resize', this.handleResize); |
| } |
| |
| componentWillUnmount(): void { |
| this.datasourceQueryManager.terminate(); |
| this.tiersQueryManager.terminate(); |
| } |
| |
| renderUnuseAction() { |
| const { datasourceToMarkAsUnusedAllSegmentsIn } = this.state; |
| if (!datasourceToMarkAsUnusedAllSegmentsIn) return; |
| |
| return ( |
| <AsyncActionDialog |
| action={async () => { |
| const resp = await axios.delete( |
| `/druid/coordinator/v1/datasources/${datasourceToMarkAsUnusedAllSegmentsIn}`, |
| {}, |
| ); |
| return resp.data; |
| }} |
| confirmButtonText="Mark as unused all segments" |
| successText="All segments in data source have been marked as unused" |
| failText="Failed to mark as unused all segments in data source" |
| intent={Intent.DANGER} |
| onClose={() => { |
| this.setState({ datasourceToMarkAsUnusedAllSegmentsIn: undefined }); |
| }} |
| onSuccess={() => { |
| this.datasourceQueryManager.rerunLastQuery(); |
| }} |
| > |
| <p> |
| {`Are you sure you want to mark as unused all segments in '${datasourceToMarkAsUnusedAllSegmentsIn}'?`} |
| </p> |
| </AsyncActionDialog> |
| ); |
| } |
| |
| renderUseAction() { |
| const { datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn } = this.state; |
| if (!datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn) return; |
| |
| return ( |
| <AsyncActionDialog |
| action={async () => { |
| const resp = await axios.post( |
| `/druid/coordinator/v1/datasources/${datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn}`, |
| {}, |
| ); |
| return resp.data; |
| }} |
| confirmButtonText="Mark as used all segments" |
| successText="All non-overshadowed segments in data source have been marked as used" |
| failText="Failed to mark as used all non-overshadowed segments in data source" |
| intent={Intent.PRIMARY} |
| onClose={() => { |
| this.setState({ datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn: undefined }); |
| }} |
| onSuccess={() => { |
| this.datasourceQueryManager.rerunLastQuery(); |
| }} |
| > |
| <p>{`Are you sure you want to mark as used all non-overshadowed segments in '${datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn}'?`}</p> |
| </AsyncActionDialog> |
| ); |
| } |
| |
| renderUseUnuseActionByInterval() { |
| const { datasourceToMarkSegmentsByIntervalIn, useUnuseAction, useUnuseInterval } = this.state; |
| if (!datasourceToMarkSegmentsByIntervalIn) return; |
| const isUse = useUnuseAction === 'use'; |
| const usedWord = isUse ? 'used' : 'unused'; |
| return ( |
| <AsyncActionDialog |
| action={async () => { |
| if (!useUnuseInterval) return; |
| const param = isUse ? 'markUsed' : 'markUnused'; |
| const resp = await axios.post( |
| `/druid/coordinator/v1/datasources/${datasourceToMarkSegmentsByIntervalIn}/${param}`, |
| { |
| interval: useUnuseInterval, |
| }, |
| ); |
| return resp.data; |
| }} |
| confirmButtonText={`Mark as ${usedWord} segments in the interval`} |
| confirmButtonDisabled={!/.\/./.test(useUnuseInterval)} |
| successText={`Segments in the interval in data source have been marked as ${usedWord}`} |
| failText={`Failed to mark as ${usedWord} segments in the interval in data source`} |
| intent={Intent.PRIMARY} |
| onClose={() => { |
| this.setState({ datasourceToMarkSegmentsByIntervalIn: undefined }); |
| }} |
| onSuccess={() => { |
| this.datasourceQueryManager.rerunLastQuery(); |
| }} |
| > |
| <p>{`Please select the interval in which you want to mark segments as ${usedWord} in '${datasourceToMarkSegmentsByIntervalIn}'?`}</p> |
| <FormGroup> |
| <InputGroup |
| value={useUnuseInterval} |
| onChange={(e: any) => { |
| const v = e.target.value; |
| this.setState({ useUnuseInterval: v.toUpperCase() }); |
| }} |
| placeholder="2018-01-01T00:00:00/2018-01-03T00:00:00" |
| /> |
| </FormGroup> |
| </AsyncActionDialog> |
| ); |
| } |
| |
| renderKillAction() { |
| const { killDatasource } = this.state; |
| if (!killDatasource) return; |
| |
| return ( |
| <AsyncActionDialog |
| action={async () => { |
| const resp = await axios.delete( |
| `/druid/coordinator/v1/datasources/${killDatasource}?kill=true&interval=1000/3000`, |
| {}, |
| ); |
| return resp.data; |
| }} |
| confirmButtonText="Permanently delete unused segments" |
| successText="Kill task was issued. Unused segments in data source will be deleted" |
| failText="Failed submit kill task" |
| intent={Intent.DANGER} |
| onClose={() => { |
| this.setState({ killDatasource: undefined }); |
| }} |
| onSuccess={() => { |
| this.datasourceQueryManager.rerunLastQuery(); |
| }} |
| > |
| <p> |
| {`Are you sure you want to permanently delete unused segments in '${killDatasource}'?`} |
| </p> |
| <p>This action is not reversible and the data deleted will be lost.</p> |
| </AsyncActionDialog> |
| ); |
| } |
| |
| renderBulkDatasourceActions() { |
| const { goToQuery, capabilities } = this.props; |
| |
| return ( |
| <MoreButton |
| altExtra={ |
| <MenuItem |
| icon={IconNames.COMPRESSED} |
| text="Force compaction run (debug)" |
| intent={Intent.DANGER} |
| onClick={() => { |
| this.setState({ showForceCompact: true }); |
| }} |
| /> |
| } |
| > |
| {capabilities.hasSql() && ( |
| <MenuItem |
| icon={IconNames.APPLICATION} |
| text="View SQL query for table" |
| onClick={() => goToQuery(DatasourcesView.DATASOURCE_SQL)} |
| /> |
| )} |
| <MenuItem |
| icon={IconNames.EDIT} |
| text="Edit default retention rules" |
| onClick={this.editDefaultRules} |
| /> |
| </MoreButton> |
| ); |
| } |
| |
| renderForceCompactAction() { |
| const { showForceCompact } = this.state; |
| if (!showForceCompact) return; |
| |
| return ( |
| <AsyncActionDialog |
| action={async () => { |
| const resp = await axios.post(`/druid/coordinator/v1/compaction/compact`, {}); |
| return resp.data; |
| }} |
| confirmButtonText="Force compaction run" |
| successText="Out of band compaction run has been initiated" |
| failText="Could not force compaction" |
| intent={Intent.DANGER} |
| onClose={() => { |
| this.setState({ showForceCompact: false }); |
| }} |
| > |
| <p>Are you sure you want to force a compaction run?</p> |
| <p>This functionality only exists for debugging and testing reasons.</p> |
| <p>If you are running it in production you are doing something wrong.</p> |
| </AsyncActionDialog> |
| ); |
| } |
| |
| private saveRules = async (datasource: string, rules: Rule[], comment: string) => { |
| try { |
| await axios.post(`/druid/coordinator/v1/rules/${datasource}`, rules, { |
| headers: { |
| 'X-Druid-Author': 'console', |
| 'X-Druid-Comment': comment, |
| }, |
| }); |
| } catch (e) { |
| AppToaster.show({ |
| message: `Failed to submit retention rules: ${getDruidErrorMessage(e)}`, |
| intent: Intent.DANGER, |
| }); |
| return; |
| } |
| |
| AppToaster.show({ |
| message: 'Retention rules submitted successfully', |
| intent: Intent.SUCCESS, |
| }); |
| this.datasourceQueryManager.rerunLastQuery(); |
| }; |
| |
| private editDefaultRules = () => { |
| this.setState({ retentionDialogOpenOn: undefined }); |
| setTimeout(() => { |
| this.setState(state => { |
| const datasourcesAndDefaultRules = state.datasourcesAndDefaultRulesState.data; |
| if (!datasourcesAndDefaultRules) return {}; |
| |
| return { |
| retentionDialogOpenOn: { |
| datasource: '_default', |
| rules: datasourcesAndDefaultRules.defaultRules, |
| }, |
| }; |
| }); |
| }, 50); |
| }; |
| |
| private saveCompaction = async (compactionConfig: any) => { |
| if (!compactionConfig) return; |
| try { |
| await axios.post(`/druid/coordinator/v1/config/compaction`, compactionConfig); |
| this.setState({ compactionDialogOpenOn: undefined }); |
| this.datasourceQueryManager.rerunLastQuery(); |
| } catch (e) { |
| AppToaster.show({ |
| message: getDruidErrorMessage(e), |
| intent: Intent.DANGER, |
| }); |
| } |
| }; |
| |
| private deleteCompaction = async () => { |
| const { compactionDialogOpenOn } = this.state; |
| if (!compactionDialogOpenOn) return; |
| const datasource = compactionDialogOpenOn.datasource; |
| AppToaster.show({ |
| message: `Are you sure you want to delete ${datasource}'s compaction?`, |
| intent: Intent.DANGER, |
| action: { |
| text: 'Confirm', |
| onClick: async () => { |
| try { |
| await axios.delete(`/druid/coordinator/v1/config/compaction/${datasource}`); |
| this.setState({ compactionDialogOpenOn: undefined }, () => |
| this.datasourceQueryManager.rerunLastQuery(), |
| ); |
| } catch (e) { |
| AppToaster.show({ |
| message: getDruidErrorMessage(e), |
| intent: Intent.DANGER, |
| }); |
| } |
| }, |
| }, |
| }); |
| }; |
| |
| private toggleUnused(showUnused: boolean) { |
| if (!showUnused) { |
| this.datasourceQueryManager.rerunLastQuery(); |
| } |
| this.setState({ showUnused: !showUnused }); |
| } |
| |
| getDatasourceActions( |
| datasource: string, |
| unused: boolean, |
| rules: Rule[], |
| compactionConfig: CompactionConfig, |
| ): BasicAction[] { |
| const { goToQuery, goToTask, capabilities } = this.props; |
| |
| const goToActions: BasicAction[] = [ |
| { |
| icon: IconNames.APPLICATION, |
| title: 'Query with SQL', |
| onAction: () => goToQuery(SqlQuery.create(SqlRef.table(datasource)).toString()), |
| }, |
| { |
| icon: IconNames.GANTT_CHART, |
| title: 'Go to tasks', |
| onAction: () => goToTask(datasource), |
| }, |
| ]; |
| |
| if (!capabilities.hasCoordinatorAccess()) { |
| return goToActions; |
| } |
| |
| if (unused) { |
| return [ |
| { |
| icon: IconNames.EXPORT, |
| title: 'Mark as used all segments', |
| |
| onAction: () => |
| this.setState({ |
| datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn: datasource, |
| }), |
| }, |
| { |
| icon: IconNames.TRASH, |
| title: 'Delete segments (issue kill task)', |
| intent: Intent.DANGER, |
| onAction: () => this.setState({ killDatasource: datasource }), |
| }, |
| ]; |
| } else { |
| return goToActions.concat([ |
| { |
| icon: IconNames.AUTOMATIC_UPDATES, |
| title: 'Edit retention rules', |
| onAction: () => { |
| this.setState({ |
| retentionDialogOpenOn: { |
| datasource, |
| rules, |
| }, |
| }); |
| }, |
| }, |
| { |
| icon: IconNames.REFRESH, |
| title: 'Mark as used all segments (will lead to reapplying retention rules)', |
| onAction: () => |
| this.setState({ |
| datasourceToMarkAllNonOvershadowedSegmentsAsUsedIn: datasource, |
| }), |
| }, |
| { |
| icon: IconNames.COMPRESSED, |
| title: 'Edit compaction configuration', |
| onAction: () => { |
| this.setState({ |
| compactionDialogOpenOn: { |
| datasource, |
| compactionConfig, |
| }, |
| }); |
| }, |
| }, |
| { |
| icon: IconNames.EXPORT, |
| title: 'Mark as used segments by interval', |
| |
| onAction: () => |
| this.setState({ |
| datasourceToMarkSegmentsByIntervalIn: datasource, |
| useUnuseAction: 'use', |
| }), |
| }, |
| { |
| icon: IconNames.IMPORT, |
| title: 'Mark as unused segments by interval', |
| |
| onAction: () => |
| this.setState({ |
| datasourceToMarkSegmentsByIntervalIn: datasource, |
| useUnuseAction: 'unuse', |
| }), |
| }, |
| { |
| icon: IconNames.IMPORT, |
| title: 'Mark as unused all segments', |
| intent: Intent.DANGER, |
| onAction: () => this.setState({ datasourceToMarkAsUnusedAllSegmentsIn: datasource }), |
| }, |
| { |
| icon: IconNames.TRASH, |
| title: 'Delete unused segments (issue kill task)', |
| intent: Intent.DANGER, |
| onAction: () => this.setState({ killDatasource: datasource }), |
| }, |
| ]); |
| } |
| } |
| |
| renderRetentionDialog(): JSX.Element | undefined { |
| const { retentionDialogOpenOn, tiersState, datasourcesAndDefaultRulesState } = this.state; |
| const { defaultRules } = datasourcesAndDefaultRulesState.data || { |
| datasources: [], |
| defaultRules: [], |
| }; |
| if (!retentionDialogOpenOn) return; |
| |
| return ( |
| <RetentionDialog |
| datasource={retentionDialogOpenOn.datasource} |
| rules={retentionDialogOpenOn.rules} |
| tiers={tiersState.data || []} |
| onEditDefaults={this.editDefaultRules} |
| defaultRules={defaultRules} |
| onCancel={() => this.setState({ retentionDialogOpenOn: undefined })} |
| onSave={this.saveRules} |
| /> |
| ); |
| } |
| |
| renderCompactionDialog() { |
| const { datasourcesAndDefaultRulesState, compactionDialogOpenOn } = this.state; |
| if (!compactionDialogOpenOn || !datasourcesAndDefaultRulesState.data) return; |
| |
| return ( |
| <CompactionDialog |
| datasource={compactionDialogOpenOn.datasource} |
| compactionConfig={compactionDialogOpenOn.compactionConfig} |
| onClose={() => this.setState({ compactionDialogOpenOn: undefined })} |
| onSave={this.saveCompaction} |
| onDelete={this.deleteCompaction} |
| /> |
| ); |
| } |
| |
| renderDatasourceTable() { |
| const { goToSegments, capabilities } = this.props; |
| const { |
| datasourcesAndDefaultRulesState, |
| datasourceFilter, |
| showUnused, |
| hiddenColumns, |
| } = this.state; |
| |
| let { datasources, defaultRules } = datasourcesAndDefaultRulesState.data |
| ? datasourcesAndDefaultRulesState.data |
| : { datasources: [], defaultRules: [] }; |
| |
| if (!showUnused) { |
| datasources = datasources.filter(d => !d.unused); |
| } |
| |
| // Calculate column values for bracing |
| |
| const totalDataSizeValues = datasources.map(d => formatTotalDataSize(d.total_data_size)); |
| |
| const minSegmentSizeValues = datasources.map(d => formatSegmentSize(d.min_segment_size)); |
| |
| const avgSegmentSizeValues = datasources.map(d => formatSegmentSize(d.avg_segment_size)); |
| |
| const maxSegmentSizeValues = datasources.map(d => formatSegmentSize(d.max_segment_size)); |
| |
| const totalRowsValues = datasources.map(d => formatTotalRows(d.total_rows)); |
| |
| const avgRowSizeValues = datasources.map(d => formatAvgRowSize(d.avg_row_size)); |
| |
| const replicatedSizeValues = datasources.map(d => formatReplicatedSize(d.replicated_size)); |
| |
| const leftToBeCompactedValues = datasources.map(d => |
| d.compactionStatus |
| ? formatLeftToBeCompacted(d.compactionStatus.bytesAwaitingCompaction) |
| : '-', |
| ); |
| |
| return ( |
| <> |
| <ReactTable |
| data={datasources} |
| loading={datasourcesAndDefaultRulesState.loading} |
| noDataText={ |
| datasourcesAndDefaultRulesState.getErrorMessage() || |
| (!datasourcesAndDefaultRulesState.loading && datasources && !datasources.length |
| ? 'No datasources' |
| : '') |
| } |
| filterable |
| filtered={datasourceFilter} |
| onFilteredChange={filtered => { |
| this.setState({ datasourceFilter: filtered }); |
| }} |
| columns={[ |
| { |
| Header: twoLines('Datasource', 'name'), |
| show: hiddenColumns.exists('Datasource name'), |
| accessor: 'datasource', |
| width: 150, |
| Cell: ({ value }) => { |
| return ( |
| <a |
| onClick={() => { |
| this.setState({ |
| datasourceFilter: addFilter(datasourceFilter, 'datasource', value), |
| }); |
| }} |
| > |
| {value} |
| </a> |
| ); |
| }, |
| }, |
| { |
| Header: 'Availability', |
| show: hiddenColumns.exists('Availability'), |
| id: 'availability', |
| filterable: false, |
| minWidth: 200, |
| accessor: row => { |
| return { |
| num_available: row.num_available_segments, |
| num_total: row.num_segments, |
| }; |
| }, |
| Cell: ({ original }) => { |
| const { datasource, num_available_segments, num_segments, unused } = original; |
| |
| if (unused) { |
| return ( |
| <span> |
| <span style={{ color: DatasourcesView.UNUSED_COLOR }}>● </span> |
| Unused |
| </span> |
| ); |
| } |
| |
| const segmentsEl = ( |
| <a onClick={() => goToSegments(datasource)}> |
| {pluralIfNeeded(num_segments, 'segment')} |
| </a> |
| ); |
| if (num_available_segments === num_segments) { |
| return ( |
| <span> |
| <span style={{ color: DatasourcesView.FULLY_AVAILABLE_COLOR }}> |
| ● |
| </span> |
| Fully available ({segmentsEl}) |
| </span> |
| ); |
| } else { |
| const percentAvailable = ( |
| Math.floor((num_available_segments / num_segments) * 1000) / 10 |
| ).toFixed(1); |
| const missing = num_segments - num_available_segments; |
| const segmentsMissingEl = ( |
| <a onClick={() => goToSegments(datasource, true)}>{`${pluralIfNeeded( |
| missing, |
| 'segment', |
| )} unavailable`}</a> |
| ); |
| return ( |
| <span> |
| <span style={{ color: DatasourcesView.PARTIALLY_AVAILABLE_COLOR }}> |
| {num_available_segments ? '\u25cf' : '\u25cb'} |
| </span> |
| {percentAvailable}% available ({segmentsEl}, {segmentsMissingEl}) |
| </span> |
| ); |
| } |
| }, |
| sortMethod: (d1, d2) => { |
| const percentAvailable1 = d1.num_available / d1.num_total; |
| const percentAvailable2 = d2.num_available / d2.num_total; |
| return percentAvailable1 - percentAvailable2 || d1.num_total - d2.num_total; |
| }, |
| }, |
| { |
| Header: twoLines('Segment load/drop', 'queues'), |
| show: hiddenColumns.exists('Segment load/drop queues'), |
| id: 'load-drop', |
| accessor: 'num_segments_to_load', |
| filterable: false, |
| minWidth: 100, |
| Cell: ({ original }) => { |
| const { num_segments_to_load, num_segments_to_drop } = original; |
| return formatLoadDrop(num_segments_to_load, num_segments_to_drop); |
| }, |
| }, |
| { |
| Header: twoLines('Total', 'data size'), |
| show: hiddenColumns.exists('Total data size'), |
| accessor: 'total_data_size', |
| filterable: false, |
| width: 100, |
| Cell: ({ value }) => ( |
| <BracedText text={formatTotalDataSize(value)} braces={totalDataSizeValues} /> |
| ), |
| }, |
| { |
| Header: twoLines('Segment size (MB)', 'min / avg / max'), |
| show: hiddenColumns.exists('Segment size'), |
| accessor: 'avg_segment_size', |
| filterable: false, |
| width: 150, |
| Cell: ({ value, original }) => ( |
| <> |
| <BracedText |
| text={formatSegmentSize(original.min_segment_size)} |
| braces={minSegmentSizeValues} |
| />{' '} |
| {' '} |
| <BracedText text={formatSegmentSize(value)} braces={avgSegmentSizeValues} />{' '} |
| {' '} |
| <BracedText |
| text={formatSegmentSize(original.max_segment_size)} |
| braces={maxSegmentSizeValues} |
| /> |
| </> |
| ), |
| }, |
| { |
| Header: twoLines('Total', 'rows'), |
| show: capabilities.hasSql() && hiddenColumns.exists('Total rows'), |
| accessor: 'total_rows', |
| filterable: false, |
| width: 100, |
| Cell: ({ value }) => ( |
| <BracedText text={formatTotalRows(value)} braces={totalRowsValues} /> |
| ), |
| }, |
| { |
| Header: twoLines('Avg. row size', '(bytes)'), |
| show: hiddenColumns.exists('Avg. row size'), |
| accessor: 'avg_row_size', |
| filterable: false, |
| width: 100, |
| Cell: ({ value }) => ( |
| <BracedText text={formatAvgRowSize(value)} braces={avgRowSizeValues} /> |
| ), |
| }, |
| { |
| Header: twoLines('Replicated', 'size'), |
| show: capabilities.hasSql() && hiddenColumns.exists('Replicated size'), |
| accessor: 'replicated_size', |
| filterable: false, |
| width: 100, |
| Cell: ({ value }) => ( |
| <BracedText text={formatReplicatedSize(value)} braces={replicatedSizeValues} /> |
| ), |
| }, |
| { |
| Header: 'Compaction', |
| show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Compaction'), |
| id: 'compactionStatus', |
| accessor: row => Boolean(row.compactionStatus), |
| filterable: false, |
| width: 150, |
| Cell: ({ original }) => { |
| const { datasource, compactionConfig, compactionStatus } = original; |
| return ( |
| <span |
| className="clickable-cell" |
| onClick={() => |
| this.setState({ |
| compactionDialogOpenOn: { |
| datasource, |
| compactionConfig, |
| }, |
| }) |
| } |
| > |
| {formatCompactionConfigAndStatus(compactionConfig, compactionStatus)} |
| <ActionIcon icon={IconNames.EDIT} /> |
| </span> |
| ); |
| }, |
| }, |
| { |
| Header: twoLines('% Compacted', 'bytes / segments / intervals'), |
| show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('% Compacted'), |
| id: 'percentCompacted', |
| width: 200, |
| accessor: ({ compactionStatus }) => |
| compactionStatus && compactionStatus.bytesCompacted |
| ? compactionStatus.bytesCompacted / |
| (compactionStatus.bytesAwaitingCompaction + compactionStatus.bytesCompacted) |
| : 0, |
| filterable: false, |
| Cell: ({ original }) => { |
| const { compactionStatus } = original; |
| |
| if (!compactionStatus || zeroCompactionStatus(compactionStatus)) { |
| return ( |
| <> |
| <BracedText text="-" braces={PERCENT_BRACES} /> {' '} |
| <BracedText text="-" braces={PERCENT_BRACES} /> {' '} |
| <BracedText text="-" braces={PERCENT_BRACES} /> |
| </> |
| ); |
| } |
| |
| return ( |
| <> |
| <BracedText |
| text={formatPercent( |
| progress( |
| compactionStatus.bytesCompacted, |
| compactionStatus.bytesAwaitingCompaction, |
| ), |
| )} |
| braces={PERCENT_BRACES} |
| />{' '} |
| {' '} |
| <BracedText |
| text={formatPercent( |
| progress( |
| compactionStatus.segmentCountCompacted, |
| compactionStatus.segmentCountAwaitingCompaction, |
| ), |
| )} |
| braces={PERCENT_BRACES} |
| />{' '} |
| {' '} |
| <BracedText |
| text={formatPercent( |
| progress( |
| compactionStatus.intervalCountCompacted, |
| compactionStatus.intervalCountAwaitingCompaction, |
| ), |
| )} |
| braces={PERCENT_BRACES} |
| /> |
| </> |
| ); |
| }, |
| }, |
| { |
| Header: twoLines('Left to be', 'compacted'), |
| show: |
| capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Left to be compacted'), |
| id: 'leftToBeCompacted', |
| width: 100, |
| accessor: ({ compactionStatus }) => |
| (compactionStatus && compactionStatus.bytesAwaitingCompaction) || 0, |
| filterable: false, |
| Cell: ({ original }) => { |
| const { compactionStatus } = original; |
| |
| if (!compactionStatus) { |
| return <BracedText text="-" braces={leftToBeCompactedValues} />; |
| } |
| |
| return ( |
| <BracedText |
| text={formatLeftToBeCompacted(compactionStatus.bytesAwaitingCompaction)} |
| braces={leftToBeCompactedValues} |
| /> |
| ); |
| }, |
| }, |
| { |
| Header: 'Retention', |
| show: capabilities.hasCoordinatorAccess() && hiddenColumns.exists('Retention'), |
| id: 'retention', |
| accessor: row => row.rules.length, |
| filterable: false, |
| minWidth: 100, |
| Cell: ({ original }) => { |
| const { datasource, rules } = original; |
| return ( |
| <span |
| onClick={() => |
| this.setState({ |
| retentionDialogOpenOn: { |
| datasource, |
| rules, |
| }, |
| }) |
| } |
| className="clickable-cell" |
| > |
| {rules.length |
| ? DatasourcesView.formatRules(rules) |
| : `Cluster default: ${DatasourcesView.formatRules(defaultRules)}`} |
| |
| <ActionIcon icon={IconNames.EDIT} /> |
| </span> |
| ); |
| }, |
| }, |
| { |
| Header: ACTION_COLUMN_LABEL, |
| show: hiddenColumns.exists(ACTION_COLUMN_LABEL), |
| accessor: 'datasource', |
| id: ACTION_COLUMN_ID, |
| width: ACTION_COLUMN_WIDTH, |
| filterable: false, |
| Cell: ({ value: datasource, original }) => { |
| const { unused, rules, compactionConfig } = original; |
| const datasourceActions = this.getDatasourceActions( |
| datasource, |
| unused, |
| rules, |
| compactionConfig, |
| ); |
| return ( |
| <ActionCell |
| onDetail={() => { |
| this.setState({ |
| datasourceTableActionDialogId: datasource, |
| actions: datasourceActions, |
| }); |
| }} |
| actions={datasourceActions} |
| /> |
| ); |
| }, |
| }, |
| ]} |
| defaultPageSize={50} |
| /> |
| {this.renderUnuseAction()} |
| {this.renderUseAction()} |
| {this.renderUseUnuseActionByInterval()} |
| {this.renderKillAction()} |
| {this.renderRetentionDialog()} |
| {this.renderCompactionDialog()} |
| {this.renderForceCompactAction()} |
| </> |
| ); |
| } |
| |
| render(): JSX.Element { |
| const { capabilities } = this.props; |
| const { |
| showUnused, |
| hiddenColumns, |
| showChart, |
| chartHeight, |
| chartWidth, |
| datasourceTableActionDialogId, |
| actions, |
| } = this.state; |
| |
| return ( |
| <div |
| className={classNames('datasource-view app-view', showChart ? 'show-chart' : 'no-chart')} |
| > |
| <ViewControlBar label="Datasources"> |
| <RefreshButton |
| onRefresh={auto => { |
| this.refresh(auto); |
| }} |
| localStorageKey={LocalStorageKeys.DATASOURCES_REFRESH_RATE} |
| /> |
| {this.renderBulkDatasourceActions()} |
| <Switch |
| checked={showChart} |
| label="Show segment timeline" |
| onChange={() => this.setState({ showChart: !showChart })} |
| disabled={!capabilities.hasSqlOrCoordinatorAccess()} |
| /> |
| <Switch |
| checked={showUnused} |
| label="Show unused" |
| onChange={() => this.toggleUnused(showUnused)} |
| disabled={!capabilities.hasCoordinatorAccess()} |
| /> |
| <TableColumnSelector |
| columns={tableColumns[capabilities.getMode()]} |
| onChange={column => |
| this.setState(prevState => ({ |
| hiddenColumns: prevState.hiddenColumns.toggle(column), |
| })) |
| } |
| tableColumnsHidden={hiddenColumns.storedArray} |
| /> |
| </ViewControlBar> |
| {showChart && ( |
| <div className={'chart-container'}> |
| <SegmentTimeline |
| capabilities={capabilities} |
| chartHeight={chartHeight} |
| chartWidth={chartWidth} |
| /> |
| </div> |
| )} |
| {this.renderDatasourceTable()} |
| {datasourceTableActionDialogId && ( |
| <DatasourceTableActionDialog |
| datasourceId={datasourceTableActionDialogId} |
| actions={actions} |
| onClose={() => this.setState({ datasourceTableActionDialogId: undefined })} |
| /> |
| )} |
| </div> |
| ); |
| } |
| } |