import React from 'react'; import * as NiceModal from '@ebay/nice-modal-react'; import {CircleStop, GearPlay} from '@gravity-ui/icons'; import { ActionTooltip, Button, Dialog, Flex, HelpMark, Icon, Popover, Switch, Text, TextInput, } from '@gravity-ui/uikit'; import type {TOperation} from '../../../../../../types/api/operations'; import {CompactState} from '../../../../../../types/api/operations'; import {cn} from '../../../../../../utils/cn'; import {BRAND_BUTTON_CLASS} from '../../../../../../utils/constants'; import createToast from '../../../../../../utils/createToast'; import {prepareErrorMessage} from '../../../../../../utils/prepareErrorMessage'; import { getCompactionProgress, getCompactionShardProgress, isCompactMetadata, } from '../../../../../../utils/tableCompaction'; import {reachMetricaGoal} from '../../../../../../utils/yaMetrica'; import i18n from '../i18n'; import './CompactTableAction.scss'; const b = cn('ydb-diagnostics-table-info'); const DEFAULT_PARALLEL_SHARDS = '1'; const COMPACT_TABLE_DIALOG = 'compact-table-dialog'; const START_COMPACTION_RESPONSE_TIMEOUT = 1000; interface CompactTableActionProps { runningCompaction?: TOperation; isFetching: boolean; onApply: (value: {cascade: boolean; parallel?: number}) => Promise; onRefreshCompactions?: () => void; executeQueryAndForgetAvailable?: boolean; } type CompactionStartResult = | {status: 'success'} | {status: 'error'; error: unknown} | {status: 'timeout'}; // Races the compaction request against a timeout so the dialog does not block on a // long-running query. The request only resolves successfully if the operation actually // started; if it fails to start, the request rejects and we surface the error. Reaching // the timeout without an error is therefore treated as a successful start. Note: an error // arriving after the timeout cannot be shown, since by then the dialog is already closed. function waitForCompactionStartOrTimeout( promise: Promise, timeoutMs: number, ): Promise { return new Promise((resolve) => { const timeoutId = window.setTimeout(() => { resolve({status: 'timeout'}); }, timeoutMs); promise .then(() => { window.clearTimeout(timeoutId); resolve({status: 'success'}); }) .catch((error: unknown) => { window.clearTimeout(timeoutId); resolve({status: 'error', error}); }); }); } function showCompactionToast(content: string) { createToast({ name: 'startTableCompaction', content, autoHiding: 3000, isClosable: true, }); } function parseParallel(value: string) { if (!value.trim()) { return undefined; } const parsedValue = Number(value); return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null; } function getProgress(operation?: TOperation) { return getCompactionProgress(operation) ?? 0; } function getCompactionProgressDescription(operation: TOperation) { const {metadata} = operation; if (!isCompactMetadata(metadata)) { return i18n('value_compaction-starting'); } if (metadata.state === CompactState.STATE_IN_PROGRESS) { return i18n('value_compaction-running'); } if (metadata.state === CompactState.STATE_DONE) { return i18n('value_compaction-done'); } if (metadata.state === CompactState.STATE_CANCELLED) { return i18n('value_compaction-cancelled'); } return i18n('value_compaction-starting'); } function CompactTableStatusDetails({operation}: {operation: TOperation}) { const {metadata} = operation; const progress = getCompactionProgress(operation); const shardProgress = getCompactionShardProgress(operation); if (!isCompactMetadata(metadata)) { return null; } return ( {i18n('title_compaction')} {metadata.state && ( {i18n('field_compaction-state', {state: metadata.state})} )} {typeof progress === 'number' && ( {i18n('field_compaction-progress', {progress})} )} {shardProgress && ( {i18n('field_compaction-shards', { done: shardProgress.shardsDone, total: shardProgress.shardsTotal, })} )} ); } export function CompactTableStatusBanner({ operation, onCancel, isCancelling, }: { operation: TOperation; onCancel: () => void; isCancelling: boolean; }) { const progress = getProgress(operation); const progressWidth = `${progress}%`; return ( {i18n('title_compaction-in-progress')} {i18n('context_compaction-in-progress')}
{i18n('context_compaction-progress', { progress, status: getCompactionProgressDescription(operation), })} ); } export function CompactTableAction({ runningCompaction, isFetching, onApply, onRefreshCompactions, executeQueryAndForgetAvailable, }: CompactTableActionProps) { const handleOpenDialog = React.useCallback(() => { reachMetricaGoal('openCompactionDialog'); openCompactTableDialog({ onApply, onRefreshCompactions, hasRunningCompaction: Boolean(runningCompaction), executeQueryAndForgetAvailable, }); }, [executeQueryAndForgetAvailable, onApply, onRefreshCompactions, runningCompaction]); const button = ( ); return runningCompaction ? ( } placement="bottom-end" > {button} ) : ( {button} ); } interface CompactTableDialogProps { onApply: (value: {cascade: boolean; parallel?: number}) => Promise; onRefreshCompactions?: () => void; hasRunningCompaction: boolean; executeQueryAndForgetAvailable?: boolean; } interface CompactTableDialogNiceModalProps extends CompactTableDialogProps { open: boolean; onClose: () => void; } function CompactTableDialog({ open, onClose, onApply, onRefreshCompactions, hasRunningCompaction, executeQueryAndForgetAvailable, }: CompactTableDialogNiceModalProps) { const [cascade, setCascade] = React.useState(true); const [parallel, setParallel] = React.useState(DEFAULT_PARALLEL_SHARDS); const [parallelError, setParallelError] = React.useState(''); const [requestErrorMessage, setRequestErrorMessage] = React.useState(''); const [isSubmitting, setIsSubmitting] = React.useState(false); const submitDisabled = isSubmitting || hasRunningCompaction; const resetFormState = React.useCallback(() => { setCascade(true); setParallel(DEFAULT_PARALLEL_SHARDS); setParallelError(''); setRequestErrorMessage(''); }, []); const handleClose = React.useCallback(() => { resetFormState(); onClose(); }, [onClose, resetFormState]); const handleCascadeUpdate = React.useCallback((value: boolean) => { setCascade(value); setRequestErrorMessage(''); }, []); const handleParallelUpdate = React.useCallback((value: string) => { setParallel(value); setParallelError(''); setRequestErrorMessage(''); }, []); const handleSubmit = React.useCallback( async (event?: React.FormEvent) => { event?.preventDefault(); if (submitDisabled) { return; } const parsedParallel = parseParallel(parallel); if (parsedParallel === null) { setParallelError(i18n('alert_positive-integer')); return; } setRequestErrorMessage(''); setIsSubmitting(true); try { reachMetricaGoal('startCompaction'); const applyPromise = onApply({ cascade, parallel: parsedParallel, }); if (executeQueryAndForgetAvailable) { // Backend returns immediately once compaction is scheduled in the background await applyPromise; } else { // Compaction query is long-running, so do not block the dialog on it. // Treat reaching the response timeout without an error as a success. const result = await waitForCompactionStartOrTimeout( applyPromise, START_COMPACTION_RESPONSE_TIMEOUT, ); if (result.status === 'error') { setRequestErrorMessage(prepareErrorMessage(result.error)); return; } } showCompactionToast(i18n('toast_compaction-started')); onRefreshCompactions?.(); handleClose(); } catch (error) { setRequestErrorMessage(prepareErrorMessage(error)); } finally { setIsSubmitting(false); } }, [ cascade, executeQueryAndForgetAvailable, handleClose, onApply, onRefreshCompactions, parallel, submitDisabled, ], ); return ( {i18n('title_run-table-compaction')}} />
{i18n('help_cascade')} {i18n('help_parallel-shards')} {requestErrorMessage && ( {requestErrorMessage} )}
); } export const CompactTableDialogNiceModal = NiceModal.create((props: CompactTableDialogProps) => { const modal = NiceModal.useModal(); const handleClose = () => { modal.hide(); modal.remove(); }; return ( ); }); NiceModal.register(COMPACT_TABLE_DIALOG, CompactTableDialogNiceModal); export function openCompactTableDialog(props: CompactTableDialogProps): void { NiceModal.show(COMPACT_TABLE_DIALOG, { id: COMPACT_TABLE_DIALOG, ...props, }); }