diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 8c87c09706f5..98cd3d23cb63 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -30,6 +30,7 @@ "d3-shape": "^3.2.0", "d3-time-format": "^4.1.0", "date-fns": "^2.28.0", + "dayjs": "^1.11.15", "druid-query-toolkit": "^1.2.0", "echarts": "^5.5.1", "file-saver": "^2.0.5", @@ -6978,6 +6979,12 @@ "date-fns": "2.x" } }, + "node_modules/dayjs": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==", + "license": "MIT" + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -23482,6 +23489,11 @@ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==" }, + "dayjs": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==" + }, "debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", diff --git a/web-console/package.json b/web-console/package.json index e7152234610e..97008f824ae5 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -72,6 +72,7 @@ "d3-shape": "^3.2.0", "d3-time-format": "^4.1.0", "date-fns": "^2.28.0", + "dayjs": "^1.11.15", "druid-query-toolkit": "^1.2.0", "echarts": "^5.5.1", "file-saver": "^2.0.5", diff --git a/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap b/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap index af55b025da42..49866cc08391 100644 --- a/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap +++ b/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap @@ -274,6 +274,16 @@ exports[`HeaderBar matches snapshot 1`] = ` shouldDismissPopover={true} text="Compaction dynamic config" /> + setCompactionDynamicConfigDialogOpen(true)} disabled={!capabilities.hasCoordinatorAccess()} /> + setWebConsoleConfigDialogOpen(true)} + /> setCompactionDynamicConfigDialogOpen(false)} /> )} + {webConsoleConfigDialogOpen && ( + setWebConsoleConfigDialogOpen(false)} /> + )} ); }); diff --git a/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx b/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx index 80d9cc83d594..1e2f3094ff39 100644 --- a/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx +++ b/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx @@ -37,12 +37,14 @@ export interface TableFilterableCellProps { onFiltersChange(filters: Filter[]): void; enableComparisons?: boolean; children?: ReactNode; + displayValue?: string; } export const TableFilterableCell = React.memo(function TableFilterableCell( props: TableFilterableCellProps, ) { - const { field, value, children, filters, enableComparisons, onFiltersChange } = props; + const { field, value, children, filters, enableComparisons, onFiltersChange, displayValue } = + props; return ( onFiltersChange( addOrUpdateFilter(filters, { diff --git a/web-console/src/dialogs/web-console-config-dialog/web-console-config-dialog.tsx b/web-console/src/dialogs/web-console-config-dialog/web-console-config-dialog.tsx new file mode 100644 index 000000000000..af316e15c615 --- /dev/null +++ b/web-console/src/dialogs/web-console-config-dialog/web-console-config-dialog.tsx @@ -0,0 +1,67 @@ +/* + * 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, Classes, Dialog, Intent } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React, { useState } from 'react'; + +import { AutoForm } from '../../components'; +import { + WEB_CONSOLE_CONFIG_FIELDS, + type WebConsoleConfig, +} from '../../druid-models/web-console-config/web-console-config'; +import { DEFAULT_WEB_CONSOLE_CONFIG } from '../../druid-models/web-console-config/web-console-config.mock'; +import { AppToaster } from '../../singletons'; +import { localStorageGetJson, LocalStorageKeys, localStorageSetJson } from '../../utils'; + +export interface WebConsoleConfigDialogProps { + onClose(): void; +} + +export const WebConsoleConfigDialog = React.memo(function WebConsoleConfigDialog( + props: WebConsoleConfigDialogProps, +) { + const { onClose } = props; + const [config, setConfig] = useState( + localStorageGetJson(LocalStorageKeys.WEB_CONSOLE_CONFIGS) || DEFAULT_WEB_CONSOLE_CONFIG, + ); + + function save() { + localStorageSetJson(LocalStorageKeys.WEB_CONSOLE_CONFIGS, config); + AppToaster.show({ + message: 'Saved web console config', + intent: Intent.SUCCESS, + }); + onClose(); + location.reload(); + } + + return ( + + + Sets the local web console configuration. + + + + + + + + + ); +}); diff --git a/web-console/src/druid-models/web-console-config/web-console-config.mock.tsx b/web-console/src/druid-models/web-console-config/web-console-config.mock.tsx new file mode 100644 index 000000000000..13e65b0e2ef3 --- /dev/null +++ b/web-console/src/druid-models/web-console-config/web-console-config.mock.tsx @@ -0,0 +1,23 @@ +/* + * 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 type { WebConsoleConfig } from './web-console-config'; + +export const DEFAULT_WEB_CONSOLE_CONFIG: WebConsoleConfig = { + showLocalTime: false, +}; diff --git a/web-console/src/druid-models/web-console-config/web-console-config.tsx b/web-console/src/druid-models/web-console-config/web-console-config.tsx new file mode 100644 index 000000000000..1e6ebbf1d9f7 --- /dev/null +++ b/web-console/src/druid-models/web-console-config/web-console-config.tsx @@ -0,0 +1,37 @@ +/* + * 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 type { Field } from '../../components'; + +export interface WebConsoleConfig { + showLocalTime?: boolean; +} + +export const WEB_CONSOLE_CONFIG_FIELDS: Field[] = [ + { + name: 'showLocalTime', + type: 'boolean', + defaultValue: false, + info: ( + <> + Boolean flag for whether we show local time in the "Tasks", "Segments" + and "Services" views. + > + ), + }, +]; diff --git a/web-console/src/utils/date.ts b/web-console/src/utils/date.ts index e240932d92b4..5eaadc8c8ac2 100644 --- a/web-console/src/utils/date.ts +++ b/web-console/src/utils/date.ts @@ -19,8 +19,14 @@ import type { DateRange, NonNullDateRange } from '@blueprintjs/datetime'; import { fromDate, toTimeZone } from '@internationalized/date'; import type { Timezone } from 'chronoshift'; +import dayjs from 'dayjs'; + +import type { WebConsoleConfig } from '../druid-models/web-console-config/web-console-config'; + +import { localStorageGetJson, LocalStorageKeys } from './local-storage-keys'; const CURRENT_YEAR = new Date().getUTCFullYear(); +export const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; export function isNonNullRange(range: DateRange): range is NonNullDateRange { return range[0] != null && range[1] != null; @@ -94,3 +100,11 @@ export function maxDate(a: Date, b: Date): Date { export function minDate(a: Date, b: Date): Date { return a < b ? a : b; } + +export function formatDate(value: string) { + const webConsoleConfig: WebConsoleConfig | undefined = localStorageGetJson( + LocalStorageKeys.WEB_CONSOLE_CONFIGS, + ); + const showLocalTime = webConsoleConfig?.showLocalTime; + return showLocalTime ? dayjs(value).format(DATE_FORMAT) : dayjs(value).toISOString(); +} diff --git a/web-console/src/utils/local-storage-keys.tsx b/web-console/src/utils/local-storage-keys.tsx index 9181e4be0d31..de1118c7ae19 100644 --- a/web-console/src/utils/local-storage-keys.tsx +++ b/web-console/src/utils/local-storage-keys.tsx @@ -61,6 +61,8 @@ export const LocalStorageKeys = { EXPLORE_STATE: 'explore-state' as const, EXPLORE_STICKY: 'explore-sticky' as const, + + WEB_CONSOLE_CONFIGS: 'web-console-configs' as const, }; export type LocalStorageKeys = (typeof LocalStorageKeys)[keyof typeof LocalStorageKeys]; diff --git a/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap b/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap index 0e6b64e844d5..b30c6bd62263 100755 --- a/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap +++ b/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap @@ -178,7 +178,7 @@ exports[`SegmentsView matches snapshot 1`] = ` "filterable": true, "headerClassName": "enable-comparisons", "show": true, - "width": 180, + "width": 220, }, { "Cell": [Function], @@ -188,7 +188,7 @@ exports[`SegmentsView matches snapshot 1`] = ` "filterable": true, "headerClassName": "enable-comparisons", "show": true, - "width": 180, + "width": 220, }, { "Cell": [Function], diff --git a/web-console/src/views/segments-view/segments-view.tsx b/web-console/src/views/segments-view/segments-view.tsx index 61ff034e7bbd..8690023a844f 100644 --- a/web-console/src/views/segments-view/segments-view.tsx +++ b/web-console/src/views/segments-view/segments-view.tsx @@ -18,6 +18,7 @@ import { Button, ButtonGroup, Intent, Label, MenuItem, Switch, Tag } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; +import dayjs from 'dayjs'; import { C, L, SqlComparison, SqlExpression } from 'druid-query-toolkit'; import * as JSONBig from 'json-bigint-native'; import type { ReactNode } from 'react'; @@ -50,6 +51,7 @@ import type { Capabilities, CapabilitiesMode } from '../../helpers'; import { booleanCustomTableFilter, BooleanFilterInput, + combineModeAndNeedle, parseFilterModeAndNeedle, sqlQueryCustomTableFilter, STANDARD_TABLE_PAGE_SIZE, @@ -65,6 +67,7 @@ import { filterMap, findMap, formatBytes, + formatDate, formatInteger, getApiArray, hasOverlayOpen, @@ -158,6 +161,20 @@ function formatRangeDimensionValue(dimension: any, value: any): string { function segmentFiltersToExpression(filters: Filter[]): SqlExpression { return SqlExpression.and( ...filterMap(filters, filter => { + if (filter.id === 'start' || filter.id === 'end') { + // Dates need to be converted to ISO string for the SQL query + const modeAndNeedle = parseFilterModeAndNeedle(filter); + if (!modeAndNeedle) return; + if (modeAndNeedle.mode === '~') { + return sqlQueryCustomTableFilter(filter); + } + const internalFilter = { ...filter }; + const formattedDate = formatDate(modeAndNeedle.needle); + const filterDate = dayjs(formattedDate).toISOString(); + filter.value = combineModeAndNeedle(modeAndNeedle.mode, formattedDate); + internalFilter.value = combineModeAndNeedle(modeAndNeedle.mode, filterDate); + return sqlQueryCustomTableFilter(internalFilter); + } if (filter.id === 'shard_type') { // Special handling for shard_type that needs to be searched for in the shard_spec // Creates filters like `shard_spec LIKE '%"type":"numbered"%'` @@ -570,7 +587,8 @@ export class SegmentsView extends React.PureComponent ReactNode = String, + displayFn: (value: string) => ReactNode = String, + filterDisplayFn: (value: string) => string = String, ) { const { filters } = this.props; const { handleFilterChange } = this; @@ -583,8 +601,9 @@ export class SegmentsView extends React.PureComponent - {valueFn(row.value)} + {displayFn(row.value)} ); }; @@ -695,20 +714,20 @@ export class SegmentsView extends React.PureComponent computeSegmentTimeSpan(start, end), + accessor: ({ start, end }) => + computeSegmentTimeSpan(dayjs(start).toISOString(), dayjs(end).toISOString()), width: 100, sortable: false, filterable: false, diff --git a/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap b/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap index 329a7fd3562a..1aa0506f8f6a 100644 --- a/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap +++ b/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap @@ -205,8 +205,10 @@ exports[`ServicesView renders data 1`] = ` "Cell": [Function], "Header": "Start time", "accessor": "start_time", + "filterMethod": [Function], + "id": "start_time", "show": true, - "width": 200, + "width": 220, }, { "Aggregated": [Function], diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index ea9c5653e9dc..4891ba248718 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -41,6 +41,9 @@ import type { QueryWithContext } from '../../druid-models'; import { getConsoleViewIcon } from '../../druid-models'; import type { Capabilities, CapabilitiesMode } from '../../helpers'; import { + booleanCustomTableFilter, + combineModeAndNeedle, + parseFilterModeAndNeedle, STANDARD_TABLE_PAGE_SIZE, STANDARD_TABLE_PAGE_SIZE_OPTIONS, suggestibleFilterInput, @@ -53,6 +56,7 @@ import { filterMap, formatBytes, formatBytesCompact, + formatDate, formatDurationWithMsIfNeeded, getApiArray, hasOverlayOpen, @@ -61,7 +65,6 @@ import { lookupBy, oneOf, pluralIfNeeded, - prettyFormatIsoDateWithMsIfNeeded, queryDruidSql, QueryManager, QueryState, @@ -198,6 +201,11 @@ function aggregateLoadQueueInfos(loadQueueInfos: LoadQueueInfo[]): LoadQueueInfo }; } +function defaultDisplayFn(value: any): string { + if (value === undefined || value === null) return ''; + return String(value); +} + interface WorkerInfo { readonly availabilityGroups: string[]; readonly blacklistedUntil: string | null; @@ -380,7 +388,10 @@ ORDER BY this.serviceQueryManager.runQuery({ capabilities, visibleColumns }); }; - private renderFilterableCell(field: string) { + private renderFilterableCell( + field: string, + displayFn: (value: string) => string = defaultDisplayFn, + ) { const { filters, onFiltersChange } = this.props; return function FilterableCell(row: { value: any }) { @@ -390,7 +401,10 @@ ORDER BY value={row.value} filters={filters} onFiltersChange={onFiltersChange} - /> + displayValue={displayFn(row.value)} + > + {displayFn(row.value)} + ); }; } @@ -434,6 +448,7 @@ ORDER BY workerInfoLookup: Record, ): Column[] => { const { capabilities } = this.props; + return [ { Header: 'Service', @@ -612,9 +627,21 @@ ORDER BY Header: 'Start time', show: visibleColumns.shown('Start time'), accessor: 'start_time', - width: 200, - Cell: this.renderFilterableCell('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: 'Detail', @@ -639,17 +666,11 @@ ORDER BY const details: string[] = []; if (workerInfo.lastCompletedTaskTime) { details.push( - `Last completed task: ${prettyFormatIsoDateWithMsIfNeeded( - workerInfo.lastCompletedTaskTime, - )}`, + `Last completed task: ${formatDate(workerInfo.lastCompletedTaskTime)}`, ); } if (workerInfo.blacklistedUntil) { - details.push( - `Blacklisted until: ${prettyFormatIsoDateWithMsIfNeeded( - workerInfo.blacklistedUntil, - )}`, - ); + details.push(`Blacklisted until: ${formatDate(workerInfo.blacklistedUntil)}`); } return details.join(' ') || null; } diff --git a/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap b/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap index 6fc2a4d3386a..87744c0ac833 100644 --- a/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap +++ b/web-console/src/views/tasks-view/__snapshots__/tasks-view.spec.tsx.snap @@ -201,8 +201,9 @@ exports[`TasksView matches snapshot 1`] = ` "Cell": [Function], "Header": "Created time", "accessor": "created_time", + "filterMethod": [Function], "show": true, - "width": 190, + "width": 220, }, { "Aggregated": [Function], diff --git a/web-console/src/views/tasks-view/tasks-view.tsx b/web-console/src/views/tasks-view/tasks-view.tsx index bc32ba8c4a4c..543a746d51ee 100644 --- a/web-console/src/views/tasks-view/tasks-view.tsx +++ b/web-console/src/views/tasks-view/tasks-view.tsx @@ -18,7 +18,8 @@ import { Button, ButtonGroup, Intent, Label, MenuItem, Tag } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { formatDistanceToNow } from 'date-fns'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; import React, { type ReactNode } from 'react'; import type { Filter } from 'react-table'; import ReactTable from 'react-table'; @@ -44,12 +45,17 @@ import { } from '../../druid-models'; import type { Capabilities } from '../../helpers'; import { + booleanCustomTableFilter, + combineModeAndNeedle, + parseFilterModeAndNeedle, SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS, suggestibleFilterInput, } from '../../react-table'; import { Api, AppToaster } from '../../singletons'; import { + DATE_FORMAT, + formatDate, formatDuration, getApiArray, getDruidErrorMessage, @@ -66,6 +72,8 @@ import { ExecutionDetailsDialog } from '../workbench-view/execution-details-dial import './tasks-view.scss'; +dayjs.extend(relativeTime); + const taskTableColumns: string[] = [ 'Task ID', 'Group ID', @@ -329,7 +337,8 @@ ORDER BY private renderTaskFilterableCell( field: string, enableComparisons = false, - valueFn: (value: string) => ReactNode = String, + displayFn: (value: string) => ReactNode = String, + filterDisplayFn: (value: string) => string = String, ) { const { filters, onFiltersChange } = this.props; @@ -341,8 +350,9 @@ ORDER BY filters={filters} onFiltersChange={onFiltersChange} enableComparisons={enableComparisons} + displayValue={filterDisplayFn(row.value)} > - {valueFn(row.value)} + {displayFn(row.value)} ); }; @@ -494,19 +504,33 @@ ORDER BY { 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) - ) : ( - - {value} - - ); - }), + width: 220, + Cell: this.renderTaskFilterableCell( + 'created_time', + true, + value => { + const day = dayjs(value); + return day.isValid() ? ( + {formatDate(value)} + ) : ( + String(value) + ); + }, + formatDate, + ), Aggregated: () => '', show: visibleColumns.shown('Created time'), + filterMethod: (filter: Filter, row: TaskQueryResultRow) => { + const modeAndNeedle = parseFilterModeAndNeedle(filter); + if (!modeAndNeedle) return true; + const parsedRowDate = formatDate(row.created_time); + if (modeAndNeedle.mode === '~') { + return booleanCustomTableFilter(filter, parsedRowDate); + } + const parsedFilterDate = formatDate(modeAndNeedle.needle); + filter.value = combineModeAndNeedle(modeAndNeedle.mode, parsedFilterDate); + return booleanCustomTableFilter(filter, parsedRowDate); + }, }, { Header: 'Duration', @@ -519,16 +543,12 @@ ORDER BY if (value > 0) { const shownDuration = formatDuration(value); - const start = new Date(original.created_time); - if (isNaN(start.valueOf())) return shownDuration; + const start = dayjs(original.created_time); + if (!start.isValid()) return shownDuration; - const end = new Date(start.valueOf() + value); + const end = start.add(value, 'ms'); return ( - + {shownDuration} );
Sets the local web console configuration.