diff --git a/client/platform/desktop/frontend/components/MultiPipeline.vue b/client/platform/desktop/frontend/components/MultiPipeline.vue index fd0b08f73..2c7852b07 100644 --- a/client/platform/desktop/frontend/components/MultiPipeline.vue +++ b/client/platform/desktop/frontend/components/MultiPipeline.vue @@ -7,7 +7,7 @@ import { watch, } from 'vue'; import { DataTableHeader } from 'vuetify'; -import { useRouter } from 'vue-router/composables'; +import { useRoute, useRouter } from 'vue-router/composables'; import { Pipe, Pipelines, useApi } from 'dive-common/apispec'; import { parentDatasetId } from 'dive-common/compositeDatasetId'; import { @@ -30,6 +30,16 @@ import { datasets, JsonConfigCache } from '../store/dataset'; const { getPipelineList, runPipeline, hasCalibrationFile } = useApi(); const { prompt } = usePrompt(); const router = useRouter(); +const route = useRoute(); + +/** Dataset ids handed off by another page (e.g. the Library selection). */ +function preselectedDatasetIds(): string[] { + const query = route.query.datasetIds; + const values = Array.isArray(query) ? query : [query]; + return values + .flatMap((value) => (value || '').split(',')) + .filter((id) => id in datasets.value); +} const unsortedPipelines = ref({} as Pipelines); const selectedPipelineType: Ref = ref(null); @@ -173,8 +183,11 @@ function toggleStaged(item: JsonConfigCache) { async function runPipelineForDatasets() { if (selectedPipeline.value !== null) { + // Only the staged datasets compatible with (and displayed for) the + // selected pipeline; staged ids can hold datasets other pipelines accept. + const runIds = stagedDatasets.value.map((item: JsonConfigCache) => item.id); const results = await Promise.allSettled( - stagedDatasetIds.value.map((datasetId: string) => { + runIds.map((datasetId: string) => { if (pipelineCreatesNewDataset(selectedPipeline.value)) { const datasetMeta = availableItems.value.find((item: JsonConfigCache) => item.id === datasetId); if (!datasetMeta) { @@ -188,7 +201,7 @@ async function runPipelineForDatasets() { }), ); const failed = results - .map((result, i) => ({ result, datasetId: stagedDatasetIds.value[i] })) + .map((result, i) => ({ result, datasetId: runIds[i] })) .filter(({ result }) => result.status === 'rejected'); if (failed.length > 0) { @@ -204,6 +217,7 @@ async function runPipelineForDatasets() { } onBeforeMount(async () => { + stagedDatasetIds.value = preselectedDatasetIds(); unsortedPipelines.value = await getPipelineList(); availableItems.value = getAvailableItems(); }); diff --git a/client/platform/desktop/frontend/components/MultiTrainingMenu.vue b/client/platform/desktop/frontend/components/MultiTrainingMenu.vue index 8cf1cbdc8..df03f3d02 100644 --- a/client/platform/desktop/frontend/components/MultiTrainingMenu.vue +++ b/client/platform/desktop/frontend/components/MultiTrainingMenu.vue @@ -18,7 +18,7 @@ import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { itemsPerPageOptions, simplifyTrainingName } from 'dive-common/constants'; import { clientSettings } from 'dive-common/store/settings'; -import { useRouter } from 'vue-router/composables'; +import { useRoute, useRouter } from 'vue-router/composables'; import { DesktopJob, RunTraining } from 'platform/desktop/constants'; import { listResumableTrainingJobs, resumeTraining, discardResumableTraining, @@ -37,6 +37,7 @@ export default defineComponent({ } = useApi(); const { prompt } = usePrompt(); const router = useRouter(); + const route = useRoute(); const unsortedPipelines = ref({} as Pipelines); const labelFile = ref(null as File | null); @@ -60,6 +61,19 @@ export default defineComponent({ unsortedPipelines.value = await getPipelineList(); }); + /* Stage dataset ids handed off by another page (e.g. the Library selection). */ + onBeforeMount(() => { + const query = route.query.datasetIds; + const values = Array.isArray(query) ? query : [query]; + values + .flatMap((value) => (value || '').split(',')) + .forEach((id) => { + const meta = datasets.value[id]; + if (meta && meta.subType === null) { + set(data.stagedItems, id, meta); + } + }); + }); const resumableJobs = ref([] as DesktopJob[]); async function refreshResumable() { diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 011ec6f3d..63639f167 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -15,6 +15,7 @@ import ImportButton from 'dive-common/components/ImportButton.vue'; import ImportMultiCamDialog from 'dive-common/components/ImportMultiCamDialog.vue'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { useRequest } from 'dive-common/use'; +import { getResponseError } from 'vue-media-annotator/utils'; import { DataTableHeader } from 'vuetify'; import { useRouter } from 'vue-router/composables'; @@ -147,26 +148,98 @@ export default defineComponent({ pendingImportPayload.value = [await request(() => api.importMultiCam(args))]; } - async function confirmDeleteDataset(datasetId: string, datasetName: string) { + const selectedRecents = ref([] as JsonConfigCache[]); + const selectedIds = computed(() => new Set(selectedRecents.value.map((item) => item.id))); + + function isSelected(item: JsonConfigCache) { + return selectedIds.value.has(item.id); + } + + function toggleSelected(item: JsonConfigCache) { + if (isSelected(item)) { + selectedRecents.value = selectedRecents.value.filter((v) => v.id !== item.id); + } else { + selectedRecents.value = selectedRecents.value.concat([item]); + } + } + + async function confirmDeleteSelected() { + const items = selectedRecents.value; + if (items.length === 0) { + return; + } const result = await prompt({ - title: 'Warning Deleting Dataset', - text: [`Do you want to delete dataset ${datasetName}?`, - '1. Deleting dataset will not remove source media, such as images or video.', - '2. It will not remove annotations files that were imported when the dataset was created.', - '3. This will remove any annotations that bave been created in DIVE for this dataset', - '4. Use the Export button for the dataset to create a copy of the last set of annotations'], + title: `Delete ${items.length} dataset${items.length > 1 ? 's' : ''}`, + text: ['Do you want to delete the selected datasets?', + '1. Deleting datasets will not remove source media, such as images or video.', + '2. It will not remove annotations files that were imported when the datasets were created.', + '3. This will remove any annotations that have been created in DIVE for these datasets', + '4. Use the Export button for a dataset to create a copy of the last set of annotations'], + positiveButton: 'Delete', + negativeButton: 'Cancel', confirm: true, }); if (!result) { return; } - await request(() => api.deleteDataset(datasetId)); - //Now we need to update recents by removing the dataset from localStorage - removeRecents(datasetId); + const failures: { id: string; name: string; reason: string }[] = []; + // Continue through the full selection so one failure does not skip the rest + // eslint-disable-next-line no-restricted-syntax + for (const item of items) { + try { + // eslint-disable-next-line no-await-in-loop + await api.deleteDataset(item.id); + removeRecents(item.id); + } catch (err) { + failures.push({ id: item.id, name: item.name, reason: getResponseError(err) }); + } + } + const failedIds = new Set(failures.map((failure) => failure.id)); + selectedRecents.value = items.filter((item) => failedIds.has(item.id)); + if (failures.length > 0) { + const deletedCount = items.length - failures.length; + await prompt({ + title: deletedCount > 0 + ? 'Some datasets could not be deleted' + : 'Failed to delete datasets', + text: [ + deletedCount > 0 + ? `Deleted ${deletedCount} of ${items.length} datasets. The following could not be deleted:` + : 'None of the selected datasets could be deleted:', + ...failures.map((failure) => `${failure.name}: ${failure.reason}`), + ], + positiveButton: 'Okay', + }); + } } const filteredRecents = computed(() => recents.value .filter((v) => v.name.toLowerCase().indexOf((searchText.value || '').toLowerCase()) >= 0)); + const allSelected = computed(() => filteredRecents.value.length > 0 + && filteredRecents.value.every((item) => selectedIds.value.has(item.id))); + const someSelected = computed(() => filteredRecents.value.some( + (item) => selectedIds.value.has(item.id), + )); + + function toggleSelectAll() { + if (allSelected.value) { + selectedRecents.value = []; + } else { + selectedRecents.value = filteredRecents.value.slice(); + } + } + + function selectedIdsQuery() { + return { datasetIds: selectedRecents.value.map((item) => item.id).join(',') }; + } + + function runPipelineOnSelected() { + router.push({ name: 'pipeline', query: selectedIdsQuery() }); + } + + function runTrainingOnSelected() { + router.push({ name: 'training', query: selectedIdsQuery() }); + } function getTypeIcon(recent: JsonConfigCache) { if (recent.subType) { if (recent.subType === 'stereo') { @@ -217,6 +290,12 @@ export default defineComponent({ } const headers: DataTableHeader[] = [ + { + text: '', + value: 'select', + sortable: false, + width: 40, + }, { text: 'Type', value: 'type', @@ -235,12 +314,6 @@ export default defineComponent({ sort: (a: string, b: string) => parseRecentDate(b).valueOf() - parseRecentDate(a).valueOf(), width: 140, }, - { - text: '', - value: 'delete', - sortable: false, - width: 40, - }, ]; const toDisplayString = (dateString: string) => { const parsed = parseRecentDate(dateString); @@ -258,7 +331,12 @@ export default defineComponent({ openMultiCamDialog, getTypeIcon, importMedia: api.importMedia, - confirmDeleteDataset, + confirmDeleteSelected, + runPipelineOnSelected, + runTrainingOnSelected, + isSelected, + toggleSelected, + toggleSelectAll, preloadCheck, toDisplayString, resetError, @@ -266,6 +344,9 @@ export default defineComponent({ multiCamOpenType, stereo, filteredRecents, + selectedRecents, + allSelected, + someSelected, pendingImportPayload, bulkImport, searchText, @@ -470,6 +551,71 @@ export default defineComponent({ Recent +