Skip to content

Commit

Permalink
ui: add export button for usage logs (PROJQUAY-6420) (quay#2492)
Browse files Browse the repository at this point in the history
Adds tab for logs in the organization and repository views. Adds modal
for exporting the logs to either an email or callback url.
  • Loading branch information
Marcusk19 authored Feb 9, 2024
1 parent 5c44cc4 commit 5f4c15b
Show file tree
Hide file tree
Showing 9 changed files with 328 additions and 3 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ GIT_HEAD
.python-version
.pylintrc
.coverage
.coverage.*
coverage
htmlcov
.tox
Expand All @@ -30,6 +31,7 @@ test/dockerclients/.*
tmp/*
/.build-image-quay-stamp
/requirements-build.in
__pycache__/

# files generated by local dev,
# do not need to check in and can be deleted
Expand Down
34 changes: 34 additions & 0 deletions web/cypress/e2e/usage-logs.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
describe('Usage Logs Export', () => {
beforeEach(() => {
cy.exec('npm run quay:seed');
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
.then((response) => response.body.csrf_token)
.then((token) => {
cy.loginByCSRF(token);
});
});

it('exports repository logs', () => {
cy.intercept(
'POST',
'api/v1/repository/user1/hello-world/exportlogs?starttime=$endtime=',
).as('exportRepositoryLogs');
cy.visit('/repository/user1/hello-world');
cy.contains('Logs').click();
cy.contains('Export').click();
cy.get('[id="export-logs-callback"]').type('[email protected]');
cy.contains('Confirm').click();
cy.contains('Logs exported with id').should('be.visible');
});
it('exports repository logs failure', () => {
cy.intercept(
'POST',
'api/v1/repository/user1/hello-world/exportlogs?starttime=$endtime=',
).as('exportRepositoryLogs');
cy.visit('/repository/user1/hello-world');
cy.contains('Logs').click();
cy.contains('Export').click();
cy.get('[id="export-logs-callback"]').type('blahblah');
cy.contains('Confirm').should('be.disabled');
});
});
36 changes: 36 additions & 0 deletions web/src/hooks/UseExportLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import axios from 'src/libs/axios';
import {ResourceError} from 'src/resources/ErrorHandling';

export async function exportLogs(
org: string,
repo: string = null,
starttime: string,
endtime: string,
callback: string,
) {
const exportLogsCallback = {};
if (callback.includes('@')) {
exportLogsCallback['callback_email'] = callback;
} else {
exportLogsCallback['callback_url'] = callback;
}

const url = repo
? `/api/v1/repository/${org}/${repo}/exportlogs?starttime=${starttime}&endtime=${endtime}`
: `/api/v1/organization/${org}/exportlogs?starttime=${starttime}&endtime=${endtime}`;

try {
const response = await axios.post(url, exportLogsCallback);
return response.data;
} catch (error) {
if (error.response) {
return error.response.data;
} else {
throw new ResourceError(
'Unable to export logs',
error.response.status,
error.response.data,
);
}
}
}
12 changes: 12 additions & 0 deletions web/src/routes/OrganizationsList/Organization/Organization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {useOrganization} from 'src/hooks/UseOrganization';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import RepositoriesList from 'src/routes/RepositoriesList/RepositoriesList';
import RobotAccountsList from 'src/routes/RepositoriesList/RobotAccountsList';
import UsageLogs from 'src/routes/UsageLogs/UsageLogs';
import CreatePermissionDrawer from './Tabs/DefaultPermissions/createPermissionDrawer/CreatePermissionDrawer';
import DefaultPermissionsList from './Tabs/DefaultPermissions/DefaultPermissionsList';
import Settings from './Tabs/Settings/Settings';
Expand Down Expand Up @@ -133,6 +134,17 @@ export default function Organization() {
),
visible: !isUserOrganization && organization?.is_admin,
},
{
name: 'Logs',
component: (
<UsageLogs
organization={organizationName}
repository={null}
type="org"
/>
),
visible: organization?.is_admin || !isUserOrganization,
},
{
name: 'Settings',
component: (
Expand Down

This file was deleted.

11 changes: 11 additions & 0 deletions web/src/routes/RepositoryDetails/RepositoryDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import Settings from './Settings/Settings';
import TagHistory from './TagHistory/TagHistory';
import TagsList from './Tags/TagsList';
import {DrawerContentType} from './Types';
import UsageLogs from '../UsageLogs/UsageLogs';

enum TabIndex {
Tags = 'tags',
Expand Down Expand Up @@ -266,6 +267,16 @@ export default function RepositoryDetails() {
repoDetails={repoDetails}
/>
</Tab>
<Tab
eventKey={TabIndex.Logs}
title={<TabTitleText>Logs</TabTitleText>}
>
<UsageLogs
organization={organization}
repository={repository}
type="repository"
/>
</Tab>
<Tab
eventKey={TabIndex.Builds}
title={<TabTitleText>Builds</TabTitleText>}
Expand Down
99 changes: 99 additions & 0 deletions web/src/routes/UsageLogs/UsageLogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import {DatePicker, Split, SplitItem} from '@patternfly/react-core';
import ExportLogsModal from './UsageLogsExportModal';
import './css/UsageLogs.scss';

interface UsageLogsProps {
organization: string;
repository: string;
type: string;
}

function defaultStartDate() {
// should be 1 month before current date
const currentDate = new Date();
currentDate.setMonth(currentDate.getMonth() - 1);

const year = currentDate.getFullYear();
const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');
const day = currentDate.getDate().toString().padStart(2, '0');
const formattedDate = `${year}-${month}-${day}`;

return formattedDate;
}

function defaultEndDate() {
// should be current date
const date = new Date();

const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = (date.getDate() + 1).toString().padStart(2, '0');
const formattedDate = `${year}-${month}-${day}`;

return formattedDate;
}

function formatDate(date: string) {
/**
* change date string from y-m-d to m%d%y for api
*/
const dates = date.split('-');
const year = dates[0];
const month = dates[1];
const day = dates[2];

return `${month}/${day}/${year}`;
}

