| /* |
| * 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 { formatDistanceToNow } from 'date-fns'; |
| import React, { type ReactNode } from 'react'; |
| import type { Filter } from 'react-table'; |
| import ReactTable from 'react-table'; |
| |
| import { |
| ACTION_COLUMN_ID, |
| ACTION_COLUMN_LABEL, |
| ACTION_COLUMN_WIDTH, |
| ActionCell, |
| MoreButton, |
| RefreshButton, |
| TableClickableCell, |
| TableColumnSelector, |
| TableFilterableCell, |
| ViewControlBar, |
| } from '../../components'; |
| import { AlertDialog, AsyncActionDialog, SpecDialog, TaskTableActionDialog } from '../../dialogs'; |
| import type { QueryWithContext } from '../../druid-models'; |
| import { |
| getConsoleViewIcon, |
| TASK_CANCELED_ERROR_MESSAGES, |
| TASK_CANCELED_PREDICATE, |
| } from '../../druid-models'; |
| import type { Capabilities } from '../../helpers'; |
| import { |
| SMALL_TABLE_PAGE_SIZE, |
| SMALL_TABLE_PAGE_SIZE_OPTIONS, |
| suggestibleFilterInput, |
| } from '../../react-table'; |
| import { Api, AppToaster } from '../../singletons'; |
| import { |
| formatDuration, |
| getApiArray, |
| getDruidErrorMessage, |
| hasOverlayOpen, |
| LocalStorageBackedVisibility, |
| LocalStorageKeys, |
| oneOf, |
| queryDruidSql, |
| QueryManager, |
| QueryState, |
| } from '../../utils'; |
| import type { BasicAction } from '../../utils/basic-action'; |
| import { ExecutionDetailsDialog } from '../workbench-view/execution-details-dialog/execution-details-dialog'; |
| |
| import './tasks-view.scss'; |
| |
| const taskTableColumns: string[] = [ |
| 'Task ID', |
| 'Group ID', |
| 'Type', |
| 'Datasource', |
| 'Status', |
| 'Error', |
| 'Created time', |
| 'Duration', |
| 'Location', |
| ]; |
| |
| interface TaskQueryResultRow { |
| task_id: string; |
| group_id: string; |
| type: string; |
| created_time: string; |
| datasource: string; |
| duration: number; |
| error_msg: string | null; |
| location: string | null; |
| status: string; |
| } |
| |
| export interface TasksViewProps { |
| filters: Filter[]; |
| onFiltersChange(filters: Filter[]): void; |
| openTaskDialog: boolean | undefined; |
| goToDatasource(datasource: string): void; |
| goToQuery(queryWithContext: QueryWithContext): void; |
| goToClassicBatchDataLoader(taskId?: string): void; |
| capabilities: Capabilities; |
| } |
| |
| export interface TasksViewState { |
| tasksState: QueryState<TaskQueryResultRow[]>; |
| |
| groupTasksBy?: 'group_id' | 'type' | 'datasource' | 'status'; |
| |
| killTaskId?: string; |
| |
| taskSpecDialogOpen: boolean; |
| alertErrorMsg?: string; |
| |
| taskTableActionDialogOpen?: { id: string; status: string; actions: BasicAction[] }; |
| executionDialogOpen?: string; |
| visibleColumns: LocalStorageBackedVisibility; |
| } |
| |
| function statusToColor(status: string): string { |
| switch (status) { |
| case 'RUNNING': |
| return '#2167d5'; |
| case 'WAITING': |
| return '#d5631a'; |
| case 'PENDING': |
| return '#ffbf00'; |
| case 'SUCCESS': |
| return '#57d500'; |
| case 'FAILED': |
| return '#d5100a'; |
| case 'CANCELED': |
| return '#858585'; |
| default: |
| return '#0a1500'; |
| } |
| } |
| |
| export class TasksView extends React.PureComponent<TasksViewProps, TasksViewState> { |
| private readonly taskQueryManager: QueryManager<Capabilities, TaskQueryResultRow[]>; |
| static statusRanking: Record<string, number> = { |
| RUNNING: 4, |
| PENDING: 3, |
| WAITING: 2, |
| SUCCESS: 1, |
| FAILED: 1, |
| }; |
| |
| static TASK_SQL = `WITH tasks AS (SELECT |
| "task_id", "group_id", "type", "datasource", "created_time", "location", "duration", "error_msg", |
| CASE WHEN ${TASK_CANCELED_PREDICATE} THEN 'CANCELED' WHEN "status" = 'RUNNING' THEN "runner_status" ELSE "status" END AS "status" |
| FROM sys.tasks |
| ) |
| SELECT "task_id", "group_id", "type", "datasource", "created_time", "location", "duration", "error_msg", "status" |
| FROM tasks |
| ORDER BY |
| ( |
| CASE "status" |
| WHEN 'RUNNING' THEN 4 |
| WHEN 'PENDING' THEN 3 |
| WHEN 'WAITING' THEN 2 |
| ELSE 1 |
| END |
| ) DESC, |
| "created_time" DESC`; |
| |
| constructor(props: TasksViewProps) { |
| super(props); |
| |
| this.state = { |
| tasksState: QueryState.INIT, |
| |
| taskSpecDialogOpen: Boolean(props.openTaskDialog), |
| |
| visibleColumns: new LocalStorageBackedVisibility( |
| LocalStorageKeys.TASK_TABLE_COLUMN_SELECTION, |
| ['Error'], |
| ), |
| }; |
| |
| this.taskQueryManager = new QueryManager({ |
| processQuery: async (capabilities, signal) => { |
| if (capabilities.hasSql()) { |
| return await queryDruidSql( |
| { |
| query: TasksView.TASK_SQL, |
| }, |
| signal, |
| ); |
| } else if (capabilities.hasOverlordAccess()) { |
| return (await getApiArray(`/druid/indexer/v1/tasks`, signal)).map(d => { |
| return { |
| task_id: d.id, |
| group_id: d.groupId, |
| type: d.type, |
| created_time: d.createdTime, |
| datasource: d.dataSource, |
| duration: d.duration ? d.duration : 0, |
| error_msg: d.errorMsg, |
| location: d.location.host ? `${d.location.host}:${d.location.port}` : null, |
| status: d.statusCode === 'RUNNING' ? d.runnerStatusCode : d.statusCode, |
| }; |
| }); |
| } else { |
| throw new Error(`must have SQL or overlord access`); |
| } |
| }, |
| onStateChange: tasksState => { |
| this.setState({ |
| tasksState, |
| }); |
| }, |
| }); |
| } |
| |
| componentDidMount(): void { |
| const { capabilities } = this.props; |
| |
| this.taskQueryManager.runQuery(capabilities); |
| } |
| |
| componentWillUnmount(): void { |
| this.taskQueryManager.terminate(); |
| } |
| |
| private readonly closeSpecDialogs = () => { |
| this.setState({ |
| taskSpecDialogOpen: false, |
| }); |
| }; |
| |
| private readonly submitTask = async (spec: JSON) => { |
| try { |
| await Api.instance.post('/druid/indexer/v1/task', spec); |
| } catch (e) { |
| AppToaster.show({ |
| message: `Failed to submit task: ${getDruidErrorMessage(e)}`, |
| intent: Intent.DANGER, |
| }); |
| return; |
| } |
| |
| AppToaster.show({ |
| message: 'Task submitted successfully', |
| intent: Intent.SUCCESS, |
| }); |
| this.taskQueryManager.rerunLastQuery(); |
| }; |
| |
| private getTaskActions( |
| id: string, |
| datasource: string, |
| status: string, |
| type: string, |
| fromTable?: boolean, |
| ): BasicAction[] { |
| const { goToDatasource, goToClassicBatchDataLoader } = this.props; |
| |
| const actions: BasicAction[] = []; |
| if (fromTable) { |
| actions.push({ |
| icon: IconNames.SEARCH_TEMPLATE, |
| title: 'View raw details', |
| onAction: () => { |
| this.setState({ |
| taskTableActionDialogOpen: { |
| id, |
| status, |
| actions: this.getTaskActions(id, datasource, status, type), |
| }, |
| }); |
| }, |
| }); |
| } |
| if (datasource && status === 'SUCCESS') { |
| actions.push({ |
| icon: IconNames.MULTI_SELECT, |
| title: 'Go to datasource', |
| onAction: () => goToDatasource(datasource), |
| }); |
| } |
| if (oneOf(type, 'index', 'index_parallel')) { |
| actions.push({ |
| icon: IconNames.CLOUD_UPLOAD, |
| title: 'Open in data loader', |
| onAction: () => goToClassicBatchDataLoader(id), |
| }); |
| } |
| if (oneOf(status, 'RUNNING', 'WAITING', 'PENDING')) { |
| actions.push({ |
| icon: IconNames.CROSS, |
| title: 'Kill', |
| intent: Intent.DANGER, |
| onAction: () => this.setState({ killTaskId: id }), |
| }); |
| } |
| return actions; |
| } |
| |
| renderKillTaskAction() { |
| const { killTaskId } = this.state; |
| if (!killTaskId) return; |
| |
| return ( |
| <AsyncActionDialog |
| action={async () => { |
| const resp = await Api.instance.post( |
| `/druid/indexer/v1/task/${Api.encodePath(killTaskId)}/shutdown`, |
| {}, |
| ); |
| return resp.data; |
| }} |
| confirmButtonText="Kill task" |
| successText="Task was killed" |
| failText="Could not kill task" |
| intent={Intent.DANGER} |
| onClose={() => { |
| this.setState({ killTaskId: undefined }); |
| }} |
| onSuccess={() => { |
| this.taskQueryManager.rerunLastQuery(); |
| }} |
| > |
| <p> |
| Are you sure you want to kill task <Tag minimal>{killTaskId}</Tag>? |
| </p> |
| </AsyncActionDialog> |
| ); |
| } |
| |
| private renderTaskFilterableCell( |
| field: string, |
| enableComparisons = false, |
| valueFn: (value: string) => ReactNode = String, |
| ) { |
| const { filters, onFiltersChange } = this.props; |
| |
| return function TaskFilterableCell(row: { value: any }) { |
| return ( |
| <TableFilterableCell |
| field={field} |
| value={row.value} |
| filters={filters} |
| onFiltersChange={onFiltersChange} |
| enableComparisons={enableComparisons} |
| > |
| {valueFn(row.value)} |
| </TableFilterableCell> |
| ); |
| }; |
| } |
| |
| private onTaskDetail(task: TaskQueryResultRow) { |
| if (task.type === 'query_controller') { |
| this.setState({ |
| executionDialogOpen: task.task_id, |
| }); |
| } else { |
| this.setState({ |
| taskTableActionDialogOpen: { |
| id: task.task_id, |
| status: task.status, |
| actions: this.getTaskActions(task.task_id, task.datasource, task.status, task.type), |
| }, |
| }); |
| } |
| } |
| |
| private renderTaskTable() { |
| const { filters, onFiltersChange } = this.props; |
| const { tasksState, groupTasksBy, visibleColumns } = this.state; |
| |
| const tasks = tasksState.data || []; |
| return ( |
| <ReactTable |
| data={tasks} |
| loading={tasksState.loading} |
| noDataText={tasksState.isEmpty() ? 'No tasks' : tasksState.getErrorMessage() || ''} |
| filterable |
| filtered={filters} |
| onFilteredChange={onFiltersChange} |
| defaultSorted={[{ id: 'status', desc: true }]} |
| pivotBy={groupTasksBy ? [groupTasksBy] : []} |
| defaultPageSize={SMALL_TABLE_PAGE_SIZE} |
| pageSizeOptions={SMALL_TABLE_PAGE_SIZE_OPTIONS} |
| showPagination={tasks.length > SMALL_TABLE_PAGE_SIZE} |
| columns={[ |
| { |
| Header: 'Task ID', |
| accessor: 'task_id', |
| width: 440, |
| Cell: ({ value, original }) => ( |
| <TableClickableCell |
| tooltip="Show detail" |
| onClick={() => this.onTaskDetail(original)} |
| hoverIcon={IconNames.SEARCH_TEMPLATE} |
| > |
| {value} |
| </TableClickableCell> |
| ), |
| Aggregated: () => '', |
| show: visibleColumns.shown('Task ID'), |
| }, |
| { |
| Header: 'Group ID', |
| accessor: 'group_id', |
| width: 300, |
| Cell: this.renderTaskFilterableCell('group_id'), |
| Aggregated: () => '', |
| show: visibleColumns.shown('Group ID'), |
| }, |
| { |
| Header: 'Type', |
| accessor: 'type', |
| width: 140, |
| Cell: this.renderTaskFilterableCell('type'), |
| show: visibleColumns.shown('Type'), |
| }, |
| { |
| Header: 'Datasource', |
| accessor: 'datasource', |
| width: 200, |
| Cell: this.renderTaskFilterableCell('datasource'), |
| show: visibleColumns.shown('Datasource'), |
| }, |
| { |
| Header: 'Status', |
| id: 'status', |
| width: 110, |
| Filter: suggestibleFilterInput([ |
| 'CANCELED', |
| 'FAILED', |
| 'PENDING', |
| 'RUNNING', |
| 'SUCCESS', |
| 'WAITING', |
| ]), |
| accessor: row => ({ |
| status: row.status, |
| created_time: row.created_time, |
| toString: () => row.status, |
| }), |
| Cell: row => { |
| if (row.aggregated) return ''; |
| const { status, error_msg } = row.original; |
| const errorMsg = |
| error_msg && !TASK_CANCELED_ERROR_MESSAGES.includes(error_msg) |
| ? error_msg |
| : undefined; |
| return ( |
| <TableFilterableCell |
| field="status" |
| value={status} |
| filters={filters} |
| onFiltersChange={onFiltersChange} |
| > |
| <span data-tooltip={errorMsg}> |
| <span style={{ color: statusToColor(status) }}>● </span> |
| {status} |
| {errorMsg && ( |
| <a onClick={() => this.setState({ alertErrorMsg: errorMsg })}> ?</a> |
| )} |
| </span> |
| </TableFilterableCell> |
| ); |
| }, |
| sortMethod: (d1, d2) => { |
| const typeofD1 = typeof d1; |
| const typeofD2 = typeof d2; |
| if (typeofD1 !== typeofD2) return 0; |
| switch (typeofD1) { |
| case 'string': |
| return TasksView.statusRanking[d1] - TasksView.statusRanking[d2]; |
| |
| case 'object': |
| return ( |
| TasksView.statusRanking[d1.status] - TasksView.statusRanking[d2.status] || |
| d1.created_time.localeCompare(d2.created_time) |
| ); |
| |
| default: |
| return 0; |
| } |
| }, |
| show: visibleColumns.shown('Status'), |
| }, |
| { |
| Header: 'Error', |
| id: 'error', |
| accessor: row => row.error_msg || '', |
| width: 300, |
| Cell: this.renderTaskFilterableCell('error'), |
| Aggregated: () => '', |
| show: visibleColumns.shown('Error'), |
| }, |
| { |
| Header: 'Created time', |
| accessor: 'created_time', |
| width: 190, |
| Cell: this.renderTaskFilterableCell('created_time', true, value => { |
| const valueAsDate = new Date(value); |
| return isNaN(valueAsDate.valueOf()) ? ( |
| String(value) |
| ) : ( |
| <span data-tooltip={formatDistanceToNow(valueAsDate, { addSuffix: true })}> |
| {value} |
| </span> |
| ); |
| }), |
| Aggregated: () => '', |
| show: visibleColumns.shown('Created time'), |
| }, |
| { |
| Header: 'Duration', |
| accessor: 'duration', |
| width: 80, |
| filterable: false, |
| className: 'padded', |
| Cell({ value, original, aggregated }) { |
| if (aggregated) return ''; |
| if (value > 0) { |
| const shownDuration = formatDuration(value); |
| |
| const start = new Date(original.created_time); |
| if (isNaN(start.valueOf())) return shownDuration; |
| |
| const end = new Date(start.valueOf() + value); |
| return ( |
| <span |
| data-tooltip={`End time: ${end.toISOString()}\n(${formatDistanceToNow(end, { |
| addSuffix: true, |
| })})`} |
| > |
| {shownDuration} |
| </span> |
| ); |
| } |
| if (oneOf(original.status, 'RUNNING', 'PENDING') && original.created_time) { |
| // Compute running duration from the created time if it exists |
| return formatDuration(Date.now() - Date.parse(original.created_time)); |
| } |
| return ''; |
| }, |
| Aggregated: () => '', |
| show: visibleColumns.shown('Duration'), |
| }, |
| { |
| Header: 'Location', |
| accessor: 'location', |
| width: 200, |
| Cell: this.renderTaskFilterableCell('location'), |
| Aggregated: () => '', |
| show: visibleColumns.shown('Location'), |
| }, |
| { |
| Header: ACTION_COLUMN_LABEL, |
| id: ACTION_COLUMN_ID, |
| accessor: 'task_id', |
| width: ACTION_COLUMN_WIDTH, |
| filterable: false, |
| sortable: false, |
| Cell: row => { |
| if (row.aggregated) return ''; |
| const id = row.value; |
| const type = row.row.type; |
| const { datasource, status } = row.original; |
| return ( |
| <ActionCell |
| onDetail={() => this.onTaskDetail(row.original)} |
| actions={this.getTaskActions(id, datasource, status, type, true)} |
| menuTitle={id} |
| /> |
| ); |
| }, |
| Aggregated: () => '', |
| }, |
| ]} |
| /> |
| ); |
| } |
| |
| renderBulkTasksActions() { |
| const { goToQuery, capabilities } = this.props; |
| |
| return ( |
| <MoreButton> |
| {capabilities.hasSql() && ( |
| <MenuItem |
| icon={getConsoleViewIcon('workbench')} |
| text="View SQL query for table" |
| onClick={() => goToQuery({ queryString: TasksView.TASK_SQL })} |
| /> |
| )} |
| <MenuItem |
| icon={IconNames.MANUALLY_ENTERED_DATA} |
| text="Submit JSON task" |
| onClick={() => this.setState({ taskSpecDialogOpen: true })} |
| /> |
| </MoreButton> |
| ); |
| } |
| |
| render() { |
| const { onFiltersChange } = this.props; |
| const { |
| groupTasksBy, |
| taskSpecDialogOpen, |
| executionDialogOpen, |
| alertErrorMsg, |
| taskTableActionDialogOpen, |
| visibleColumns, |
| } = this.state; |
| |
| return ( |
| <div className="tasks-view app-view"> |
| <ViewControlBar label="Tasks"> |
| <Label>Group by</Label> |
| <ButtonGroup> |
| <Button |
| active={!groupTasksBy} |
| onClick={() => this.setState({ groupTasksBy: undefined })} |
| > |
| None |
| </Button> |
| <Button |
| active={groupTasksBy === 'group_id'} |
| onClick={() => this.setState({ groupTasksBy: 'group_id' })} |
| > |
| Group ID |
| </Button> |
| <Button |
| active={groupTasksBy === 'type'} |
| onClick={() => this.setState({ groupTasksBy: 'type' })} |
| > |
| Type |
| </Button> |
| <Button |
| active={groupTasksBy === 'datasource'} |
| onClick={() => this.setState({ groupTasksBy: 'datasource' })} |
| > |
| Datasource |
| </Button> |
| <Button |
| active={groupTasksBy === 'status'} |
| onClick={() => this.setState({ groupTasksBy: 'status' })} |
| > |
| Status |
| </Button> |
| </ButtonGroup> |
| <RefreshButton |
| localStorageKey={LocalStorageKeys.TASKS_REFRESH_RATE} |
| onRefresh={auto => { |
| if (auto && hasOverlayOpen()) return; |
| this.taskQueryManager.rerunLastQuery(auto); |
| }} |
| /> |
| {this.renderBulkTasksActions()} |
| <TableColumnSelector |
| columns={taskTableColumns} |
| onChange={column => |
| this.setState(prevState => ({ |
| visibleColumns: prevState.visibleColumns.toggle(column), |
| })) |
| } |
| tableColumnsHidden={visibleColumns.getHiddenColumns()} |
| /> |
| </ViewControlBar> |
| {this.renderTaskTable()} |
| |
| {this.renderKillTaskAction()} |
| {taskSpecDialogOpen && ( |
| <SpecDialog |
| onClose={this.closeSpecDialogs} |
| onSubmit={this.submitTask} |
| title="Submit task" |
| /> |
| )} |
| <AlertDialog |
| icon={IconNames.ERROR} |
| intent={Intent.PRIMARY} |
| isOpen={Boolean(alertErrorMsg)} |
| confirmButtonText="OK" |
| onConfirm={() => this.setState({ alertErrorMsg: undefined })} |
| > |
| <p>{alertErrorMsg}</p> |
| </AlertDialog> |
| {taskTableActionDialogOpen && ( |
| <TaskTableActionDialog |
| taskId={taskTableActionDialogOpen.id} |
| status={taskTableActionDialogOpen.status} |
| actions={taskTableActionDialogOpen.actions} |
| onClose={() => this.setState({ taskTableActionDialogOpen: undefined })} |
| /> |
| )} |
| {executionDialogOpen && ( |
| <ExecutionDetailsDialog |
| id={executionDialogOpen} |
| goToTask={taskId => { |
| onFiltersChange([{ id: 'task_id', value: `=${taskId}` }]); |
| this.setState({ executionDialogOpen: undefined }); |
| }} |
| onClose={() => this.setState({ executionDialogOpen: undefined })} |
| /> |
| )} |
| </div> |
| ); |
| } |
| } |