Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(archive): revamp archive-upload view #532

Merged
merged 18 commits into from
Oct 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 103 additions & 9 deletions src/app/Archives/ArchiveUploadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,49 @@
*/
import * as React from 'react';
import { Prompt } from 'react-router-dom';
import { ActionGroup, Button, FileUpload, Form, FormGroup, Modal, ModalVariant } from '@patternfly/react-core';
import {
ActionGroup,
Button,
ExpandableSection,
FileUpload,
Form,
FormGroup,
Modal,
ModalVariant,
Popover,
Text,
Tooltip,
ValidatedOptions,
} from '@patternfly/react-core';
import { first } from 'rxjs/operators';
import { ServiceContext } from '@app/Shared/Services/Services';
import { NotificationsContext } from '@app/Notifications/Notifications';
import { CancelUploadModal } from '@app/Modal/CancelUploadModal';
import { RecordingLabelFields } from '@app/RecordingMetadata/RecordingLabelFields';
import { HelpIcon } from '@patternfly/react-icons';
import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel';
import { from, Observable } from 'rxjs';

export const parseLabels = (file: File): Observable<RecordingLabel[]> => {
return from(
file
.text()
.then(JSON.parse)
.then((obj) => {
const labels: RecordingLabel[] = [];
const labelObj = obj['labels'];
if (labelObj) {
Object.keys(labelObj).forEach((key) => {
labels.push({
key: key,
value: labelObj[key],
});
});
}
return labels;
})
);
};

export interface ArchiveUploadModalProps {
visible: boolean;
Expand All @@ -51,12 +89,25 @@ export interface ArchiveUploadModalProps {
export const ArchiveUploadModal: React.FunctionComponent<ArchiveUploadModalProps> = (props) => {
const context = React.useContext(ServiceContext);
const notifications = React.useContext(NotificationsContext);

const [uploadFile, setUploadFile] = React.useState(undefined as File | undefined);
const [filename, setFilename] = React.useState('' as string | undefined);
const [uploading, setUploading] = React.useState(false);
const [rejected, setRejected] = React.useState(false);
const [showCancelPrompt, setShowCancelPrompt] = React.useState(false);
const [abort, setAbort] = React.useState(new AbortController());
const [labels, setLabels] = React.useState([] as RecordingLabel[]);
const [valid, setValid] = React.useState(ValidatedOptions.success);

const getFormattedLabels = React.useCallback(() => {
const formattedLabels = {};
labels.forEach((l) => {
if (l.key && l.value) {
formattedLabels[l.key] = l.value;
}
});
return formattedLabels;
}, [labels]);

const reset = React.useCallback(() => {
setUploadFile(undefined);
Expand All @@ -65,7 +116,9 @@ export const ArchiveUploadModal: React.FunctionComponent<ArchiveUploadModalProps
setRejected(true);
setShowCancelPrompt(false);
setAbort(new AbortController());
}, [setUploadFile, setFilename, setUploading, setRejected, setShowCancelPrompt, setAbort]);
setLabels([] as RecordingLabel[]);
setValid(ValidatedOptions.success);
}, [setUploadFile, setFilename, setUploading, setRejected, setShowCancelPrompt, setAbort, setLabels, setValid]);

const handleFileChange = React.useCallback(
(file, filename) => {
Expand All @@ -89,22 +142,25 @@ export const ArchiveUploadModal: React.FunctionComponent<ArchiveUploadModalProps
reset();
props.onClose();
}
}, [uploading, setShowCancelPrompt, reset]);
}, [uploading, setShowCancelPrompt, reset, props.onClose]);

const handleSubmit = React.useCallback(() => {
if (!uploadFile) {
notifications.warning('Attempted to submit JFR upload without a file selected');
return;
}
setUploading(true);
context.api.uploadRecording(uploadFile, abort.signal).pipe(first()).subscribe(handleClose, reset);
}, [context.api, notifications, setUploading, uploadFile, abort, handleClose, reset]);
context.api
.uploadRecording(uploadFile, getFormattedLabels(), abort.signal)
.pipe(first())
.subscribe(handleClose, reset);
}, [context.api, notifications, setUploading, uploadFile, abort.signal, handleClose, reset, getFormattedLabels]);
tthvo marked this conversation as resolved.
Show resolved Hide resolved

const handleAbort = React.useCallback(() => {
abort.abort();
reset();
props.onClose();
}, [abort, reset, handleClose]);
}, [abort.abort, reset, props.onClose]);

