diff --git a/client/src/api/histories.ts b/client/src/api/histories.ts index 03d3542feff..1d8eabd8c52 100644 --- a/client/src/api/histories.ts +++ b/client/src/api/histories.ts @@ -51,6 +51,11 @@ export type PublishedHistory = SharedHistory & { */ export type AnyHistoryEntry = MyHistory | SharedHistory | PublishedHistory | ArchivedHistorySummary; +/** + * Represents a reference to a history, containing only the fields necessary for identifying a history in API calls. + */ +export type HistoryReference = Pick; + /** * Represents the options for fetching histories. */ @@ -301,6 +306,73 @@ export async function getHistoryCounts(historyId: string): Promise { + const { data, error } = await GalaxyApi().GET("/api/histories/{history_id}/contents/bulk/storage/runs/{run_id}", { + params: { + path: { history_id: history.id, run_id: runId }, + }, + }); + + if (error) { + rethrowSimple(error); + } + + return data; +} + +export async function getStorageOperationRunItemsWithTotal( + history: HistoryReference, + runId: string, + options: StorageOperationRunItemsOptions = {}, +): Promise { + const { offset = 0, limit = 50, search } = options; + const { response, data, error } = await GalaxyApi().GET( + "/api/histories/{history_id}/contents/bulk/storage/runs/{run_id}/items", + { + params: { + path: { history_id: history.id, run_id: runId }, + query: { + offset, + limit, + search, + }, + }, + }, + ); + + if (error) { + rethrowSimple(error); + } + + const totalMatchesHeader = response.headers.get("total_matches"); + const totalMatches = totalMatchesHeader !== null ? parseInt(totalMatchesHeader, 10) : null; + return { + data, + totalMatches: Number.isNaN(totalMatches) ? null : totalMatches, + }; +} + /** * Fetches the workflow extraction summary for a specific history entry. * @param {string} historyId The ID of the history entry to fetch the workflow extraction summary for diff --git a/client/src/api/notifications.ts b/client/src/api/notifications.ts index 9fc55a8d776..243e6a6841d 100644 --- a/client/src/api/notifications.ts +++ b/client/src/api/notifications.ts @@ -15,6 +15,11 @@ export interface SharedItemNotification extends BaseUserNotification { content: components["schemas"]["NewSharedItemNotificationContent"]; } +export interface StorageOperationNotification extends BaseUserNotification { + category: "storage_operation"; + content: components["schemas"]["StorageOperationNotificationContent"]; +} + type NotificationCreateData = components["schemas"]["NotificationCreateData"]; export interface MessageNotificationCreateData extends NotificationCreateData { @@ -28,7 +33,7 @@ export interface MessageNotificationCreateRequest extends NotificationCreateRequ notification: MessageNotificationCreateData; } -export type UserNotification = MessageNotification | SharedItemNotification; +export type UserNotification = MessageNotification | SharedItemNotification | StorageOperationNotification; export type NotificationChanges = components["schemas"]["UserNotificationUpdateRequest"]; diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 5a9e11bb642..31466318d58 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -2359,6 +2359,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/histories/{history_id}/contents/bulk/storage/execute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Executes a previously previewed storage bulk operation snapshot. */ + post: operations["bulk_storage_operation_execute_api_histories__history_id__contents_bulk_storage_execute_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/histories/{history_id}/contents/bulk/storage/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Previews a storage bulk operation for selected history contents. */ + post: operations["bulk_storage_operation_preview_api_histories__history_id__contents_bulk_storage_preview_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/histories/{history_id}/contents/bulk/storage/runs/{run_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Returns run status summary for a storage bulk operation. */ + get: operations["bulk_storage_operation_run_api_histories__history_id__contents_bulk_storage_runs__run_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/histories/{history_id}/contents/bulk/storage/runs/{run_id}/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Returns paginated per-item details for a storage bulk operation run. */ + get: operations["bulk_storage_operation_run_items_api_histories__history_id__contents_bulk_storage_runs__run_id__items_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/histories/{history_id}/contents/dataset_collections/{hdca_id}/download": { parameters: { query?: never; @@ -11309,6 +11377,22 @@ export interface components { [key: string]: unknown; }[]; }; + /** + * DatasetStorageOperationFailureReasonCode + * @enum {string} + */ + DatasetStorageOperationFailureReasonCode: + | "dataset_not_found" + | "invalid_target_object_store" + | "missing_source_object_store" + | "already_in_target" + | "target_quota_exceeded" + | "shared_dataset" + | "insufficient_permissions" + | "dataset_in_use" + | "target_expiration_imminent" + | "checksum_verification_failed" + | "execution_error"; /** DatasetTextContentDetails */ DatasetTextContentDetails: { /** @@ -19327,6 +19411,7 @@ export interface components { content: | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] + | components["schemas"]["StorageOperationNotificationContent"] | components["schemas"]["BroadcastNotificationContent"]; /** * Expiration time @@ -19415,6 +19500,7 @@ export interface components { content: | components["schemas"]["MessageNotificationContent"] | components["schemas"]["NewSharedItemNotificationContent"] + | components["schemas"]["StorageOperationNotificationContent"] | components["schemas"]["BroadcastNotificationContent"]; /** * Create time @@ -20502,7 +20588,7 @@ export interface components { * displayed in the notification preferences. * @enum {string} */ - PersonalNotificationCategory: "message" | "new_shared_item"; + PersonalNotificationCategory: "message" | "new_shared_item" | "storage_operation"; /** PluginAspectStatus */ PluginAspectStatus: { /** Message */ @@ -22578,6 +22664,267 @@ export interface components { /** Total Item Count */ total_item_count: number; }; + /** StorageOperationEligibilityReasonSummary */ + StorageOperationEligibilityReasonSummary: { + /** Count */ + count: number; + reason_code: components["schemas"]["DatasetStorageOperationFailureReasonCode"]; + }; + /** StorageOperationEligibilitySummary */ + StorageOperationEligibilitySummary: { + /** Eligible Count */ + eligible_count: number; + /** Ineligible Count */ + ineligible_count: number; + /** Reasons */ + reasons?: components["schemas"]["StorageOperationEligibilityReasonSummary"][]; + }; + /** StorageOperationEstimateSummary */ + StorageOperationEstimateSummary: { + /** + * Bytes To Transfer + * @default 0 + */ + bytes_to_transfer: number; + /** Quota Delta Transfers */ + quota_delta_transfers?: components["schemas"]["StorageOperationQuotaDeltaTransfer"][]; + quota_projection?: components["schemas"]["StorageOperationQuotaProjectionSummary"] | null; + }; + /** StorageOperationExecutePolicy */ + StorageOperationExecutePolicy: { + /** Max Retries */ + max_retries?: number | null; + /** + * Skip Ineligible + * @default true + */ + skip_ineligible: boolean; + }; + /** StorageOperationExecuteRequest */ + StorageOperationExecuteRequest: { + execution_policy?: components["schemas"]["StorageOperationExecutePolicy"]; + /** + * Notify On Completion + * @default true + */ + notify_on_completion: boolean; + /** + * Snapshot Id + * @example 0123456789ABCDEF + */ + snapshot_id: string; + }; + /** StorageOperationExecuteResponse */ + StorageOperationExecuteResponse: { + run: components["schemas"]["StorageOperationRunSummary"]; + }; + /** + * StorageOperationMode + * @enum {string} + */ + StorageOperationMode: "move"; + /** StorageOperationNotificationContent */ + StorageOperationNotificationContent: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + category: "storage_operation"; + /** + * Failed Count + * @description Failed datasets count. + * @default 0 + */ + failed_count: number; + /** + * History ID + * @description The encoded history ID. + * @example 0123456789ABCDEF + */ + history_id: string; + /** + * Message + * @description The message of the notification (supports Markdown). + */ + message: string; + /** + * Mode + * @description Storage operation mode. + */ + mode: string; + /** + * Run ID + * @description The encoded storage operation run ID. + * @example 0123456789ABCDEF + */ + run_id: string; + /** + * Run URL + * @description Absolute or relative URL to the storage operation run status view. + */ + run_url: string; + /** + * Skipped Count + * @description Skipped datasets count. + * @default 0 + */ + skipped_count: number; + /** + * State + * @description The current state of the storage operation run when this notification was generated. + */ + state: components["schemas"]["StorageOperationRunState"]; + /** + * Subject + * @description The subject of the notification. + */ + subject: string; + /** + * Succeeded Count + * @description Succeeded datasets count. + * @default 0 + */ + succeeded_count: number; + /** + * Total Count + * @description Total datasets in the run. + */ + total_count: number; + }; + /** StorageOperationPreviewRequest */ + StorageOperationPreviewRequest: { + /** Items */ + items?: + | { + [key: string]: unknown; + }[] + | null; + mode: components["schemas"]["StorageOperationMode"]; + /** Target Object Store Id */ + target_object_store_id: string; + }; + /** StorageOperationPreviewResponse */ + StorageOperationPreviewResponse: { + eligibility: components["schemas"]["StorageOperationEligibilitySummary"]; + estimates: components["schemas"]["StorageOperationEstimateSummary"]; + /** + * Expires At + * Format: date-time + */ + expires_at: string; + selection_counts: components["schemas"]["StorageOperationSelectionCounts"]; + /** + * Snapshot Id + * @example 0123456789ABCDEF + */ + snapshot_id: string; + /** Warnings */ + warnings?: string[]; + }; + /** StorageOperationQuotaDeltaTransfer */ + StorageOperationQuotaDeltaTransfer: { + /** + * Bytes + * @default 0 + */ + bytes: number; + /** Source Object Store Id */ + source_object_store_id: string; + /** Target Object Store Id */ + target_object_store_id: string; + }; + /** StorageOperationQuotaProjectionSummary */ + StorageOperationQuotaProjectionSummary: { + /** Projected Usage */ + projected_usage: number; + /** Quota Limit */ + quota_limit: number; + }; + /** + * StorageOperationRunItemState + * @enum {string} + */ + StorageOperationRunItemState: "pending" | "running" | "succeeded" | "failed" | "skipped"; + /** StorageOperationRunItemStatus */ + StorageOperationRunItemStatus: { + /** Bytes Processed */ + bytes_processed: number; + /** + * Create Time + * Format: date-time + * @description The time and date this item was created. + */ + create_time: string; + /** + * Dataset Id + * @example 0123456789ABCDEF + */ + dataset_id: string; + reason_code?: components["schemas"]["DatasetStorageOperationFailureReasonCode"] | null; + state: components["schemas"]["StorageOperationRunItemState"]; + /** + * Update Time + * Format: date-time + * @description The last time and date this item was updated. + */ + update_time: string; + }; + /** StorageOperationRunResponse */ + StorageOperationRunResponse: { + /** Items */ + items?: components["schemas"]["StorageOperationRunItemStatus"][]; + run: components["schemas"]["StorageOperationRunSummary"]; + }; + /** + * StorageOperationRunState + * @enum {string} + */ + StorageOperationRunState: "pending" | "running" | "completed" | "failed"; + /** StorageOperationRunSummary */ + StorageOperationRunSummary: { + /** + * Create Time + * Format: date-time + * @description The time and date this item was created. + */ + create_time: string; + /** Failed Count */ + failed_count: number; + mode: components["schemas"]["StorageOperationMode"]; + /** + * Run Id + * @example 0123456789ABCDEF + */ + run_id: string; + /** Skipped Count */ + skipped_count: number; + state: components["schemas"]["StorageOperationRunState"]; + /** Succeeded Count */ + succeeded_count: number; + /** Target Object Store Id */ + target_object_store_id: string; + /** Task Id */ + task_id?: string | null; + /** Total Bytes Processed */ + total_bytes_processed: number; + /** Total Count */ + total_count: number; + /** + * Update Time + * Format: date-time + * @description The last time and date this item was updated. + */ + update_time: string; + }; + /** StorageOperationSelectionCounts */ + StorageOperationSelectionCounts: { + /** Expanded Leaf Count */ + expanded_leaf_count: number; + /** Selected Items Count */ + selected_items_count: number; + /** Unique Dataset Count */ + unique_dataset_count: number; + }; /** StoreExportPayload */ StoreExportPayload: { /** @@ -24603,6 +24950,13 @@ export interface components { * "push": true * }, * "enabled": true + * }, + * "storage_operation": { + * "channels": { + * "email": true, + * "push": true + * }, + * "enabled": true * } * } * } @@ -25006,6 +25360,13 @@ export interface components { * "push": true * }, * "enabled": true + * }, + * "storage_operation": { + * "channels": { + * "email": true, + * "push": true + * }, + * "enabled": true * } * } * } @@ -25035,7 +25396,8 @@ export interface components { */ content: | components["schemas"]["MessageNotificationContent"] - | components["schemas"]["NewSharedItemNotificationContent"]; + | components["schemas"]["NewSharedItemNotificationContent"] + | components["schemas"]["StorageOperationNotificationContent"]; /** * Create time * Format: date-time @@ -37855,6 +38217,236 @@ export interface operations { }; }; }; + bulk_storage_operation_execute_api_histories__history_id__contents_bulk_storage_execute_post: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StorageOperationExecuteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageOperationExecuteResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + bulk_storage_operation_preview_api_histories__history_id__contents_bulk_storage_preview_post: { + parameters: { + query?: { + /** @description Generally a property name to filter by followed by an (often optional) hyphen and operator string. */ + q?: string[] | null; + /** @description The value to filter by. */ + qv?: string[] | null; + }; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StorageOperationPreviewRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageOperationPreviewResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + bulk_storage_operation_run_api_histories__history_id__contents_bulk_storage_runs__run_id__get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageOperationRunResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + bulk_storage_operation_run_items_api_histories__history_id__contents_bulk_storage_runs__run_id__items_get: { + parameters: { + query?: { + /** @description The offset for paginated per-item run details. */ + offset?: number; + /** @description The maximum number of per-item run details to return. */ + limit?: number; + /** + * @description A mix of free text and GitHub-style tags used to filter the index operation. + * + * ## Query Structure + * + * GitHub-style filter tags (not be confused with Galaxy tags) are tags of the form + * `:` or `:''`. The tag name + * *generally* (but not exclusively) corresponds to the name of an attribute on the model + * being indexed (i.e. a column in the database). + * + * If the tag is quoted, the attribute will be filtered exactly. If the tag is unquoted, + * generally a partial match will be used to filter the query (i.e. in terms of the implementation + * this means the database operation `ILIKE` will typically be used). + * + * Once the tagged filters are extracted from the search query, the remaining text is just + * used to search various documented attributes of the object. + * + * ## GitHub-style Tags Available + * + * `state` + * : Item state. + * + * `reason_code` + * : Item reason code. (The tag `reason` can be used a short hand alias for this tag to filter on this attribute.) + * + * `dataset_id` + * : Encoded dataset id. (The tag `dataset` can be used a short hand alias for this tag to filter on this attribute.) + * + * ## Free Text + * + * Free text search terms will be searched against the following attributes of the + * Storage operation run items: `state`, `reason_code`, `dataset_id`. + */ + search?: string | null; + }; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageOperationRunItemStatus"][]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; history_contents__download_collection: { parameters: { query?: never; diff --git a/client/src/components/Common/DatasetPopoverLink.vue b/client/src/components/Common/DatasetPopoverLink.vue new file mode 100644 index 00000000000..7c0b28dbfbb --- /dev/null +++ b/client/src/components/Common/DatasetPopoverLink.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/client/src/components/History/Content/ContentItem.test.js b/client/src/components/History/Content/ContentItem.test.js index 84d555c4159..6cd14e6e654 100644 --- a/client/src/components/History/Content/ContentItem.test.js +++ b/client/src/components/History/Content/ContentItem.test.js @@ -7,7 +7,6 @@ import VueRouter from "vue-router"; import { HttpResponse, useServerMock } from "@/api/client/__mocks__"; import { updateContentFields } from "@/components/History/model/queries"; -import { setupSelectableMock } from "@/components/ObjectStore/mockServices"; import ContentItem from "./ContentItem.vue"; @@ -25,8 +24,6 @@ vi.mock("vue-router/composables", () => ({ useRouter: vi.fn(() => ({})), })); -setupSelectableMock(); - // mock queries updateContentFields.mockImplementation(async () => {}); @@ -45,6 +42,10 @@ describe("ContentItem", () => { suppressLucideVue2Deprecation(); server.use( + http.get("/api/object_stores", ({ response }) => { + return response(200).json([]); + }), + http.get("/api/datasets/{dataset_id}", ({ response }) => { // We need to use untyped here because this endpoint is not // described in the OpenAPI spec due to its complexity for now. diff --git a/client/src/components/History/CurrentHistory/HistoryOperations/HistoryOperations.vue b/client/src/components/History/CurrentHistory/HistoryOperations/HistoryOperations.vue index 8622468e4a8..f17cb343b8a 100644 --- a/client/src/components/History/CurrentHistory/HistoryOperations/HistoryOperations.vue +++ b/client/src/components/History/CurrentHistory/HistoryOperations/HistoryOperations.vue @@ -3,11 +3,13 @@ import { faCheckSquare, faCompress } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import type { HistorySummaryExtended } from "@/api"; +import type { StorageRun } from "@/stores/storageOperationsStore"; import localize from "@/utils/localization"; import GButton from "@/components/BaseComponents/GButton.vue"; import GButtonGroup from "@/components/BaseComponents/GButtonGroup.vue"; import DefaultOperations from "@/components/History/CurrentHistory/HistoryOperations/DefaultOperations.vue"; +import HistoryStorageOperationsIndicator from "@/components/History/CurrentHistory/HistoryOperations/HistoryStorageOperationsIndicator.vue"; interface Props { history: HistorySummaryExtended; @@ -16,6 +18,7 @@ interface Props { expandedCount: number; showSelection: boolean; isMultiViewItem: boolean; + activeStorageRuns: StorageRun[]; } const props = defineProps(); @@ -61,15 +64,22 @@ function onUpdateOperationStatus(updateTime: number) { - - - +
+ + + - + + + +