diff --git a/src/app/Archives/AllArchivedRecordingsTable.tsx b/src/app/Archives/AllArchivedRecordingsTable.tsx new file mode 100644 index 000000000..25c367db9 --- /dev/null +++ b/src/app/Archives/AllArchivedRecordingsTable.tsx @@ -0,0 +1,321 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import * as React from 'react'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + SearchInput, + Badge, + EmptyState, + EmptyStateIcon, + Text, + Title, + Tooltip, + Split, + SplitItem, +} from '@patternfly/react-core'; +import { TableComposable, Th, Thead, Tbody, Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; +import { of } from 'rxjs'; +import { LoadingView } from '@app/LoadingView/LoadingView'; +import { RecordingDirectory } from '@app/Shared/Services/Api.service'; +import { getTargetFromDirectory, includesDirectory, indexOfDirectory } from './ArchiveDirectoryUtil'; +import { HelpIcon } from '@patternfly/react-icons'; + +export interface AllArchivedRecordingsTableProps {} + +export const AllArchivedRecordingsTable: React.FunctionComponent = () => { + const context = React.useContext(ServiceContext); + + const [directories, setDirectories] = React.useState([] as RecordingDirectory[]); + const [counts, setCounts] = React.useState(new Map()); + const [searchText, setSearchText] = React.useState(''); + const [searchedDirectories, setSearchedDirectories] = React.useState([] as RecordingDirectory[]); + const [expandedDirectories, setExpandedDirectories] = React.useState([] as RecordingDirectory[]); + const [isLoading, setIsLoading] = React.useState(false); + const addSubscription = useSubscriptions(); + + const tableColumns: string[] = React.useMemo(() => ['Directory', 'Count'], []); + + const handleDirectoriesAndCounts = React.useCallback( + (directories: RecordingDirectory[]) => { + const updatedDirectories: RecordingDirectory[] = []; + const updatedCounts = new Map(); + for (const dir of directories) { + updatedDirectories.push(dir); + updatedCounts.set(dir.connectUrl, dir.recordings.length as number); + } + setDirectories(updatedDirectories); + setCounts(updatedCounts); + setIsLoading(false); + }, + [setDirectories, setCounts, setIsLoading] + ); + + const refreshDirectoriesAndCounts = React.useCallback(() => { + setIsLoading(true); + addSubscription( + context.api.doGet('fs/recordings', 'beta').subscribe(handleDirectoriesAndCounts) + ); + }, [addSubscription, context.api, setIsLoading, handleDirectoriesAndCounts]); + + const handleSearchInput = React.useCallback( + (searchInput) => { + setSearchText(searchInput); + }, + [setSearchText] + ); + + const handleSearchInputClear = React.useCallback(() => { + handleSearchInput(''); + }, [handleSearchInput]); + + React.useEffect(() => { + refreshDirectoriesAndCounts(); + }, [refreshDirectoriesAndCounts]); + + React.useEffect(() => { + let updatedSearchedDirectories: RecordingDirectory[]; + if (!searchText) { + updatedSearchedDirectories = directories; + } else { + const formattedSearchText = searchText.trim().toLowerCase(); + updatedSearchedDirectories = directories.filter( + (d: RecordingDirectory) => + d.jvmId.toLowerCase().includes(formattedSearchText) || + d.connectUrl.toLowerCase().includes(formattedSearchText) + ); + } + setSearchedDirectories(updatedSearchedDirectories); + }, [searchText, directories, setSearchedDirectories]); + + React.useEffect(() => { + if (!context.settings.autoRefreshEnabled()) { + return; + } + const id = window.setInterval( + () => refreshDirectoriesAndCounts(), + context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits() + ); + return () => window.clearInterval(id); + }, [context.settings, refreshDirectoriesAndCounts]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.RecordingMetadataUpdated).subscribe((v) => { + refreshDirectoriesAndCounts(); + }) + ); + }, [addSubscription, context.notificationChannel, refreshDirectoriesAndCounts]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.ActiveRecordingSaved).subscribe((v) => { + refreshDirectoriesAndCounts(); + }) + ); + }, [addSubscription, context.notificationChannel, refreshDirectoriesAndCounts]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.ArchivedRecordingDeleted).subscribe((v) => { + refreshDirectoriesAndCounts(); + }) + ); + }, [addSubscription, context.notificationChannel, refreshDirectoriesAndCounts]); + + const toggleExpanded = React.useCallback( + (dir) => { + const idx = indexOfDirectory(expandedDirectories, dir); + setExpandedDirectories((prevExpandedDirectories) => + idx >= 0 + ? [ + ...prevExpandedDirectories.slice(0, idx), + ...prevExpandedDirectories.slice(idx + 1, prevExpandedDirectories.length), + ] + : [...prevExpandedDirectories, dir] + ); + }, + [expandedDirectories, setExpandedDirectories] + ); + + const isHidden = React.useMemo(() => { + return directories.map((dir) => { + return !includesDirectory(searchedDirectories, dir) || (counts.get(dir.connectUrl) || 0) === 0; + }); + }, [directories, searchedDirectories, counts]); + + const directoryRows = React.useMemo(() => { + return directories.map((dir, idx) => { + let isExpanded: boolean = includesDirectory(expandedDirectories, dir); + + const handleToggle = () => { + if ((counts.get(dir.connectUrl) || 0) !== 0 || isExpanded) { + toggleExpanded(dir); + } + }; + + return ( + + + + + + {dir.connectUrl} + + + + + + + + {counts.get(dir.connectUrl) || 0} + + + ); + }); + }, [directories, expandedDirectories, counts, isHidden]); + + const recordingRows = React.useMemo(() => { + return directories.map((dir, idx) => { + let isExpanded: boolean = includesDirectory(expandedDirectories, dir); + + return ( + + + {isExpanded ? ( + + + + ) : null} + + + ); + }); + }, [directories, expandedDirectories, isHidden]); + + const rowPairs = React.useMemo(() => { + let rowPairs: JSX.Element[] = []; + for (let i = 0; i < directoryRows.length; i++) { + rowPairs.push(directoryRows[i]); + rowPairs.push(recordingRows[i]); + } + return rowPairs; + }, [directoryRows, recordingRows]); + + const noDirectories = React.useMemo(() => { + return isHidden.reduce((a, b) => a && b, true); + }, [isHidden]); + + let view: JSX.Element; + if (isLoading) { + view = ; + } else if (noDirectories) { + view = ( + <> + + + + No Archived Recordings + + + + ); + } else { + view = ( + <> + + + + + {tableColumns.map((key) => ( + + {key} + + ))} + + + {rowPairs} + + + ); + } + + return ( + <> + + + + + + + + + + {view} + + ); +}; diff --git a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx index 1af035417..09d23f9bc 100644 --- a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx +++ b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx @@ -364,7 +364,7 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent - + @@ -383,7 +383,7 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent - + @@ -398,12 +398,12 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent diff --git a/src/app/Archives/ArchiveDirectoryUtil.tsx b/src/app/Archives/ArchiveDirectoryUtil.tsx new file mode 100644 index 000000000..956db3a9f --- /dev/null +++ b/src/app/Archives/ArchiveDirectoryUtil.tsx @@ -0,0 +1,61 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RecordingDirectory } from '@app/Shared/Services/Api.service'; +import { Target } from '@app/Shared/Services/Target.service'; + +export const includesDirectory = (arr: RecordingDirectory[], dir: RecordingDirectory): boolean => { + return arr.some((t) => t.connectUrl === dir.connectUrl); +}; + +export const indexOfDirectory = (arr: RecordingDirectory[], dir: RecordingDirectory): number => { + let index = -1; + arr.forEach((d, idx) => { + if (d.connectUrl === dir.connectUrl) { + index = idx; + } + }); + return index; +}; + +export const getTargetFromDirectory = (dir: RecordingDirectory): Target => { + return { + connectUrl: dir.connectUrl, + alias: dir.jvmId, + }; +}; diff --git a/src/app/Archives/Archives.tsx b/src/app/Archives/Archives.tsx index a08406383..4975500ab 100644 --- a/src/app/Archives/Archives.tsx +++ b/src/app/Archives/Archives.tsx @@ -39,6 +39,7 @@ import * as React from 'react'; import { ServiceContext } from '@app/Shared/Services/Services'; import { Card, CardBody, EmptyState, EmptyStateIcon, Tab, Tabs, Title } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; +import { AllArchivedRecordingsTable } from './AllArchivedRecordingsTable'; import { AllTargetsArchivedRecordingsTable } from './AllTargetsArchivedRecordingsTable'; import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; @@ -77,7 +78,10 @@ export const Archives: React.FunctionComponent = () => { - + + + + diff --git a/src/app/RecordingMetadata/BulkEditLabels.tsx b/src/app/RecordingMetadata/BulkEditLabels.tsx index 5638c3132..03e32f53b 100644 --- a/src/app/RecordingMetadata/BulkEditLabels.tsx +++ b/src/app/RecordingMetadata/BulkEditLabels.tsx @@ -39,7 +39,13 @@ import * as React from 'react'; import { Button, Split, SplitItem, Stack, StackItem, Text, Tooltip, ValidatedOptions } from '@patternfly/react-core'; import { ServiceContext } from '@app/Shared/Services/Services'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { ActiveRecording, ArchivedRecording, Recording, UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service'; +import { + ActiveRecording, + ArchivedRecording, + Recording, + RecordingDirectory, + UPLOADS_SUBDIRECTORY, +} from '@app/Shared/Services/Api.service'; import { includesLabel, parseLabels, RecordingLabel } from './RecordingLabel'; import { combineLatest, concatMap, filter, first, forkJoin, map, merge, Observable, of } from 'rxjs'; import { LabelCell } from '@app/RecordingMetadata/LabelCell'; @@ -54,6 +60,8 @@ export interface BulkEditLabelsProps { isTargetRecording: boolean; isUploadsTable?: boolean; checkedIndices: number[]; + directory?: RecordingDirectory; + directoryRecordings?: ArchivedRecording[]; } export const BulkEditLabels: React.FunctionComponent = (props) => { @@ -81,26 +89,34 @@ export const BulkEditLabels: React.FunctionComponent = (pro updatedLabels = updatedLabels.filter((label) => { return !includesLabel(toDelete, label); }); - tasks.push( - props.isTargetRecording - ? context.api.postTargetRecordingMetadata(r.name, updatedLabels).pipe(first()) - : props.isUploadsTable - ? context.api.postUploadedRecordingMetadata(r.name, updatedLabels).pipe(first()) - : context.api.postRecordingMetadata(r.name, updatedLabels).pipe(first()) - ); + if (props.directory) { + tasks.push( + context.api.postRecordingMetadataFromPath(props.directory.jvmId, r.name, updatedLabels).pipe(first()) + ); + } + if (props.isTargetRecording) { + tasks.push(context.api.postTargetRecordingMetadata(r.name, updatedLabels).pipe(first())); + } else if (props.isUploadsTable) { + tasks.push(context.api.postUploadedRecordingMetadata(r.name, updatedLabels).pipe(first())); + } else { + tasks.push(context.api.postRecordingMetadata(r.name, updatedLabels).pipe(first())); + } } }); addSubscription(forkJoin(tasks).subscribe(() => setEditing((editing) => !editing))); }, [ + addSubscription, recordings, props.checkedIndices, props.isTargetRecording, + props.isUploadsTable, + props.directory, + props.directoryRecordings, editing, setEditing, commonLabels, savedCommonLabels, parseLabels, - context, context.api, ]); @@ -139,7 +155,9 @@ export const BulkEditLabels: React.FunctionComponent = (pro const refreshRecordingList = React.useCallback(() => { let observable: Observable; - if (props.isTargetRecording) { + if (props.directoryRecordings) { + observable = of(props.directoryRecordings); + } else if (props.isTargetRecording) { observable = context.target.target().pipe( filter((target) => target !== NO_TARGET), concatMap((target) => @@ -200,6 +218,7 @@ export const BulkEditLabels: React.FunctionComponent = (pro addSubscription, props.isTargetRecording, props.isUploadsTable, + props.directoryRecordings, context, context.target, context.api, @@ -230,7 +249,7 @@ export const BulkEditLabels: React.FunctionComponent = (pro ); }) ); - }, [addSubscription, context, context.notificationChannel, setRecordings]); + }, [addSubscription, context.notificationChannel, setRecordings]); React.useEffect(() => { updateCommonLabels(setCommonLabels); diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index d24e1fb60..37d78105f 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -36,7 +36,12 @@ * SOFTWARE. */ import * as React from 'react'; -import { ArchivedRecording, UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service'; +import { + ArchivedRecording, + Recording, + RecordingDirectory, + UPLOADS_SUBDIRECTORY, +} from '@app/Shared/Services/Api.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; import { useSubscriptions } from '@app/utils/useSubscriptions'; @@ -50,12 +55,6 @@ import { ToolbarContent, ToolbarGroup, ToolbarItem, - Text, - TextVariants, - FlexItem, - Flex, - Chip, - Badge, } from '@patternfly/react-core'; import { Tbody, Tr, Td, ExpandableRowContent, TableComposable } from '@patternfly/react-table'; import { PlusIcon } from '@patternfly/react-icons'; @@ -89,6 +88,8 @@ export interface ArchivedRecordingsTableProps { target: Observable; isUploadsTable: boolean; isNestedTable: boolean; + directory?: RecordingDirectory; + directoryRecordings?: ArchivedRecording[]; } export const ArchivedRecordingsTable: React.FunctionComponent = (props) => { @@ -186,7 +187,9 @@ export const ArchivedRecordingsTable: React.FunctionComponent { setIsLoading(true); - if (props.isUploadsTable) { + if (props.directory) { + handleRecordings(props.directoryRecordings); + } else if (props.isUploadsTable) { addSubscription( queryUploadedRecordings() .pipe(map((v) => v.data.archivedRecordings.data as ArchivedRecording[])) @@ -204,7 +207,15 @@ export const ArchivedRecordingsTable: React.FunctionComponent { dispatch(deleteAllFiltersIntent(targetConnectURL, true)); @@ -252,7 +263,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent old.concat(event.message.recording)); }) ); - }, [addSubscription, context, context.notificationChannel, setRecordings]); + }, [addSubscription, context.notificationChannel, setRecordings]); React.useEffect(() => { addSubscription( @@ -265,12 +276,11 @@ export const ArchivedRecordingsTable: React.FunctionComponent old.filter((r) => r.name !== event.message.recording.name)); setCheckedIndices((old) => old.filter((idx) => idx !== hashCode(event.message.recording.name))); }) ); - }, [addSubscription, context, context.notificationChannel, setRecordings, setCheckedIndices]); + }, [addSubscription, context.notificationChannel, setRecordings, setCheckedIndices]); React.useEffect(() => { addSubscription( @@ -320,18 +330,29 @@ export const ArchivedRecordingsTable: React.FunctionComponent { const tasks: Observable[] = []; - addSubscription( - props.target.subscribe((t) => { - filteredRecordings.forEach((r: ArchivedRecording) => { - if (checkedIndices.includes(hashCode(r.name))) { - context.reports.delete(r); - tasks.push(context.api.deleteArchivedRecording(t.connectUrl, r.name).pipe(first())); - } - }); - }) - ); - addSubscription(forkJoin(tasks).subscribe()); - }, [filteredRecordings, checkedIndices, context.reports, context.api, addSubscription]); + if (props.directory) { + const directory = props.directory; + filteredRecordings.forEach((r: ArchivedRecording) => { + if (checkedIndices.includes(hashCode(r.name))) { + context.reports.delete(r); + tasks.push(context.api.deleteArchivedRecordingFromPath(directory.jvmId, r.name).pipe(first())); + } + }); + addSubscription(forkJoin(tasks).subscribe()); + } else { + addSubscription( + props.target.subscribe((t) => { + filteredRecordings.forEach((r: ArchivedRecording) => { + if (checkedIndices.includes(hashCode(r.name))) { + context.reports.delete(r); + tasks.push(context.api.deleteArchivedRecording(t.connectUrl, r.name).pipe(first())); + } + }); + addSubscription(forkJoin(tasks).subscribe()); + }) + ); + } + }, [addSubscription, filteredRecordings, checkedIndices, context.reports, context.api, props.directory]); const toggleExpanded = React.useCallback( (id: string) => { @@ -345,7 +366,7 @@ export const ArchivedRecordingsTable: React.FunctionComponent { + const RecordingRow = (props: RecordingRowProps) => { const parsedLabels = React.useMemo(() => { return parseLabels(props.recording.metadata.labels); }, [props.recording.metadata.labels]); @@ -406,7 +427,12 @@ export const ArchivedRecordingsTable: React.FunctionComponent context.api.uploadArchivedRecordingToGrafana(props.sourceTarget, props.recording.name)} + uploadFn={ + props.directory + ? () => + context.api.uploadArchivedRecordingToGrafanaFromPath(props.directory!.jvmId, props.recording.name) + : () => context.api.uploadArchivedRecordingToGrafana(props.sourceTarget, props.recording.name) + } /> ); @@ -414,6 +440,8 @@ export const ArchivedRecordingsTable: React.FunctionComponent )); }, [filteredRecordings, expandedRows, checkedIndices]); @@ -503,9 +532,11 @@ export const ArchivedRecordingsTable: React.FunctionComponent ), - [checkedIndices, setShowDetailsPanel, props.isUploadsTable] + [checkedIndices, setShowDetailsPanel, props.isUploadsTable, props.directoryRecordings] ); const totalArchiveSize = React.useMemo(() => { @@ -554,6 +585,16 @@ export const ArchivedRecordingsTable: React.FunctionComponent ); }; + +interface RecordingRowProps { + key: string; + recording: ArchivedRecording; + labelFilters: string[]; + index: number; + sourceTarget: Observable; + directory?: RecordingDirectory; +} + export interface ArchivedRecordingsToolbarProps { target: string; checkedIndices: number[]; diff --git a/src/app/Recordings/RecordingActions.tsx b/src/app/Recordings/RecordingActions.tsx index c7a81955e..ac8ea1b0d 100644 --- a/src/app/Recordings/RecordingActions.tsx +++ b/src/app/Recordings/RecordingActions.tsx @@ -36,7 +36,7 @@ * SOFTWARE. */ import { NotificationsContext } from '@app/Notifications/Notifications'; -import { ActiveRecording } from '@app/Shared/Services/Api.service'; +import { ActiveRecording, Recording } from '@app/Shared/Services/Api.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { Target } from '@app/Shared/Services/Target.service'; import { useSubscriptions } from '@app/utils/useSubscriptions'; @@ -54,7 +54,7 @@ export interface RowAction { export interface RecordingActionsProps { index: number; - recording: ActiveRecording; + recording: Recording; sourceTarget?: Observable; uploadFn: () => Observable; } diff --git a/src/app/Recordings/RecordingLabelsPanel.tsx b/src/app/Recordings/RecordingLabelsPanel.tsx index 24571b5bb..ea7db7c56 100644 --- a/src/app/Recordings/RecordingLabelsPanel.tsx +++ b/src/app/Recordings/RecordingLabelsPanel.tsx @@ -37,6 +37,7 @@ */ import { BulkEditLabels } from '@app/RecordingMetadata/BulkEditLabels'; +import { ArchivedRecording, RecordingDirectory } from '@app/Shared/Services/Api.service'; import { DrawerActions, DrawerCloseButton, @@ -51,6 +52,8 @@ export interface RecordingLabelsPanelProps { isTargetRecording: boolean; isUploadsTable?: boolean; checkedIndices: number[]; + directory?: RecordingDirectory; + directoryRecordings?: ArchivedRecording[]; } export const RecordingLabelsPanel: React.FunctionComponent = (props) => { @@ -70,6 +73,8 @@ export const RecordingLabelsPanel: React.FunctionComponent diff --git a/src/app/Recordings/ReportFrame.tsx b/src/app/Recordings/ReportFrame.tsx index 3e6c924ce..2fd3f2dfe 100644 --- a/src/app/Recordings/ReportFrame.tsx +++ b/src/app/Recordings/ReportFrame.tsx @@ -41,6 +41,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { Spinner } from '@patternfly/react-core'; import { first } from 'rxjs/operators'; import { isGenerationError } from '@app/Shared/Services/Report.service'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; export interface ReportFrameProps extends React.HTMLProps { isExpanded: boolean; @@ -48,6 +49,7 @@ export interface ReportFrameProps extends React.HTMLProps { } export const ReportFrame: React.FunctionComponent = React.memo((props) => { + const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); const [report, setReport] = React.useState(undefined as string | undefined); const [loaded, setLoaded] = React.useState(false); @@ -57,23 +59,24 @@ export const ReportFrame: React.FunctionComponent = React.memo if (!props.isExpanded) { return; } - const sub = context.reports - .report(recording) - .pipe(first()) - .subscribe( - (report) => setReport(report), - (err) => { - if (isGenerationError(err)) { - err.messageDetail.pipe(first()).subscribe((detail) => setReport(detail)); - } else if (isHttpError(err)) { - setReport(err.message); - } else { - setReport(JSON.stringify(err)); + addSubscription( + context.reports + .report(recording) + .pipe(first()) + .subscribe( + (report) => setReport(report), + (err) => { + if (isGenerationError(err)) { + err.messageDetail.pipe(first()).subscribe((detail) => setReport(detail)); + } else if (isHttpError(err)) { + setReport(err.message); + } else { + setReport(JSON.stringify(err)); + } } - } - ); - return () => sub.unsubscribe(); - }, [context, context.reports, recording, isExpanded, setReport, props, props.isExpanded, props.recording]); + ) + ); + }, [addSubscription, context.reports, recording, isExpanded, setReport, props]); const onLoad = () => setLoaded(true); diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index b11fc82dc..599d4932f 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -400,6 +400,50 @@ export class ApiService { ); } + // from file system path functions + uploadArchivedRecordingToGrafanaFromPath(subdirectoryName: string, recordingName: string): Observable { + return this.sendRequest('beta', `fs/recordings/${subdirectoryName}/${encodeURIComponent(recordingName)}/upload`, { + method: 'POST', + }).pipe( + map((resp) => resp.ok), + first() + ); + } + deleteArchivedRecordingFromPath(subdirectoryName: string, recordingName: string): Observable { + return this.sendRequest('beta', `fs/recordings/${subdirectoryName}/${encodeURIComponent(recordingName)}`, { + method: 'DELETE', + }).pipe( + map((resp) => resp.ok), + first() + ); + } + + transformAndStringifyToRawLabels(labels: RecordingLabel[]) { + const rawLabels = {}; + for (const label of labels) { + rawLabels[label.key] = label.value; + } + return JSON.stringify(rawLabels); + } + + postRecordingMetadataFromPath( + subdirectoryName: string, + recordingName: string, + labels: RecordingLabel[] + ): Observable { + return this.sendRequest( + 'beta', + `fs/recordings/${subdirectoryName}/${encodeURIComponent(recordingName)}/metadata/labels`, + { + method: 'POST', + body: this.transformAndStringifyToRawLabels(labels), + } + ).pipe( + map((resp) => resp.ok), + first() + ); + } + deleteCustomEventTemplate(templateName: string): Observable { return this.sendRequest('v1', `templates/${encodeURIComponent(templateName)}`, { method: 'DELETE', @@ -784,6 +828,8 @@ export class ApiService { } } +export interface AllArchivesResponse {} + export interface ApiV2Response { meta: { status: string; @@ -812,6 +858,12 @@ interface CredentialsResponse extends ApiV2Response { }; } +export interface RecordingDirectory { + connectUrl: string; + jvmId: string; + recordings: ArchivedRecording[]; +} + export interface ArchivedRecording { name: string; downloadUrl: string; diff --git a/src/test/Archives/AllArchivedRecordingsTable.test.tsx b/src/test/Archives/AllArchivedRecordingsTable.test.tsx new file mode 100644 index 000000000..f48edff80 --- /dev/null +++ b/src/test/Archives/AllArchivedRecordingsTable.test.tsx @@ -0,0 +1,303 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { of } from 'rxjs'; +import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; +import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { AllArchivedRecordingsTable } from '@app/Archives/AllArchivedRecordingsTable'; +import { ArchivedRecording, RecordingDirectory } from '@app/Shared/Services/Api.service'; + +const mockConnectUrl1 = 'service:jmx:rmi://someUrl1'; +const mockJvmId1 = 'fooJvmId1'; +const mockConnectUrl2 = 'service:jmx:rmi://someUrl2'; +const mockJvmId2 = 'fooJvmId2'; +const mockConnectUrl3 = 'service:jmx:rmi://someUrl3'; +const mockJvmId3 = 'fooJvmId3'; + +const mockCount1 = 1; + +const mockRecordingSavedNotification = { + message: { + target: mockConnectUrl3, + }, +} as NotificationMessage; + +const mockRecordingDeletedNotification = { + message: { + target: mockConnectUrl1, + }, +} as NotificationMessage; + +const mockRecordingLabels = { + someLabel: 'someValue', +}; + +const mockRecording: ArchivedRecording = { + name: 'someRecording', + downloadUrl: 'http://downloadUrl', + reportUrl: 'http://reportUrl', + metadata: { labels: mockRecordingLabels }, + size: 2048, +}; + +const mockRecordingDirectory1: RecordingDirectory = { + connectUrl: mockConnectUrl1, + jvmId: mockJvmId1, + recordings: [mockRecording], +}; + +const mockRecordingDirectory2: RecordingDirectory = { + connectUrl: mockConnectUrl2, + jvmId: mockJvmId2, + recordings: [mockRecording], +}; + +const mockRecordingDirectory3: RecordingDirectory = { + connectUrl: mockConnectUrl3, + jvmId: mockJvmId3, + recordings: [mockRecording, mockRecording, mockRecording], +}; + +const mockRecordingDirectory3Removed: RecordingDirectory = { + ...mockRecordingDirectory3, + recordings: [mockRecording, mockRecording], +}; + +const mockRecordingDirectory3Added: RecordingDirectory = { + connectUrl: mockConnectUrl3, + jvmId: mockJvmId3, + recordings: [mockRecording, mockRecording, mockRecording, mockRecording], +}; + +jest.mock('@app/Recordings/ArchivedRecordingsTable', () => { + return { + ArchivedRecordingsTable: jest.fn((props) => { + return
Archived Recordings Table
; + }), + }; +}); + +jest.mock('@app/Shared/Services/Target.service', () => ({ + ...jest.requireActual('@app/Shared/Services/Target.service'), // Require actual implementation of utility functions for Target +})); + +jest + .spyOn(defaultServices.api, 'doGet') + .mockReturnValueOnce(of([])) // renders correctly + + .mockReturnValueOnce(of([])) // shows no recordings when empty + + .mockReturnValueOnce(of([mockRecordingDirectory1])) // has the correct table elements + + .mockReturnValueOnce(of([mockRecordingDirectory1, mockRecordingDirectory2, mockRecordingDirectory3])) // search function + + .mockReturnValueOnce(of([mockRecordingDirectory1, mockRecordingDirectory2, mockRecordingDirectory3])) // expands targets to show their + + // notifications trigger doGet queries + .mockReturnValueOnce(of([mockRecordingDirectory1, mockRecordingDirectory2, mockRecordingDirectory3])) // increments the count when an archived recording is saved + .mockReturnValueOnce(of([mockRecordingDirectory1, mockRecordingDirectory2, mockRecordingDirectory3Added])) + + .mockReturnValueOnce(of([mockRecordingDirectory1, mockRecordingDirectory2, mockRecordingDirectory3])) // decrements the count when an archived recording is deleted + .mockReturnValueOnce(of([mockRecordingDirectory1, mockRecordingDirectory2, mockRecordingDirectory3Removed])); + +jest + .spyOn(defaultServices.notificationChannel, 'messages') + .mockReturnValueOnce(of()) // renders correctly + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // shows no recordings when empty + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // has the correct table elements + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // correctly handles the search function + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // expands targets to show their + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockRecordingSavedNotification)) // increments the count when an archived recording is saved + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockRecordingDeletedNotification)) // decrements the count when an archived recording is deleted + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()); + +describe('', () => { + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('shows no recordings when empty', () => { + render( + + + + ); + + expect(screen.getByText('No Archived Recordings')).toBeInTheDocument(); + }); + + it('has the correct table elements', () => { + render( + + + + ); + + expect(screen.getByLabelText('all-archives-table')).toBeInTheDocument(); + expect(screen.getByText('Directory')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText(`${mockConnectUrl1}`)).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('correctly handles the search function', () => { + render( + + + + ); + + const search = screen.getByLabelText('Search input'); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + + userEvent.type(search, '1'); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(1); + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockConnectUrl1}`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1}`)).toBeTruthy(); + + userEvent.type(search, 'asdasdjhj'); + expect(screen.getByText('No Archived Recordings')).toBeInTheDocument(); + expect(screen.queryByLabelText('all-archives-table')).not.toBeInTheDocument(); + + userEvent.clear(search); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + }); + + it('expands targets to show their ', () => { + render( + + + + ); + + expect(screen.queryByText('Archived Recordings Table')).not.toBeInTheDocument(); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + + let firstTarget = rows[0]; + const expand = within(firstTarget).getByLabelText('Details'); + userEvent.click(expand); + + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(4); + + let expandedTable = rows[1]; + expect(within(expandedTable).getByText('Archived Recordings Table')).toBeTruthy(); + + userEvent.click(expand); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + expect(screen.queryByText('Archived Recordings Table')).not.toBeInTheDocument(); + }); + + it('increments the count when an archived recording is saved', () => { + render( + + + + ); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + + const thirdTarget = rows[2]; + expect(within(thirdTarget).getByText(`${mockConnectUrl3}`)).toBeTruthy(); + expect(within(thirdTarget).getByText(4)).toBeTruthy(); + }); + + it('decrements the count when an archived recording is deleted', () => { + render( + + + + ); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + + const thirdTarget = rows[2]; + expect(within(thirdTarget).getByText(`${mockConnectUrl3}`)).toBeTruthy(); + expect(within(thirdTarget).getByText(2)).toBeTruthy(); + }); +}); diff --git a/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx b/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx index 5253d50a7..ee5a027b5 100644 --- a/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx +++ b/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx @@ -246,7 +246,7 @@ describe('', () => { ); - expect(screen.getByLabelText('all-archives-table')).toBeInTheDocument(); + expect(screen.getByLabelText('all-targets-table')).toBeInTheDocument(); expect(screen.getByText('Target')).toBeInTheDocument(); expect(screen.getByText('Count')).toBeInTheDocument(); expect(screen.getByText(`${mockAlias1} (${mockConnectUrl1})`)).toBeInTheDocument(); @@ -276,7 +276,7 @@ describe('', () => { expect(within(secondTarget).getByText(`${mockAlias2} (${mockConnectUrl2})`)).toBeTruthy(); expect(within(secondTarget).getByText(`${mockCount2}`)).toBeTruthy(); - const checkbox = screen.getByLabelText('all-archives-hide-check'); + const checkbox = screen.getByLabelText('all-targets-hide-check'); userEvent.click(checkbox); tableBody = screen.getAllByRole('rowgroup')[1]; @@ -310,7 +310,7 @@ describe('', () => { userEvent.type(search, 'asdasdjhj'); expect(screen.getByText('No Targets')).toBeInTheDocument(); - expect(screen.queryByLabelText('all-archives-table')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('all-targets-table')).not.toBeInTheDocument(); userEvent.clear(search); tableBody = screen.getAllByRole('rowgroup')[1]; @@ -356,7 +356,7 @@ describe('', () => { ); - const checkbox = screen.getByLabelText('all-archives-hide-check'); + const checkbox = screen.getByLabelText('all-targets-hide-check'); userEvent.click(checkbox); let tableBody = screen.getAllByRole('rowgroup')[1]; @@ -420,7 +420,7 @@ describe('', () => { ); - const checkbox = screen.getByLabelText('all-archives-hide-check'); + const checkbox = screen.getByLabelText('all-targets-hide-check'); userEvent.click(checkbox); let tableBody = screen.getAllByRole('rowgroup')[1]; diff --git a/src/test/Archives/Archives.test.tsx b/src/test/Archives/Archives.test.tsx index 75e3589a4..53064605d 100644 --- a/src/test/Archives/Archives.test.tsx +++ b/src/test/Archives/Archives.test.tsx @@ -48,12 +48,20 @@ import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; jest.mock('@app/Recordings/ArchivedRecordingsTable', () => { return { - ArchivedRecordingsTable: jest.fn((props) => { + ArchivedRecordingsTable: jest.fn(() => { return
Uploads Table
; }), }; }); +jest.mock('@app/Archives/AllArchivedRecordingsTable', () => { + return { + AllArchivedRecordingsTable: jest.fn(() => { + return
All Archives Table
; + }), + }; +}); + jest.mock('@app/Archives/AllTargetsArchivedRecordingsTable', () => { return { AllTargetsArchivedRecordingsTable: jest.fn(() => { @@ -133,7 +141,11 @@ describe('', () => { let secondTab = tabsList[1]; expect(secondTab).toHaveAttribute('aria-selected', 'false'); - expect(within(secondTab).getByText('Uploads')).toBeTruthy(); + expect(within(secondTab).getByText('All Archives')).toBeTruthy(); + + let thirdTab = tabsList[2]; + expect(thirdTab).toHaveAttribute('aria-selected', 'false'); + expect(within(thirdTab).getByText('Uploads')).toBeTruthy(); // Click the Uploads tab userEvent.click(screen.getByText('Uploads')); @@ -146,7 +158,11 @@ describe('', () => { expect(within(firstTab).getByText('All Targets')).toBeTruthy(); secondTab = tabsList[1]; - expect(secondTab).toHaveAttribute('aria-selected', 'true'); - expect(within(secondTab).getByText('Uploads')).toBeTruthy(); + expect(secondTab).toHaveAttribute('aria-selected', 'false'); + expect(within(secondTab).getByText('All Archives')).toBeTruthy(); + + thirdTab = tabsList[2]; + expect(thirdTab).toHaveAttribute('aria-selected', 'true'); + expect(within(thirdTab).getByText('Uploads')).toBeTruthy(); }); }); diff --git a/src/test/Archives/__snapshots__/AllArchivedRecordingsTable.test.tsx.snap b/src/test/Archives/__snapshots__/AllArchivedRecordingsTable.test.tsx.snap new file mode 100644 index 000000000..f655faeeb --- /dev/null +++ b/src/test/Archives/__snapshots__/AllArchivedRecordingsTable.test.tsx.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +Array [ +
+
+
+
+
+
+
+ + + + + + + + +
+ +
+
+
+
+
+
+
+
+ , +
+
+ +

+ No Archived Recordings +

+
+
, +] +`; diff --git a/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap b/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap index b0fe1f96d..c4a823332 100644 --- a/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap +++ b/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap @@ -7,7 +7,7 @@ Array [ data-ouia-component-id="OUIA-Generated-Toolbar-1" data-ouia-component-type="PF4/Toolbar" data-ouia-safe={true} - id="all-archives-toolbar" + id="all-targets-toolbar" >
@@ -103,7 +103,7 @@ Array [
, renders correctly 1`] = ` role="presentation" > + +
  • +