diff --git a/.gitignore b/.gitignore index 5f2127af81..199876f326 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ GIT_HEAD .python-version .pylintrc .coverage +.coverage.* coverage htmlcov .tox @@ -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 diff --git a/web/cypress/e2e/usage-logs.cy.ts b/web/cypress/e2e/usage-logs.cy.ts new file mode 100644 index 0000000000..33093e6aba --- /dev/null +++ b/web/cypress/e2e/usage-logs.cy.ts @@ -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('example@example.com'); + 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'); + }); +}); diff --git a/web/src/hooks/UseExportLogs.ts b/web/src/hooks/UseExportLogs.ts new file mode 100644 index 0000000000..51125c534f --- /dev/null +++ b/web/src/hooks/UseExportLogs.ts @@ -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, + ); + } + } +} diff --git a/web/src/routes/OrganizationsList/Organization/Organization.tsx b/web/src/routes/OrganizationsList/Organization/Organization.tsx index a818aeda00..d9b8658068 100644 --- a/web/src/routes/OrganizationsList/Organization/Organization.tsx +++ b/web/src/routes/OrganizationsList/Organization/Organization.tsx @@ -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'; @@ -133,6 +134,17 @@ export default function Organization() { ), visible: !isUserOrganization && organization?.is_admin, }, + { + name: 'Logs', + component: ( + + ), + visible: organization?.is_admin || !isUserOrganization, + }, { name: 'Settings', component: ( diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx deleted file mode 100644 index a89176418b..0000000000 --- a/web/src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function UsageLogsTab() { - return

UsageLogsTab

; -} diff --git a/web/src/routes/RepositoryDetails/RepositoryDetails.tsx b/web/src/routes/RepositoryDetails/RepositoryDetails.tsx index fd92e6d7d4..e07d8375f4 100644 --- a/web/src/routes/RepositoryDetails/RepositoryDetails.tsx +++ b/web/src/routes/RepositoryDetails/RepositoryDetails.tsx @@ -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', @@ -266,6 +267,16 @@ export default function RepositoryDetails() { repoDetails={repoDetails} /> + Logs} + > + + Builds} diff --git a/web/src/routes/UsageLogs/UsageLogs.tsx b/web/src/routes/UsageLogs/UsageLogs.tsx new file mode 100644 index 0000000000..e2472752d5 --- /dev/null +++ b/web/src/routes/UsageLogs/UsageLogs.tsx @@ -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( + formatDate(defaultStartDate()), + ); + const [logEndDate, setLogEndDate] = React.useState( + 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 ( + + + + { + setLogStartDate(formatDate(str)); + }} + validators={[rangeValidator]} + /> + + + { + setLogEndDate(formatDate(str)); + }} + validators={[rangeValidator]} + /> + + + + + + ); +} diff --git a/web/src/routes/UsageLogs/UsageLogsExportModal.tsx b/web/src/routes/UsageLogs/UsageLogsExportModal.tsx new file mode 100644 index 0000000000..d53af27484 --- /dev/null +++ b/web/src/routes/UsageLogs/UsageLogsExportModal.tsx @@ -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 ( + + + + 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 + , + , + ]} + > + + 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: + + { + if (validateUserInput(callbackEmailOrUrl)) + setUserInputValidated('success'); + else setUserInputValidated('error'); + setCallbackEmailOrUrl(callbackEmailOrUrl); + }} + validated={userInputValidated} + /> + + + {' '} + 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.{' '} + + + + + ); +} + +interface ExportLogsModalProps { + organization: string; + repository: string; + starttime: string; + endtime: string; + type: string; +} diff --git a/web/src/routes/UsageLogs/css/UsageLogs.scss b/web/src/routes/UsageLogs/css/UsageLogs.scss new file mode 100644 index 0000000000..63244b4dbc --- /dev/null +++ b/web/src/routes/UsageLogs/css/UsageLogs.scss @@ -0,0 +1,7 @@ +.usage-logs-header { + margin: 20px; +} + +.usage-logs { + margin: 20px; +}