diff --git a/src/containers/Operations/columns.tsx b/src/containers/Operations/columns.tsx index 9e0b7e656b..625392fc53 100644 --- a/src/containers/Operations/columns.tsx +++ b/src/containers/Operations/columns.tsx @@ -15,9 +15,12 @@ import {parseProtobufTimestampToMs} from '../../utils/timeParsers'; import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants'; import i18n from './i18n'; +import {getOperationProgress, isIndexBuildMetadata} from './utils'; import './Operations.scss'; +const IMPORT_EXPORT_KINDS: OperationKind[] = ['import/s3', 'export/s3', 'export/yt']; + export function getColumns({ database, refreshTable, @@ -28,13 +31,17 @@ export function getColumns({ kind: OperationKind; }): DataTableColumn[] { const isBuildIndex = kind === 'buildindex'; + const isImportOrExport = IMPORT_EXPORT_KINDS.includes(kind); - // Helper function to get description tooltip content + // Helper function to get description tooltip content (buildindex-only) const getDescriptionTooltip = (operation: TOperation): string => { - if (!operation.metadata?.description) { + const {metadata} = operation; + + if (!isIndexBuildMetadata(metadata) || !metadata.description) { return ''; } - return JSON.stringify(operation.metadata.description, null, 2); + + return JSON.stringify(metadata.description, null, 2); }; const columns: DataTableColumn[] = [ @@ -72,34 +79,38 @@ export function getColumns({ }, ]; - // Add buildindex-specific columns + // Add buildindex-specific state column if (isBuildIndex) { - columns.push( - { - name: COLUMNS_NAMES.STATE, - header: COLUMNS_TITLES[COLUMNS_NAMES.STATE], - render: ({row}) => { - const metadata = row.metadata as IndexBuildMetadata | undefined; - if (!metadata?.state) { - return EMPTY_DATA_PLACEHOLDER; - } - return metadata.state; - }, + columns.push({ + name: COLUMNS_NAMES.STATE, + header: COLUMNS_TITLES[COLUMNS_NAMES.STATE], + render: ({row}) => { + const metadata = row.metadata as IndexBuildMetadata | undefined; + if (!metadata?.state) { + return EMPTY_DATA_PLACEHOLDER; + } + return metadata.state; }, - { - name: COLUMNS_NAMES.PROGRESS, - header: COLUMNS_TITLES[COLUMNS_NAMES.PROGRESS], - render: ({row}) => { - const metadata = row.metadata as IndexBuildMetadata | undefined; - if (metadata?.progress === undefined) { - return EMPTY_DATA_PLACEHOLDER; - } - return `${Math.round(metadata.progress)}%`; - }, + }); + } + + // Add progress column for operations that have progress data + if (isBuildIndex || isImportOrExport) { + columns.push({ + name: COLUMNS_NAMES.PROGRESS, + header: COLUMNS_TITLES[COLUMNS_NAMES.PROGRESS], + render: ({row}) => { + const progress = getOperationProgress(row, i18n); + if (progress === null) { + return EMPTY_DATA_PLACEHOLDER; + } + return progress; }, - ); - } else { - // Add standard columns for non-buildindex operations + }); + } + + // Add standard columns for non-buildindex operations + if (!isBuildIndex) { columns.push( { name: COLUMNS_NAMES.CREATED_BY, diff --git a/src/containers/Operations/i18n/en.json b/src/containers/Operations/i18n/en.json index 791d01f9dd..8a02cdcbdf 100644 --- a/src/containers/Operations/i18n/en.json +++ b/src/containers/Operations/i18n/en.json @@ -16,6 +16,14 @@ "column_state": "State", "column_progress": "Progress", "label_duration-ongoing": "{{value}} (ongoing)", + "value_progress_unspecified": "Unspecified", + "value_progress_preparing": "Preparing", + "value_progress_transfer_data": "Transferring Data", + "value_progress_build_indexes": "Building Indexes", + "value_progress_done": "Done", + "value_progress_cancellation": "Cancelling", + "value_progress_cancelled": "Cancelled", + "value_progress_create_changefeeds": "Creating Changefeeds", "header_cancel": "Cancel operation", "header_forget": "Forget operation", "text_cancel": "The operation will be cancelled. Do you want to proceed?", diff --git a/src/containers/Operations/utils.ts b/src/containers/Operations/utils.ts new file mode 100644 index 0000000000..2bb436d701 --- /dev/null +++ b/src/containers/Operations/utils.ts @@ -0,0 +1,167 @@ +import type { + ExportToS3Metadata, + ExportToYtMetadata, + ImportFromS3Metadata, + IndexBuildMetadata, + TOperation, +} from '../../types/api/operations'; +import {OPERATION_METADATA_TYPE_URLS} from '../../types/api/operations'; + +// Type guards for operation metadata kinds +export function isIndexBuildMetadata( + metadata: TOperation['metadata'], +): metadata is IndexBuildMetadata { + if (!metadata) { + return false; + } + + return metadata['@type'] === OPERATION_METADATA_TYPE_URLS.IndexBuild; +} + +export function isImportFromS3Metadata( + metadata: TOperation['metadata'], +): metadata is ImportFromS3Metadata { + if (!metadata) { + return false; + } + + return metadata['@type'] === OPERATION_METADATA_TYPE_URLS.ImportFromS3; +} + +export function isExportToS3Metadata( + metadata: TOperation['metadata'], +): metadata is ExportToS3Metadata { + if (!metadata) { + return false; + } + + return metadata['@type'] === OPERATION_METADATA_TYPE_URLS.ExportToS3; +} + +export function isExportToYtMetadata( + metadata: TOperation['metadata'], +): metadata is ExportToYtMetadata { + if (!metadata) { + return false; + } + + return metadata['@type'] === OPERATION_METADATA_TYPE_URLS.ExportToYt; +} + +export function isImportExportMetadata( + metadata: TOperation['metadata'], +): metadata is ImportFromS3Metadata | ExportToS3Metadata | ExportToYtMetadata { + return ( + isImportFromS3Metadata(metadata) || + isExportToS3Metadata(metadata) || + isExportToYtMetadata(metadata) + ); +} + +// i18n keys for import/export progress enum values +// value_progress_unspecified, value_progress_preparing, etc. +export type OperationProgressKey = + | 'value_progress_unspecified' + | 'value_progress_preparing' + | 'value_progress_transfer_data' + | 'value_progress_build_indexes' + | 'value_progress_done' + | 'value_progress_cancellation' + | 'value_progress_cancelled' + | 'value_progress_create_changefeeds'; + +/** + * Calculate progress percentage from Import/Export metadata + * + * Calculates overall progress based on items_progress array: + * - Sums all parts_total and parts_completed across all items + * - Returns percentage rounded to nearest integer + * + * @param metadata - Import/Export operation metadata + * @returns Progress percentage (0-100) or null if cannot be calculated + */ +export function calculateImportExportProgress( + metadata: ImportFromS3Metadata | ExportToS3Metadata | ExportToYtMetadata | undefined, +): number | null { + if (!metadata?.items_progress || metadata.items_progress.length === 0) { + return null; + } + + let totalParts = 0; + let completedParts = 0; + + for (const item of metadata.items_progress) { + if (item.parts_total !== undefined && item.parts_total > 0) { + totalParts += item.parts_total; + completedParts += item.parts_completed || 0; + } + } + + if (totalParts === 0) { + return null; + } + + return Math.round((completedParts / totalParts) * 100); +} + +/** + * Get progress display value for an operation + * + * Handles different progress formats: + * - BuildIndex: numeric progress (0-100) -> "75%" + * - Import/Export: calculated from items_progress -> "45%" or enum value -> "Done" + * + * @param operation - Operation to get progress for + * @param translateProgress - Function to translate progress enum values (i18n) + * @returns Formatted progress string or null if no progress available + */ +export function getOperationProgress( + operation: TOperation, + translateProgress: (key: OperationProgressKey) => string, +): string | null { + const metadata = operation.metadata; + + if (!metadata) { + return null; + } + + if (isIndexBuildMetadata(metadata)) { + if (typeof metadata.progress === 'number') { + return `${Math.round(metadata.progress)}%`; + } + } + + // Import/Export: calculate from items_progress or show enum value + if (isImportExportMetadata(metadata)) { + // Try to calculate percentage from items_progress + const calculatedProgress = calculateImportExportProgress(metadata); + if (calculatedProgress !== null) { + return `${calculatedProgress}%`; + } + + // Fallback to enum progress value + if (metadata.progress) { + const progressValue = + typeof metadata.progress === 'string' + ? metadata.progress + : String(metadata.progress); + + // Backend enums are usually PROGRESS_DONE, PROGRESS_PREPARING, etc. + // Normalize by stripping optional PROGRESS_ prefix and lowercasing. + // Both "PROGRESS_DONE" and "DONE" will map to "value_progress_done". + const base = progressValue.replace(/^PROGRESS_/, '').toLowerCase(); // done + const i18nKey = `value_progress_${base}` as OperationProgressKey; + + try { + const translated = translateProgress(i18nKey); + if (translated && translated !== i18nKey) { + return translated; + } + } catch {} + + return progressValue; + } + } + + return null; +} diff --git a/src/types/api/operations.ts b/src/types/api/operations.ts index 94b7e9fb0c..fcce861646 100644 --- a/src/types/api/operations.ts +++ b/src/types/api/operations.ts @@ -88,7 +88,101 @@ export enum IndexBuildState { STATE_REJECTED = 'STATE_REJECTED', } -export type TOperationMetadata = IndexBuildMetadata; +export const OPERATION_METADATA_TYPE_URLS = { + IndexBuild: 'type.googleapis.com/Ydb.Table.IndexBuildMetadata', + ImportFromS3: 'type.googleapis.com/Ydb.Import.ImportFromS3Metadata', + ExportToS3: 'type.googleapis.com/Ydb.Export.ExportToS3Metadata', + ExportToYt: 'type.googleapis.com/Ydb.Export.ExportToYtMetadata', +} as const; + +export type OperationMetadataTypeUrl = + (typeof OPERATION_METADATA_TYPE_URLS)[keyof typeof OPERATION_METADATA_TYPE_URLS]; + +/** + * Import/Export progress enum + * source: https://github.com/ydb-platform/ydb/blob/main/ydb/public/api/protos/ydb_import.proto + */ +export enum ImportExportProgress { + PROGRESS_UNSPECIFIED = 'PROGRESS_UNSPECIFIED', + PROGRESS_PREPARING = 'PROGRESS_PREPARING', + PROGRESS_TRANSFER_DATA = 'PROGRESS_TRANSFER_DATA', + PROGRESS_BUILD_INDEXES = 'PROGRESS_BUILD_INDEXES', + PROGRESS_DONE = 'PROGRESS_DONE', + PROGRESS_CANCELLATION = 'PROGRESS_CANCELLATION', + PROGRESS_CANCELLED = 'PROGRESS_CANCELLED', + PROGRESS_CREATE_CHANGEFEEDS = 'PROGRESS_CREATE_CHANGEFEEDS', +} + +/** + * Import/Export item progress + * source: https://github.com/ydb-platform/ydb/blob/main/ydb/public/api/protos/ydb_import.proto + */ +export interface ImportExportItemProgress { + parts_total?: number; + parts_completed?: number; + start_time?: IProtobufTimeObject; + end_time?: IProtobufTimeObject; +} + +/** + * Import from S3 metadata + * source: https://github.com/ydb-platform/ydb/blob/main/ydb/public/api/protos/ydb_import.proto#L108 + */ +export interface ImportFromS3Metadata { + '@type'?: typeof OPERATION_METADATA_TYPE_URLS.ImportFromS3; + settings?: { + endpoint?: string; + scheme?: string; + bucket?: string; + items?: Array<{ + source_prefix?: string; + source_path?: string; + destination_path?: string; + }>; + [key: string]: unknown; + }; + progress?: ImportExportProgress | string; + items_progress?: ImportExportItemProgress[]; +} + +/** + * Export to S3 metadata + * source: https://github.com/ydb-platform/ydb/blob/main/ydb/public/api/protos/ydb_export.proto + */ +export interface ExportToS3Metadata { + '@type'?: typeof OPERATION_METADATA_TYPE_URLS.ExportToS3; + settings?: { + endpoint?: string; + scheme?: string; + bucket?: string; + items?: Array<{ + source_path?: string; + destination_prefix?: string; + }>; + [key: string]: unknown; + }; + progress?: ImportExportProgress | string; + items_progress?: ImportExportItemProgress[]; +} + +/** + * Export to YT metadata + * source: https://github.com/ydb-platform/ydb/blob/main/ydb/public/api/protos/ydb_export.proto + */ +export interface ExportToYtMetadata { + '@type'?: typeof OPERATION_METADATA_TYPE_URLS.ExportToYt; + settings?: { + [key: string]: unknown; + }; + progress?: ImportExportProgress | string; + items_progress?: ImportExportItemProgress[]; +} + +export type TOperationMetadata = + | IndexBuildMetadata + | ImportFromS3Metadata + | ExportToS3Metadata + | ExportToYtMetadata; export interface TCostInfo { consumed_units?: number;