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;
+}