return (
<>
Expand All @@ -115,7 +171,28 @@ export const ArchiveUploadModal: React.FunctionComponent<ArchiveUploadModalProps
showClose={true}
onClose={handleClose}
title="Re-Upload Archived Recording"
description="Select a JDK Flight Recorder file to re-upload. Files must be .jfr binary format and follow the naming convention used by Cryostat when archiving recordings."
description={
<Text>
<span>
Select a JDK Flight Recorder file to re-upload. Files must be .jfr binary format and follow the naming
convention used by Cryostat when archiving recordings
</span>{' '}
<Tooltip
content={
<Text>
Archive naming conventions: <b>target-name_recordingName_timestamp.jfr</b>.
<br />
For example: io-cryostat-Cryostat_profiling_timestamp.jfr
</Text>
}
>
<sup style={{ cursor: 'pointer' }}>
<b>[?]</b>
</sup>
</Tooltip>
<span>.</span>
</Text>
}
>
<CancelUploadModal
visible={showCancelPrompt}
Expand All @@ -124,7 +201,7 @@ export const ArchiveUploadModal: React.FunctionComponent<ArchiveUploadModalProps
onYes={handleAbort}
onNo={() => setShowCancelPrompt(false)}
/>
<Form>
<Form isHorizontal>
<FormGroup label="JFR File" isRequired fieldId="file" validated={rejected ? 'error' : 'default'}>
<FileUpload
id="file-upload"
Expand All @@ -139,8 +216,25 @@ export const ArchiveUploadModal: React.FunctionComponent<ArchiveUploadModalProps
}}
/>
</FormGroup>
<ExpandableSection toggleTextExpanded="Hide metadata options" toggleTextCollapsed="Show metadata options">
<FormGroup
label="Labels"
fieldId="labels"
labelIcon={
<Tooltip content={<Text>Unique key-value pairs containing information about the recording.</Text>}>
<HelpIcon noVerticalAlign />
</Tooltip>
}
>
<RecordingLabelFields isUploadable labels={labels} setLabels={setLabels} setValid={setValid} />
</FormGroup>
</ExpandableSection>
<ActionGroup>
<Button variant="primary" onClick={handleSubmit} isDisabled={!filename}>
<Button
variant="primary"
onClick={handleSubmit}
isDisabled={!filename || valid !== ValidatedOptions.success}
>
Submit
</Button>
<Button variant="link" onClick={handleClose}>
Expand Down
26 changes: 13 additions & 13 deletions src/app/Archives/Archives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ import { Target } from '@app/Shared/Services/Target.service';
import { of } from 'rxjs';
import { UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service';

/*
This specific target is used as the "source" for the Uploads version of the ArchivedRecordingsTable.
The connectUrl is the 'uploads' because for actions performed on uploaded archived recordings,
the backend issues a notification with the "target" field set to the 'uploads', signalling that
these recordings are not associated with any target. We can then match on the 'uploads' when performing
notification handling in the ArchivedRecordingsTable.
*/
export const uploadAsTarget: Target = {
connectUrl: UPLOADS_SUBDIRECTORY,
alias: '',
};

export const Archives = () => {
const context = React.useContext(ServiceContext);
const [activeTab, setActiveTab] = React.useState(0);
Expand All @@ -56,26 +68,14 @@ export const Archives = () => {
return () => sub.unsubscribe();
}, [context.api]);

/*
This specific target is used as the "source" for the Uploads version of the ArchivedRecordingsTable.
The connectUrl is the empty string because for actions performed on uploaded archived recordings,
the backend issues a notification with the "target" field set to the empty string, signalling that
these recordings are not associated with any target. We can then match on the empty string when performing
notification handling in the ArchivedRecordingsTable.
*/
const target: Target = {
connectUrl: UPLOADS_SUBDIRECTORY,
alias: '',
};

const cardBody = React.useMemo(() => {
return archiveEnabled ? (
<Tabs id="archives" activeKey={activeTab} onSelect={(evt, idx) => setActiveTab(Number(idx))}>
<Tab id="all-targets" eventKey={0} title="All Targets">
<AllTargetsArchivedRecordingsTable />
</Tab>
<Tab id="uploads" eventKey={1} title="Uploads">
<ArchivedRecordingsTable target={of(target)} isUploadsTable={true} isNestedTable={false} />
<ArchivedRecordingsTable target={of(uploadAsTarget)} isUploadsTable={true} isNestedTable={false} />
</Tab>
</Tabs>
) : (
Expand Down
2 changes: 1 addition & 1 deletion src/app/CreateRecording/CustomRecordingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export const CustomRecordingForm = (props) => {
</Tooltip>
}
>
<RecordingLabelFields labels={labels} setLabels={setLabels} valid={labelsValid} setValid={setLabelsValid} />
<RecordingLabelFields labels={labels} setLabels={setLabels} setValid={setLabelsValid} />
</FormGroup>
</ExpandableSection>
<ExpandableSection toggleTextExpanded="Hide advanced options" toggleTextCollapsed="Show advanced options">
Expand Down
Loading