export default function UsageLogs(props: UsageLogsProps) {
const [logStartDate, setLogStartDate] = React.useState<string>(
formatDate(defaultStartDate()),
);
const [logEndDate, setLogEndDate] = React.useState<string>(
formatDate(defaultEndDate()),
);
const minDate = new Date(defaultStartDate());
const maxDate = new Date(defaultEndDate());
const rangeValidator = (date: Date) => {
if (date < minDate) {
return 'Date is before the allowable range';
} else if (date > maxDate) {
return 'Date is after the allowable range';
}
return '';
};

return (
<Split hasGutter className="usage-logs-header">
<SplitItem isFilled></SplitItem>
<SplitItem>
<DatePicker
value={logStartDate}
onChange={(_event, str, date) => {
setLogStartDate(formatDate(str));
}}
validators={[rangeValidator]}
/>
</SplitItem>
<SplitItem>
<DatePicker
value={logEndDate}
onChange={(_event, str, date) => {
setLogEndDate(formatDate(str));
}}
validators={[rangeValidator]}
/>
</SplitItem>
<SplitItem>
<ExportLogsModal
organization={props.organization}
repository={props.repository}
starttime={logStartDate}
endtime={logEndDate}
type={props.type}
/>
</SplitItem>
</Split>
);
}
127 changes: 127 additions & 0 deletions web/src/routes/UsageLogs/UsageLogsExportModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import {
Button,
Text,
Modal,
ModalVariant,
TextInput,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';

import {exportLogs} from 'src/hooks/UseExportLogs';
import {useAlerts} from 'src/hooks/UseAlerts';
import {AlertVariant} from 'src/atoms/AlertState';

export default function ExportLogsModal(props: ExportLogsModalProps) {
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [callbackEmailOrUrl, setCallbackEmailOrUrl] = React.useState('');
const [userInputValidated, setUserInputValidated] = React.useState<
'success' | 'warning' | 'error' | 'default'
>('default');
const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => {
setIsModalOpen(!isModalOpen);
};
const {addAlert} = useAlerts();

const exportLogsClick = (callback: string) => {
return exportLogs(
props.organization,
props.repository,
props.starttime,
props.endtime,
callback,
);
};

const validateUserInput = (userInput: string) => {
return /(http(s)?:.+)|.+@.+/.test(userInput);
};

const validateDate = () => {
if (new Date(props.endtime) < new Date(props.starttime)) return false;
return true;
};

return (
<React.Fragment>
<Button
variant="primary"
onClick={handleModalToggle}
isDisabled={!validateDate()}
>
Export
</Button>
<Modal
variant={ModalVariant.medium}
title="Export Usage Logs"
isOpen={isModalOpen}
onClose={handleModalToggle}
actions={[
<Button
key="confirm"
variant="primary"
onClick={() =>
exportLogsClick(callbackEmailOrUrl).then((response) => {
if (response['export_id']) {
addAlert({
variant: AlertVariant.Success,
title: `Logs exported with id ${response['export_id']}`,
});
setIsModalOpen(false);
} else {
addAlert({
variant: AlertVariant.Failure,
title: `Problem exporting logs: ${response['error_message']}`,
});
setIsModalOpen(false);
}
})
}
isDisabled={userInputValidated === 'error'}
>
{' '}
Confirm
</Button>,
<Button key="cancel" variant="link" onClick={handleModalToggle}>
Cancel
</Button>,
]}
>
<Text>
Enter an e-mail address or callback URL (must start with http:// or
https://) at which to receive the exported logs once they have been
fully processed:
</Text>
<TextInput
id="export-logs-callback"
value={callbackEmailOrUrl}
type="text"
onChange={(_event, callbackEmailOrUrl) => {
if (validateUserInput(callbackEmailOrUrl))
setUserInputValidated('success');
else setUserInputValidated('error');
setCallbackEmailOrUrl(callbackEmailOrUrl);
}}
validated={userInputValidated}
/>
<HelperText>
<HelperTextItem variant="indeterminate">
{' '}
Note: The export process can take up to an hour to process if there
are many logs. As well, only a single export process can run at a
time for each namespace. Additional export requests will be queued.{' '}
</HelperTextItem>
</HelperText>
</Modal>
</React.Fragment>
);
}

interface ExportLogsModalProps {
organization: string;
repository: string;
starttime: string;
endtime: string;
type: string;
}
7 changes: 7 additions & 0 deletions web/src/routes/UsageLogs/css/UsageLogs.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.usage-logs-header {
margin: 20px;
}

.usage-logs {
margin: 20px;
}

0 comments on commit 5f4c15b

Please sign in to comment.