diff --git a/packages/ui/src/ui/components/Dialog/Dialog.tsx b/packages/ui/src/ui/components/Dialog/Dialog.tsx index e663744ea..7b01ca0ff 100644 --- a/packages/ui/src/ui/components/Dialog/Dialog.tsx +++ b/packages/ui/src/ui/components/Dialog/Dialog.tsx @@ -55,6 +55,7 @@ import {DatePickerControl} from './controls/DatePickerControl/DatePickerControl' import {RangeInputPickerControl} from './controls/RangeInputPickerControl/RangeInputPickerControl'; import {AclColumnsControl} from '../../containers/ACL/RequestPermissions/AclColumnsControl/AclColumnsControl'; import {useHotkeysScope} from '../../hooks/use-hotkeysjs-scope'; +import ExportsEditTabField from '../../pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField'; const block = cn('yt-dialog'); @@ -204,10 +205,12 @@ export type DialogField = >; registerDialogTabControl('yt-create-table-tab', CreateTableTabField); +registerDialogTabControl('yt-create-queue-export-tab', ExportsEditTabField); export type DialogTabField = | DFDialogTabField - | RegisteredDialogTabField<'yt-create-table-tab', any, FieldT>; + | RegisteredDialogTabField<'yt-create-table-tab', any, FieldT> + | RegisteredDialogTabField<'yt-create-queue-export-tab', any, FieldT>; export type YTDialogType = typeof YTDialog; export function YTDialog>( diff --git a/packages/ui/src/ui/constants/navigation/index.ts b/packages/ui/src/ui/constants/navigation/index.ts index ac622b0eb..db4df90e5 100644 --- a/packages/ui/src/ui/constants/navigation/index.ts +++ b/packages/ui/src/ui/constants/navigation/index.ts @@ -19,6 +19,7 @@ export const Tab = { ANNOTATION: 'annotation', ACCESS_LOG: 'access_log', MOUNT_CONFIG: 'mount_config', + ORIGINATING_QUEUE: 'originating_queue', } as const; export const ContentMode = { @@ -34,6 +35,7 @@ export const SELECT_ALL = PREFIX + 'SELECT_ALL'; export const SET_CONTENT_MODE = PREFIX + 'SET_CONTENT_MODE'; export const SET_TEXT_FILTER = PREFIX + 'SET_TEXT_FILTER'; export const SET_TRANSACTION = PREFIX + 'SET_TRANSACTION'; +export const SET_ORIGINATING_QUEUE_PATH = PREFIX + 'SET_ORIGINATING_QUEUE_PATH'; export const CLEAR_TRANSACTION = PREFIX + 'CLEAR_TRANSACTION'; export const UPDATE_PATH = PREFIX + 'UPDATE_PATH'; export const UPDATE_VIEW = createActionTypes(`${PREFIX}UPDATE_VIEW`); diff --git a/packages/ui/src/ui/constants/navigation/tabs/queue.ts b/packages/ui/src/ui/constants/navigation/tabs/queue.ts index 89ea81617..5806ab352 100644 --- a/packages/ui/src/ui/constants/navigation/tabs/queue.ts +++ b/packages/ui/src/ui/constants/navigation/tabs/queue.ts @@ -15,6 +15,7 @@ export enum QUEUE_MODE { METRICS = 'metrics', PARTITIONS = 'partitions', CONSUMERS = 'consumers', + EXPORTS = 'exports', } // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/packages/ui/src/ui/pages/navigation/Navigation/Navigation.js b/packages/ui/src/ui/pages/navigation/Navigation/Navigation.js index 754b612b0..73d66796f 100644 --- a/packages/ui/src/ui/pages/navigation/Navigation/Navigation.js +++ b/packages/ui/src/ui/pages/navigation/Navigation/Navigation.js @@ -166,7 +166,9 @@ class Navigation extends Component { onTabChange = (value) => { const {setMode} = this.props; - setMode(value); + if (value !== Tab.ORIGINATING_QUEUE) { + setMode(value); + } }; renderTabs() { diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/Queue.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/Queue.tsx index e7294b60a..efc296bbf 100644 --- a/packages/ui/src/ui/pages/navigation/tabs/Queue/Queue.tsx +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/Queue.tsx @@ -26,6 +26,7 @@ import ConsumersExtraControls from './views/Consumers/ConsumersExtraControls'; import Partitions from './views/Partitions/Partitions'; import PartitionsExtraControls from './views/Partitions/PartitionsExtraControls'; import UIFactory from '../../../../UIFactory'; +import {Exports} from './views/Exports/Exports'; const emptyView = {ExtraControls: () => null, View: () => null}; @@ -33,6 +34,7 @@ const views: Record null, View: () => null}, [QUEUE_MODE.PARTITIONS]: {ExtraControls: PartitionsExtraControls, View: Partitions}, [QUEUE_MODE.CONSUMERS]: {ExtraControls: ConsumersExtraControls, View: Consumers}, + [QUEUE_MODE.EXPORTS]: {ExtraControls: () => null, View: Exports}, }; function useViewByMode(mode: QUEUE_MODE) { diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/Toolbar/Toolbar.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/Toolbar/Toolbar.tsx index be5b4995e..197b92cdb 100644 --- a/packages/ui/src/ui/pages/navigation/tabs/Queue/Toolbar/Toolbar.tsx +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/Toolbar/Toolbar.tsx @@ -29,6 +29,10 @@ const tabItems: React.ComponentProps['items'] = [ value: QUEUE_MODE.CONSUMERS, text: 'Consumers', }, + { + value: QUEUE_MODE.EXPORTS, + text: 'Exports', + }, ]; const Toolbar: React.VFC = ({extras: Extras, queueMode, changeQueueMode}) => { diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/Exports.scss b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/Exports.scss new file mode 100644 index 000000000..bb6d1278a --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/Exports.scss @@ -0,0 +1,5 @@ +.exports { + &__configs { + width: 100%; + } +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/Exports.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/Exports.tsx new file mode 100644 index 000000000..51ecf0079 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/Exports.tsx @@ -0,0 +1,58 @@ +import React, {useEffect} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {Flex, Loader} from '@gravity-ui/uikit'; +import b from 'bem-cn-lite'; + +import {getEditJsonYsonSettings} from '../../../../../../store/selectors/thor/unipika'; +import {requestExportsConfig} from '../../../../../../store/actions/navigation/tabs/queue/exports'; +import { + getExportsConfig, + getExportsConfigRequestInfo, +} from '../../../../../../store/selectors/navigation/tabs/queue'; +import {getPath} from '../../../../../../store/selectors/navigation'; + +import Yson from '../../../../../../components/Yson/Yson'; +import {YsonDownloadButton} from '../../../../../../components/DownloadAttributesButton/YsonDownloadButton'; +import {ExportsEdit} from './ExportsEdit/ExportsEdit'; + +import './Exports.scss'; + +const block = b('exports'); + +export function Exports() { + const dispatch = useDispatch(); + + const unipikaSettings = useSelector(getEditJsonYsonSettings); + const config = useSelector(getExportsConfig); + const {loading, loaded} = useSelector(getExportsConfigRequestInfo); + const path = useSelector(getPath); + + useEffect(() => { + dispatch(requestExportsConfig()); + }, [path]); + + return ( + + {!loading && loaded ? ( + } + className={block('configs')} + /> + ) : ( + + )} + + ); +} + +function ExportsExtraTools({config, settings}: any) { + return ( + + + + + ); +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEdit.scss b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEdit.scss new file mode 100644 index 000000000..f69abea82 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEdit.scss @@ -0,0 +1,4 @@ +.exports-edit { + height: 0; + text-align: right; +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEdit.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEdit.tsx new file mode 100644 index 000000000..fcf29a34c --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEdit.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import {Button} from '@gravity-ui/uikit'; +import b from 'bem-cn-lite'; + +import Icon from '../../../../../../../components/Icon/Icon'; +import {QueueExportConfig} from '../../../../../../../types/navigation/queue/queue'; + +import {ExportsEditDialog} from './ExportsEditDialog/ExportsEditDialog'; + +import './ExportsEdit.scss'; + +const block = b('exports-edit'); + +type ExportConfigUtility = { + id: string; + name: string; +}; + +export type ExportConfig = ExportConfigUtility & QueueExportConfig<{value: number}>; + +export function ExportsEdit() { + const [visible, setVisible] = React.useState(false); + + const toggleVisibility = React.useCallback(() => { + setVisible(!visible); + }, [visible, setVisible]); + + return ( + <> +
+ +
+ + + ); +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditDialog.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditDialog.tsx new file mode 100644 index 000000000..a161ef854 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditDialog.tsx @@ -0,0 +1,78 @@ +import React, {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {getExportsConfig} from '../../../../../../../../store/selectors/navigation/tabs/queue'; +import {updateExportsConfig} from '../../../../../../../../store/actions/navigation/tabs/queue/exports'; + +import {FormApi, YTDFDialog} from '../../../../../../../../components/Dialog'; + +import {renderControls} from './TabControls'; +import {ExportConfig} from '../ExportsEdit'; +import { + fields, + prepareInitialValues, + prepareNewExport, + prepareUpdateValues, + validate, +} from './utils'; + +interface DialogProps { + visible: boolean; + onClose: () => void; +} + +export type FormValues = { + exports: ExportConfig[]; +}; + +export function ExportsEditDialog(props: DialogProps) { + const {visible, onClose} = props; + const configs = useSelector(getExportsConfig); + const dispatch = useDispatch(); + + const [initialValues, setInitialValues] = useState(); + const [nextId, setNextId] = useState(1); + + useEffect(() => { + setInitialValues(prepareInitialValues(configs)); + }, [configs]); + + const onCreateTab = () => { + const res = prepareNewExport(nextId); + setNextId((id) => id + 1); + return res; + }; + + const onAdd = async (form: FormApi>) => { + const {values} = form.getState(); + const preparedValues = prepareUpdateValues(values['exports']); + dispatch(updateExportsConfig(preparedValues)); + }; + + return ( + + onAdd={onAdd} + visible={visible} + onClose={onClose} + size="l" + headerProps={{ + title: 'Edit config', + }} + initialValues={initialValues} + validate={validate} + fields={[ + { + type: 'yt-create-queue-export-tab', + name: 'exports', + isRemovable: () => false, + getTitle: (values) => values.name, + onCreateTab: onCreateTab, + renderControls: renderControls, + multiple: true, + fields: fields, + }, + ]} + virtualized + /> + ); +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField.scss b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField.scss new file mode 100644 index 000000000..ca98248e6 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField.scss @@ -0,0 +1,41 @@ +.export-edit-tab-field { + display: flex; + flex-direction: column; + overflow: hidden; + width: 240px; + min-height: 450px; + + &__add-export { + flex-shrink: 0; + + display: flex; + align-items: center; + + padding: 20px 16px 10px 24px; + font-size: var(--g-text-subheader-3-font-size); + + &-label { + flex-grow: 1; + } + } + + &__exports { + flex-grow: 1; + flex-shrink: 1; + border-bottom: 1px solid var(--light-divider); + width: 225px; + &-item { + flex-grow: 1; + display: flex; + align-items: center; + overflow: hidden; + + &-node { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField.tsx new file mode 100644 index 000000000..694561344 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/ExportsEditTabField/ExportsEditTabField.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import b from 'bem-cn-lite'; +import {Button} from '@gravity-ui/uikit'; + +import Icon from '../../../../../../../../../components/Icon/Icon'; +import { + TabFieldVertical, + TabFieldVerticalProps, +} from '../../../../../../../../../components/Dialog'; + +import './ExportsEditTabField.scss'; + +const block = b('export-edit-tab-field'); + +type Props = TabFieldVerticalProps; + +export default class ExportsEditTabField extends React.Component { + static isTabControl = true as const; + static isTabControlVertical = true; + + onAddExport(active = true) { + const {onCreateTab = () => {}} = this.props; + onCreateTab('exports', active); + } + + render() { + const {activeTab, ...rest} = this.props; + + return ( +
+ + +
+ ); + } +} + +function AddExport({onAddExport}: {onAddExport: Function}) { + return ( +
+ Exports + +
+ ); +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/TabControls.tsx b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/TabControls.tsx new file mode 100644 index 000000000..85fe3ee5c --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/TabControls.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import {Button} from '@gravity-ui/uikit'; + +import Icon from '../../../../../../../../components/Icon/Icon'; + +export function renderControls( + _item: any, + _onCreate: (active?: boolean) => void, + onRemove?: () => void, +) { + return ( + + ); +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/fields.ts b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/fields.ts new file mode 100644 index 000000000..c38196133 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/fields.ts @@ -0,0 +1,41 @@ +import {validateExportDirectory, validateExportPeriod} from './validate'; + +export const fields = [ + { + name: 'name', + type: 'text' as const, + caption: 'Export name', + required: true, + }, + { + type: 'path' as const, + name: 'export_directory', + required: true, + caption: 'Export directory', + validator: validateExportDirectory, + }, + { + type: 'number' as const, + name: 'export_period', + caption: 'Export period', + required: true, + extras: { + validator: validateExportPeriod, + }, + }, + { + type: 'text' as const, + name: 'output_table_name_pattern', + caption: 'Output table name pattern', + }, + { + type: 'tumbler' as const, + name: 'use_upper_bound_for_table_names', + caption: 'Use upper bound for table names', + }, + { + type: 'time-duration' as const, + name: 'export_ttl', + caption: 'TTL', + }, +]; diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/index.ts b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/index.ts new file mode 100644 index 000000000..3776a9209 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/index.ts @@ -0,0 +1,5 @@ +import {fields} from './fields'; +import {validate} from './validate'; +import {prepareInitialValues, prepareNewExport, prepareUpdateValues} from './prepareValues'; + +export {fields, validate, prepareNewExport, prepareInitialValues, prepareUpdateValues}; diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/prepareValues.ts b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/prepareValues.ts new file mode 100644 index 000000000..97c67e8c7 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/prepareValues.ts @@ -0,0 +1,70 @@ +import isEmpty_ from 'lodash/isEmpty'; +import omitBy_ from 'lodash/omitBy'; + +import {QueueExportConfig} from '../../../../../../../../../types/navigation/queue/queue'; +import {ExportConfig} from '../../ExportsEdit'; +import {FormValues} from '../ExportsEditDialog'; + +export function prepareInitialValues( + configs: {[key: string]: QueueExportConfig} | undefined, +): {exports: ExportConfig[]} { + if (isEmpty_(configs)) { + const name = 'new_export'; + return { + exports: [ + { + id: name, + export_period: {value: 30000}, + export_directory: '//home/', + name, + }, + ], + }; + } + + const preparedConfigs: ExportConfig[] = []; + for (const config in configs) { + if (configs[config]) { + const {export_period, export_ttl, ...restConfig} = configs[config]; + const preparedConfig: ExportConfig = { + id: config, + name: config, + export_period: {value: export_period}, + export_ttl: export_ttl ? {value: export_ttl} : undefined, + ...restConfig, + }; + + preparedConfigs.push(preparedConfig); + } + } + return {exports: preparedConfigs}; +} + +export function prepareUpdateValues(configs: FormValues['exports']): { + [key: string]: QueueExportConfig; +} { + const preparedConfigs: {[key: string]: QueueExportConfig} = {}; + for (const config of configs) { + const {name, export_period, export_ttl, export_directory, ...restConfig} = config; + + const preparedConfig: QueueExportConfig = { + export_directory: export_directory, + export_period: export_period.value, + export_ttl: export_ttl?.value, + ...omitBy_(restConfig, (value) => value === ''), + }; + + preparedConfigs[name] = preparedConfig; + } + return preparedConfigs; +} + +export function prepareNewExport(id: number) { + const name = `new_export_${id}`; + return { + name: name, + id: name, + export_period: {value: 30000}, + export_directory: '//home/', + }; +} diff --git a/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/validate.ts b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/validate.ts new file mode 100644 index 000000000..7a3ace182 --- /dev/null +++ b/packages/ui/src/ui/pages/navigation/tabs/Queue/views/Exports/ExportsEdit/ExportsEditDialog/utils/validate.ts @@ -0,0 +1,63 @@ +import set_ from 'lodash/set'; +import forEach_ from 'lodash/forEach'; +import isEmpty_ from 'lodash/isEmpty'; + +import {ExportConfig} from '../../ExportsEdit'; +import {FormValues} from '../ExportsEditDialog'; +import {ytApiV3} from '../../../../../../../../../rum/rum-wrap-api'; + +export function validate(values: FormValues) { + const configs = values['exports']; + const errors = {}; + + validateTabsNoDuplicates(configs, 'name', errors); + validateTabsNoDuplicates(configs, 'export_directory', errors); + + return isEmpty_(errors) ? undefined : errors; +} + +function validateTabsNoDuplicates( + configs: FormValues['exports'], + fieldName: keyof ExportConfig, + errors: any, +) { + const counters: Record> = {}; + forEach_(configs, (config: ExportConfig, index: number) => { + const field = String(config[fieldName]); + const indexes = counters[field]; + if (!indexes) { + counters[field] = [index]; + } else { + indexes.push(index); + } + }); + + forEach_(counters, (indices) => { + if (indices.length > 1) { + indices.forEach((index) => { + set_( + errors, + `exports[${index}].${fieldName}`, + 'The export with this export directory already exists', + ); + }); + } + }); +} + +export function validateExportPeriod(value?: number) { + return !value || value % 1000 ? 'Export period should be a multiple of 1000' : undefined; +} + +export async function validateExportDirectory(path: string) { + try { + const res = await ytApiV3.exists({path}); + if (!res) { + return 'Target path should exist'; + } + return undefined; + } catch (err) { + const e = err as Error; + return e?.message || 'Unexpected type of error: ' + typeof e; + } +} diff --git a/packages/ui/src/ui/store/actions/navigation/index.js b/packages/ui/src/ui/store/actions/navigation/index.js index 4d695d27a..2de539ce1 100644 --- a/packages/ui/src/ui/store/actions/navigation/index.js +++ b/packages/ui/src/ui/store/actions/navigation/index.js @@ -36,6 +36,7 @@ import {loadTabletErrorsCount} from './tabs/tablet-errors/tablet-errors-backgrou import {isSupportedEffectiveExpiration} from '../../../store/selectors/thor/support'; import {getTabs} from '../../../store/selectors/navigation/navigation'; import UIFactory from '../../../UIFactory'; +import {fetchOriginatingQueuePath} from './tabs/queue/exports'; export function updateView(settings = {}) { return (dispatch, getState) => { @@ -145,6 +146,7 @@ export function updateView(settings = {}) { }; dispatch(fetchTableMountConfig(path)); + dispatch(fetchOriginatingQueuePath()); if (!account) { /** @@ -281,19 +283,19 @@ const attributesToLoad = [ '_read_schema', '_restore_path', '_yql_key_meta', + '_yql_op_id', '_yql_row_spec', '_yql_subkey_meta', '_yql_type', '_yql_value_meta', - '_yql_op_id', 'access_time', 'account', 'acl', 'atomicity', 'broken', - 'cluster_name', 'chunk_count', 'chunk_row_count', + 'cluster_name', 'compressed_data_size', 'compression_codec', 'compression_ratio', @@ -302,30 +304,32 @@ const attributesToLoad = [ 'default_disk_space', 'disk_space', 'dynamic', + 'effective_expiration', 'enable_dynamic_store_read', + 'erasure_codec', 'expiration_time', 'expiration_timeout', - 'effective_expiration', - 'replicated_table_options', - 'replica_path', - 'remount_needed_tablet_count', - 'erasure_codec', 'id', 'in_memory_mode', 'key', 'key_columns', + 'leader_controller_address', 'lock_count', 'lock_mode', 'locks', + 'mode', 'modification_time', 'monitoring_cluster', 'monitoring_project', - 'mode', 'optimize_for', 'owner', 'path', 'pipeline_format_version', 'primary_medium', + 'queue_static_export_destination', + 'remount_needed_tablet_count', + 'replica_path', + 'replicated_table_options', 'replication_factor', 'resource_usage', 'schema', @@ -340,12 +344,11 @@ const attributesToLoad = [ 'tablet_error_count', 'tablet_state', 'target_path', - 'type', - 'title', 'timeout', - 'uncompressed_data_size', - 'leader_controller_address', + 'title', 'treat_as_queue_consumer', + 'type', + 'uncompressed_data_size', ]; function getAttributesToLoad() { diff --git a/packages/ui/src/ui/store/actions/navigation/tabs/queue/exports.ts b/packages/ui/src/ui/store/actions/navigation/tabs/queue/exports.ts new file mode 100644 index 000000000..ae974eef6 --- /dev/null +++ b/packages/ui/src/ui/store/actions/navigation/tabs/queue/exports.ts @@ -0,0 +1,122 @@ +import {ThunkAction, UnknownAction} from '@reduxjs/toolkit'; +// @ts-ignore +import yt from '@ytsaurus/javascript-wrapper/lib/yt'; +import ypath from '../../../../../common/thor/ypath'; + +import {RootState} from '../../../../reducers'; +import {updateView} from '../..'; +import {exportsActions} from '../../../../reducers/navigation/tabs/queue/exports'; +import {getAttributes, getPath} from '../../../../selectors/navigation'; + +import {ytApiV3} from '../../../../../rum/rum-wrap-api'; +import CancelHelper from '../../../../../utils/cancel-helper'; +import {wrapApiPromiseByToaster} from '../../../../../utils/utils'; +import {SET_ORIGINATING_QUEUE_PATH} from '../../../../../constants/navigation'; +import {QueueExportConfig} from '../../../../../types/navigation/queue/queue'; + +type AsyncAction = ThunkAction; + +const cancelHelper = new CancelHelper(); + +export function requestExportsConfig(): AsyncAction { + return async (dispatch, getState) => { + const state = getState(); + const path = getPath(state); + dispatch(exportsActions.onGetConfigRequest()); + try { + const config = await ytApiV3.get({ + parameters: {attributes: ['static_export_config'], path: `${path}/@`}, + cancellation: cancelHelper.removeAllAndSave, + }); + dispatch(exportsActions.onGetConfigSuccess({config})); + } catch (error: any) { + dispatch(exportsActions.onGetConfigFailure({error})); + } + }; +} + +export function updateExportsConfig(configs: { + [key: string]: QueueExportConfig; +}): AsyncAction { + return async (dispatch, getState) => { + const state = getState(); + const path = getPath(state); + const attributes = getAttributes(state); + dispatch(exportsActions.onUpdateConfigRequest()); + + try { + const transactionId = await yt.v3.startTransaction({}); + try { + await wrapApiPromiseByToaster( + ytApiV3.set( + { + path: `${path}/@static_export_config`, + cancellation: cancelHelper.removeAllAndSave, + transaction_id: transactionId, + }, + configs, + ), + { + toasterName: 'update_static_export_config', + errorTitle: 'Failed to update config', + skipSuccessToast: true, + }, + ); + + for (const config in configs) { + if (configs[config]) { + await wrapApiPromiseByToaster( + ytApiV3.set( + { + path: `${configs[config]['export_directory']}/@queue_static_export_destination`, + cancellation: cancelHelper.removeAllAndSave, + transaction_id: transactionId, + }, + { + originating_queue_id: `${ypath.getValue(attributes, '/id')}`, + }, + ), + { + toasterName: 'update_queue_static_export_destination', + errorTitle: 'Failed to update destination map node', + skipSuccessToast: true, + }, + ); + } + } + + await yt.v3.commitTransaction({transaction_id: transactionId}); + dispatch(exportsActions.onUpdateConfigSuccess()); + dispatch(updateView()); + } catch (error: any) { + await yt.v3.abortTransaction({transaction_id: transactionId}); + dispatch(exportsActions.onUpdateConfigFailure(error)); + } + } catch (error: any) { + dispatch(exportsActions.onUpdateConfigFailure(error)); + } + }; +} + +export function fetchOriginatingQueuePath(): AsyncAction { + return async (dispatch, getState) => { + const state = getState(); + const attributes = getAttributes(state); + const queueId = ypath.getValue( + attributes, + '/queue_static_export_destination/originating_queue_id', + ); + try { + const originatingPath = await ytApiV3.get({ + path: `#${queueId}/@path`, + cancellation: cancelHelper.removeAllAndSave, + }); + dispatch({ + type: SET_ORIGINATING_QUEUE_PATH, + data: originatingPath, + }); + } catch (error: any) { + dispatch(exportsActions.onUpdateConfigFailure({error})); + } + }; +} diff --git a/packages/ui/src/ui/store/reducers/navigation/navigation.js b/packages/ui/src/ui/store/reducers/navigation/navigation.js index bc036d8d2..dc6a8384b 100644 --- a/packages/ui/src/ui/store/reducers/navigation/navigation.js +++ b/packages/ui/src/ui/store/reducers/navigation/navigation.js @@ -3,6 +3,7 @@ import {mergeStateOnClusterChange} from '../../../store/reducers/utils'; import { CLEAR_TRANSACTION, SET_MODE, + SET_ORIGINATING_QUEUE_PATH, SET_TRANSACTION, Tab, UPDATE_PATH, @@ -29,6 +30,8 @@ const ephemeralState = { isWriteable: false, isAccountUsable: false, checkPermissionsError: undefined, + /** @type {string | undefined} */ + originatingQueuePath: undefined, }; export const initialState = { @@ -47,6 +50,9 @@ const reducer = (state = initialState, action) => { case SET_TRANSACTION: return {...state, transaction: action.data}; + case SET_ORIGINATING_QUEUE_PATH: + return {...state, originatingQueuePath: action.data}; + case UPDATE_PATH: { const {path, shouldUpdateContentMode} = action.data; @@ -82,6 +88,7 @@ const reducer = (state = initialState, action) => { isWriteable, isAccountUsable, checkPermissionsError, + originatingQueuePath, } = action.data; return { ...state, @@ -92,6 +99,7 @@ const reducer = (state = initialState, action) => { isWriteable, isAccountUsable, checkPermissionsError, + originatingQueuePath, }; } diff --git a/packages/ui/src/ui/store/reducers/navigation/tabs/queue/exports.ts b/packages/ui/src/ui/store/reducers/navigation/tabs/queue/exports.ts new file mode 100644 index 000000000..27a03de75 --- /dev/null +++ b/packages/ui/src/ui/store/reducers/navigation/tabs/queue/exports.ts @@ -0,0 +1,51 @@ +import {PayloadAction, createSlice} from '@reduxjs/toolkit'; +import {YTError} from '../../../../../types'; + +export interface ExportsState { + config?: object; + loading: boolean; + loaded: boolean; + error?: YTError; +} + +const initialState: ExportsState = { + loading: false, + loaded: false, +}; + +const exportsSlice = createSlice({ + initialState, + name: 'queueExports', + reducers: { + onGetConfigRequest(state: ExportsState) { + return {...state, loading: true}; + }, + onGetConfigSuccess( + state: ExportsState, + {payload}: PayloadAction>, + ) { + return {...state, loaded: true, loading: false, ...payload}; + }, + onGetConfigFailure( + state: ExportsState, + {payload}: PayloadAction>, + ) { + return {...state, loading: false, ...payload}; + }, + onUpdateConfigRequest(state: ExportsState) { + return {...state, loading: true}; + }, + onUpdateConfigSuccess(state: ExportsState) { + return {...state, loaded: true, loading: false}; + }, + onUpdateConfigFailure( + state: ExportsState, + {payload}: PayloadAction>, + ) { + return {...state, loading: false, ...payload}; + }, + }, +}); + +export const exportsActions = exportsSlice.actions; +export const exports = exportsSlice.reducer; diff --git a/packages/ui/src/ui/store/reducers/navigation/tabs/queue/index.ts b/packages/ui/src/ui/store/reducers/navigation/tabs/queue/index.ts index f0c823a50..0c94ec15c 100644 --- a/packages/ui/src/ui/store/reducers/navigation/tabs/queue/index.ts +++ b/packages/ui/src/ui/store/reducers/navigation/tabs/queue/index.ts @@ -3,9 +3,11 @@ import {combineReducers} from 'redux'; import filters from './filters'; import partitions from './partitions'; import status from './status'; +import {exports} from './exports'; export default combineReducers({ filters, partitions, status, + exports, }); diff --git a/packages/ui/src/ui/store/selectors/navigation/navigation.ts b/packages/ui/src/ui/store/selectors/navigation/navigation.ts index 61504b158..5f8d7731b 100644 --- a/packages/ui/src/ui/store/selectors/navigation/navigation.ts +++ b/packages/ui/src/ui/store/selectors/navigation/navigation.ts @@ -18,6 +18,7 @@ import {getAccessLogBasePath} from '../../../config'; import {getTabletErrorsBackgroundCount} from '../../../store/selectors/navigation/tabs/tablet-errors-background'; import UIFactory from '../../../UIFactory'; import {getConfigData} from '../../../config/ui-settings'; +import {getCluster} from '../global'; export function getNavigationPathAttributesLoadState(state: RootState) { return state.navigation.navigation.loadState; @@ -71,9 +72,17 @@ export const getNavigationRestorePath = createSelector([getNavigationPathAttribu return ypath.getValue(attrs, '/_restore_path'); }); +export const getNavigationOriginatingQueuePath = (state: RootState) => + state.navigation.navigation.originatingQueuePath; + export const getSupportedTabs = createSelector( - [getNavigationPathAttributes, getTableMountConfigHasData, getTabletErrorsBackgroundCount], - (attributes, mountConfigHasData, tabletErrorsCount) => { + [ + getNavigationPathAttributes, + getTableMountConfigHasData, + getTabletErrorsBackgroundCount, + getNavigationOriginatingQueuePath, + ], + (attributes, mountConfigHasData, tabletErrorsCount, originatingQueuePath) => { const isDynamic = attributes.dynamic === true; const isPipeline = attributes.pipeline_format_version !== undefined; const mountConfigVisible = mountConfigHasData || isDynamic; @@ -114,6 +123,10 @@ export const getSupportedTabs = createSelector( supportedByAttribute.push(Tab.CONSUMER); } + if (originatingQueuePath) { + supportedByAttribute.push(Tab.ORIGINATING_QUEUE); + } + let supportedTabletErrors: Array> = []; if ( tabletErrorsCount > 0 || @@ -139,8 +152,14 @@ export const getSupportedTabs = createSelector( ); export const getTabs = createSelector( - [getSupportedTabs, getTabletErrorsBackgroundCount, getAttributes], - (supportedTabs, tabletErrorsCount, attributes) => { + [ + getSupportedTabs, + getTabletErrorsBackgroundCount, + getAttributes, + getNavigationOriginatingQueuePath, + getCluster, + ], + (supportedTabs, tabletErrorsCount, attributes, originatingQueuePath, cluster) => { const isACO = attributes?.type === 'access_control_object'; const tabs: { @@ -154,6 +173,9 @@ export const getTabs = createSelector( text?: string; caption?: string; counter?: number; + url?: string; + external?: boolean; + routed?: false; }[] = [ { value: Tab.CONSUMER, @@ -303,6 +325,13 @@ export const getTabs = createSelector( title: 'Go to tablets errors', counter: tabletErrorsCount > 0 ? tabletErrorsCount : undefined, }, + { + value: Tab.ORIGINATING_QUEUE, + title: 'Originating queue', + url: `${window.location.origin}/${cluster}/navigation?path=${originatingQueuePath}`, + external: true, + routed: false, + }, ]; UIFactory.getNavigationExtraTabs().forEach((extraTab) => { diff --git a/packages/ui/src/ui/store/selectors/navigation/tabs/queue.ts b/packages/ui/src/ui/store/selectors/navigation/tabs/queue.ts index 5854b07cd..df74f81b7 100644 --- a/packages/ui/src/ui/store/selectors/navigation/tabs/queue.ts +++ b/packages/ui/src/ui/store/selectors/navigation/tabs/queue.ts @@ -1,7 +1,10 @@ import {createSelector} from 'reselect'; +import ypath from '../../../../common/thor/ypath'; -import type {RootState} from '../../../../store/reducers'; -import type {YtQueueStatus} from '../../../../store/reducers/navigation/tabs/queue/types'; +import type {RootState} from '../../../reducers'; +import type {YtQueueStatus} from '../../../reducers/navigation/tabs/queue/types'; + +import {QueueExportConfig} from '../../../../types/navigation/queue/queue'; const metaMock = {cell_id: '890', host: 'lbk-vla-249.search.yandex.net'}; export const emptyRate = {'1m': 0, '1h': 0, '1d': 0}; @@ -109,3 +112,12 @@ export const getConsumers = createSelector( ); export type SelectedConsumer = NonNullable>[0]; + +export const getExportsConfigRequestInfo = (state: RootState) => ({ + loading: state.navigation.tabs.queue.exports.loading, + loaded: state.navigation.tabs.queue.exports.loaded, + error: state.navigation.tabs.queue.exports.error, +}); + +export const getExportsConfig = (state: RootState): {[key: string]: QueueExportConfig} => + ypath.getValue(state.navigation.tabs.queue.exports.config, '/static_export_config'); diff --git a/packages/ui/src/ui/types/navigation/queue/queue.ts b/packages/ui/src/ui/types/navigation/queue/queue.ts new file mode 100644 index 000000000..5e66a194a --- /dev/null +++ b/packages/ui/src/ui/types/navigation/queue/queue.ts @@ -0,0 +1,7 @@ +export type QueueExportConfig = { + export_period: NumberType; + export_directory: string; + output_table_name_pattern?: string; + use_upper_bound_for_table_names?: boolean; + export_ttl?: NumberType; +}; diff --git a/packages/ui/tests/.gitignore b/packages/ui/tests/.gitignore index 240b1b084..e28e1756e 100644 --- a/packages/ui/tests/.gitignore +++ b/packages/ui/tests/.gitignore @@ -3,3 +3,4 @@ node_modules/ /playwright-report/ /playwright/.cache/ .env +downloads/static-table diff --git a/packages/ui/tests/e2e/pages/table.queue.base.spec.ts b/packages/ui/tests/e2e/pages/table.queue.base.spec.ts new file mode 100644 index 000000000..a461e5845 --- /dev/null +++ b/packages/ui/tests/e2e/pages/table.queue.base.spec.ts @@ -0,0 +1,16 @@ +import {expect, test} from '@playwright/test'; +import {E2E_DIR, makeClusterUrl} from '../../utils'; + +const PATH = `${E2E_DIR}/queue`; + +test('Queue: update existing export', async ({page}) => { + await page.goto(makeClusterUrl(`navigation?offsetMode=key&qMode=exports&navmode=queue&path=${PATH}`)); + await page.waitForSelector('.exports-edit__button'); + await page.locator('.exports-edit__button').click(); + + await page.locator('.df-dialog__field-group_type_time-duration .g-text-input__control').fill('1000'); + + await page.locator('button[type="submit"]').click(); + + await expect(page.locator('.structured-yson-virtualized__row_key_export_ttl .number')).toHaveText('1000'); +}); diff --git a/packages/ui/tests/init-cluster-e2e.sh b/packages/ui/tests/init-cluster-e2e.sh index cc2975581..8324d7d08 100755 --- a/packages/ui/tests/init-cluster-e2e.sh +++ b/packages/ui/tests/init-cluster-e2e.sh @@ -18,7 +18,6 @@ function createAndMountDynamicTable { schema=$2 yt create -i --attributes "{dynamic=%true;schema=$schema}" table $path yt mount-table $path - yt set $path/@mount_config/temp 1 } # userColumnPresets @@ -130,6 +129,24 @@ createAccountForQuotaEditor e2e-parent createAccountForQuotaEditor e2e-overcommit yt set //sys/accounts/e2e-overcommit-${E2E_SUFFIX}/@allow_children_limit_overcommit %true +yt create -r map_node ${E2E_DIR}/tmp/queue_export_default +yt create -r map_node ${E2E_DIR}/tmp/queue_export_extra + +QUEUE=${E2E_DIR}/queue +createAndMountDynamicTable "$QUEUE" "[{name=key;type=string};{name=value;type=string};{name=empty;type=any}]" +( + set +x + for ((i = 0; i < 300; i++)); do + echo "{key=key$i; value=value$i;};" + done + set -x +) | yt insert-rows --format yson ${QUEUE} + +TX_ID=$(yt start-tx) +yt set ${E2E_DIR}/tmp/queue_export_default/@queue_static_export_destination "{originating_queue_id=$(yt get $E2E_DIR/queue/@id)}" --tx $TX_ID +yt set ${E2E_DIR}/queue/@static_export_config "{default={export_directory=\"$E2E_DIR/tmp/queue_export_default\";export_period=10000}}" --tx $TX_ID +yt commit-tx $TX_ID + DYN_TABLE=${E2E_DIR}/dynamic-table createAndMountDynamicTable "$DYN_TABLE" "[{name=key;sort_order=ascending;type=string};{name=value;type=string};{name=empty;type=any}]" ( @@ -139,6 +156,7 @@ createAndMountDynamicTable "$DYN_TABLE" "[{name=key;sort_order=ascending;type=st done set -x ) | yt insert-rows --format yson ${DYN_TABLE} +yt set ${E2E_DIR}/dynamic-table/@mount_config/temp 1 yt freeze-table ${DYN_TABLE} STATIC_TABLE=${E2E_DIR}/static-table diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts index ba0e6d025..298995b6c 100644 --- a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts +++ b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts @@ -70,6 +70,14 @@ test('Navigation: map_node - ACL', async ({page}) => { await expect(page).toHaveScreenshot(); }); +test('Navigation: map_node - Originating queue path', async ({page}) => { + await page.goto(makeClusterUrl(`navigation?path=${E2E_DIR}/tmp/queue_export`)); + + await navigationPage(page).replaceBreadcrumbsTestDir(); + + await expect(page).toHaveScreenshot(); +}); + test('Navigation - Locks', async ({page}) => { await page.goto(makeClusterUrl(`navigation?navmode=locks&path=${E2E_DIR}/locked`)); diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation---map-node---select-by-first-cell-1-chromium-linux.png b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation---map-node---select-by-first-cell-1-chromium-linux.png index 206c3164c..4e80e4a54 100644 Binary files a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation---map-node---select-by-first-cell-1-chromium-linux.png and b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation---map-node---select-by-first-cell-1-chromium-linux.png differ diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-1-chromium-linux.png b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-1-chromium-linux.png index 424e6a260..148747402 100644 Binary files a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-1-chromium-linux.png and b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-1-chromium-linux.png differ diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-2-chromium-linux.png b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-2-chromium-linux.png index 0ba8526f2..25c4d74f8 100644 Binary files a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-2-chromium-linux.png and b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-2-chromium-linux.png differ diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-3-chromium-linux.png b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-3-chromium-linux.png index 6fa2c44dc..c57235410 100644 Binary files a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-3-chromium-linux.png and b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Content-3-chromium-linux.png differ diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Originating-queue-path-1-chromium-linux.png b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Originating-queue-path-1-chromium-linux.png new file mode 100644 index 000000000..ca72bda3f Binary files /dev/null and b/packages/ui/tests/screenshots/pages/navigation/navigation.base.screen.ts-snapshots/Navigation-map-node---Originating-queue-path-1-chromium-linux.png differ diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts b/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts index 1637e71a0..71285d2cd 100644 --- a/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts +++ b/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts @@ -2,7 +2,7 @@ import {Page, expect, test} from '@playwright/test'; import {E2E_DIR, makeClusterUrl} from '../../../utils'; import {replaceInnerHtml} from '../../../utils/dom'; import {TablePage} from './TablePage'; -import {basePage} from '../../../utils/BasePage'; +import {navigationPage} from './NavigationPage'; function tablePage(page: Page) { return new TablePage({page}); @@ -174,6 +174,29 @@ test('Navigation: table - userColumnPresets', async ({page, context}) => { }); }); +test('Navigation: queue create new export', async ({page}) => { + await page.goto(makeClusterUrl(`navigation?offsetMode=key&qMode=exports&navmode=queue&path=${E2E_DIR}/queue`)); + await page.waitForSelector('.exports-edit__button'); + await page.locator('.exports-edit__button').click(); + + await page.getByTestId('create-config').click(); + + await page.locator('.df-text-control input[id="name"]').fill('extra'); + await page.locator('.path-editor-control .g-text-input__control').fill(`${E2E_DIR}/tmp/queue_export_extra`); + await page.keyboard.press('Enter', {delay: 2000}); + await page.locator('.df-dialog__field-wrapper_type_number input[id="export_period"]').fill('10000'); + + await page.locator('button[type="submit"]').click({timeout: 2000}); + + await replaceInnerHtml(page, { + '.structured-yson-virtualized__row_key_export_directory .structured-yson-virtualized__value': + '//tmp/some/path/to/export', + }); + await navigationPage(page).replaceBreadcrumbsTestDir(); + + await expect(page).toHaveScreenshot(); +}); + test('Navigation: yql-v3-types', async ({page}) => { await page.goto(makeClusterUrl(`navigation?path=${E2E_DIR}/tmp/yql-v3-types-table`)); diff --git a/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts-snapshots/Navigation-queue-create-new-export-1-chromium-linux.png b/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts-snapshots/Navigation-queue-create-new-export-1-chromium-linux.png new file mode 100644 index 000000000..45321041a Binary files /dev/null and b/packages/ui/tests/screenshots/pages/navigation/navigation.table.base.screen.ts-snapshots/Navigation-queue-create-new-export-1-chromium-linux.png differ