diff --git a/cypress/e2e/awx/general-ui/dashboard-checks.cy.ts b/cypress/e2e/awx/general-ui/dashboard-checks.cy.ts index 8eb11c9dd5..6805b73009 100644 --- a/cypress/e2e/awx/general-ui/dashboard-checks.cy.ts +++ b/cypress/e2e/awx/general-ui/dashboard-checks.cy.ts @@ -1,4 +1,5 @@ import { AwxItemsResponse } from '../../../../frontend/awx/common/AwxItemsResponse'; +import { IAwxDashboardData } from '../../../../frontend/awx/dashboard/AwxDashboard'; import { Inventory } from '../../../../frontend/awx/interfaces/Inventory'; import { Job } from '../../../../frontend/awx/interfaces/Job'; import { Project } from '../../../../frontend/awx/interfaces/Project'; @@ -30,33 +31,34 @@ describe('Dashboard: General UI tests - resources count and empty state check', cy.visit(`/ui_next/dashboard`); cy.clickButton('Manage view'); cy.get('.pf-c-modal-box__title-text').should('contain', 'Manage Dashboard'); - cy.contains('tr', 'Projects').find('input').uncheck(); + cy.contains('tr', 'Resource Counts').find('input').uncheck(); cy.clickModalButton('Apply'); - cy.contains('.pf-c-card__header', 'Projects').should('not.be.visible'); + cy.contains('.pf-c-title', 'Hosts').should('not.exist'); cy.clickButton('Manage view'); cy.get('.pf-c-modal-box__title-text').should('contain', 'Manage Dashboard'); - cy.contains('tr', 'Projects').find('input').check(); + cy.contains('tr', 'Resource Counts').find('input').check(); cy.clickModalButton('Apply'); - cy.contains('.pf-c-card__header', 'Projects').should('be.visible'); + cy.contains('.pf-c-title', 'Hosts').should('be.visible'); }); it('within the Manage Dashboard modal, clicking the Cancel button should revert any changes', () => { cy.visit(`/ui_next/dashboard`); cy.clickButton('Manage view'); cy.get('.pf-c-modal-box__title-text').should('contain', 'Manage Dashboard'); - cy.contains('tr', 'Projects').find('input').uncheck(); + cy.contains('tr', 'Resource Counts').find('input').uncheck(); cy.clickModalButton('Cancel'); - cy.contains('.pf-c-card__header', 'Projects').should('be.visible'); + cy.contains('.pf-c-title', 'Hosts').should('be.visible'); }); it('within the Manage Dashboard modal, clicking the Close button should revert any changes', () => { cy.visit(`/ui_next/dashboard`); cy.clickButton('Manage view'); cy.get('.pf-c-modal-box__title-text').should('contain', 'Manage Dashboard'); - cy.contains('tr', 'Projects').find('input').uncheck(); + cy.contains('tr', 'Resource Counts').find('input').uncheck(); cy.get('[aria-label="Close"]').click(); - cy.contains('.pf-c-card__header', 'Projects').should('be.visible'); + cy.contains('.pf-c-title', 'Hosts').should('be.visible'); }); + // Manage Dashboard modal table does not currently support keyboard input to reorder items, use drag & drop it('within the Manage Dashboard modal, dragging a resource should reorder the resource', () => { let initialArray: string[]; @@ -67,7 +69,7 @@ describe('Dashboard: General UI tests - resources count and empty state check', initialArray = Array.from(headers, (title) => title.innerText.split('\n')[0]); cy.clickButton('Manage view'); cy.get('.pf-c-modal-box__title-text').should('contain', 'Manage Dashboard'); - cy.get('#draggable-row-project').drag('#draggable-row-recent_job_activity'); + cy.get('#draggable-row-recent_jobs').drag('#draggable-row-recent_job_activity'); cy.clickModalButton('Apply'); }); cy.get('.pf-c-card__header').then((headers) => { @@ -77,62 +79,58 @@ describe('Dashboard: General UI tests - resources count and empty state check', }); it('checks inventories count', () => { - cy.intercept('GET', 'api/v2/dashboard/').as('getInventories'); + cy.intercept('GET', 'api/v2/dashboard/').as('getDashboard'); cy.visit(`/ui_next/dashboard`); - cy.contains('.pf-c-card__header', 'Inventories') - .next() - .within(() => { - cy.contains('tspan', 'Ready') - .invoke('text') - .then((text: string) => { - cy.wait('@getInventories') - .its('response.body.inventories.total') - .then((total) => { - expect(total).to.equal(parseInt(text.split(':')[1])); - }); - }); + cy.wait('@getDashboard') + .its('response.body') + .then((data: IAwxDashboardData) => { + cy.get('#inventories-chart').should('contain', data.inventories.total); + const readyCount = data.inventories.total - data.inventories.inventory_failed; + if (readyCount > 0) { + cy.get('#inventories-legend-synced-count').should('contain', readyCount); + } + if (data.inventories.inventory_failed > 0) { + cy.get('#inventories-legend-failed-count').should( + 'contain', + data.inventories.inventory_failed + ); + } }); - cy.checkAnchorLinks('Go to Inventories'); }); it('checks hosts count', () => { - cy.intercept('GET', 'api/v2/dashboard/').as('getHosts'); + cy.intercept('GET', 'api/v2/dashboard/').as('getDashboard'); cy.visit(`/ui_next/dashboard`); - cy.contains('.pf-c-card__header', 'Hosts') - .next() - .within(() => { - cy.contains('tspan', 'Ready') - .invoke('text') - .then((text: string) => { - cy.wait('@getHosts') - .its('response.body.hosts.total') - .then((total) => { - expect(total).to.equal(parseInt(text.split(':')[1])); - }); - }); + cy.wait('@getDashboard') + .its('response.body') + .then((data: IAwxDashboardData) => { + cy.get('#hosts-chart').should('contain', data.hosts.total); + const readyCount = data.hosts.total - data.hosts.failed; + if (readyCount > 0) { + cy.get('#hosts-legend-ready-count').should('contain', readyCount); + } + if (data.hosts.failed > 0) { + cy.get('#hosts-legend-failed-count').should('contain', data.hosts.failed); + } }); - cy.checkAnchorLinks('Go to Hosts'); }); - // JT Disabling invalid test. Ready count does not always match the total count. - // it('checks projects count', () => { - // cy.intercept('GET', 'api/v2/dashboard/').as('getProjects'); - // cy.visit(`/ui_next/dashboard`); - // cy.contains('.pf-c-card__header', 'Projects') - // .next() - // .within(() => { - // cy.contains('tspan', 'Ready') - // .invoke('text') - // .then((text: string) => { - // cy.wait('@getProjects') - // .its('response.body.projects.total') - // .then((total) => { - // expect(total).to.equal(parseInt(text.split(':')[1])); - // }); - // }); - // }); - // cy.checkAnchorLinks('Go to Projects'); - // }); + it('checks projects count', () => { + cy.intercept('GET', 'api/v2/dashboard/').as('getDashboard'); + cy.visit(`/ui_next/dashboard`); + cy.wait('@getDashboard') + .its('response.body') + .then((data: IAwxDashboardData) => { + cy.get('#projects-chart').should('contain', data.projects.total); + const readyCount = data.projects.total - data.projects.failed; + if (readyCount > 0) { + cy.get('#projects-legend-ready-count').should('contain', readyCount); + } + if (data.projects.failed > 0) { + cy.get('#projects-legend-failed-count').should('contain', data.projects.failed); + } + }); + }); it('checks jobs count and the max # of jobs in the table', () => { cy.intercept('GET', '/api/v2/unified_jobs/?order_by=-finished&page=1&page_size=10').as( diff --git a/cypress/support/component.tsx b/cypress/support/component.tsx index 874d16c369..74bf01e191 100644 --- a/cypress/support/component.tsx +++ b/cypress/support/component.tsx @@ -15,6 +15,9 @@ // *********************************************************** import '@patternfly/patternfly/patternfly-base.css'; +import '@patternfly/patternfly/patternfly-charts.css'; + +import '@patternfly/patternfly/patternfly-charts-theme-dark.css'; import { Page } from '@patternfly/react-core'; import 'cypress-react-selector'; diff --git a/framework/PageDashboard/PageDashboardCountBar.tsx b/framework/PageDashboard/PageDashboardCountBar.tsx index a0c30377ed..b1d1be6914 100644 --- a/framework/PageDashboard/PageDashboardCountBar.tsx +++ b/framework/PageDashboard/PageDashboardCountBar.tsx @@ -1,13 +1,15 @@ -import { CardBody, Flex, FlexItem, Title } from '@patternfly/react-core'; -import { ArrowRightIcon } from '@patternfly/react-icons'; +import { ChartDonut, ChartLabel, ChartLabelProps } from '@patternfly/react-charts'; +import { CardBody, Title } from '@patternfly/react-core'; +import { CSSProperties } from 'react'; import { Link } from 'react-router-dom'; import { PageDashboardCard } from './PageDashboardCard'; export type PageDashboardCountBarProps = { counts: { title: string; - count: number; + total?: number; to: string; + counts?: { label: string; count: number; color: string; link?: string }[]; }[]; }; @@ -15,35 +17,143 @@ export function PageDashboardCountBar(props: PageDashboardCountBarProps) { return ( - - {props.counts.map((item) => ( - - - - + {props.counts.map((item, index) => { + const id = item.title.toLowerCase().replace(/ /g, '-'); + const total: number = + item.total ?? + item.counts?.reduce<number>((acc: number, curr) => acc + (curr.count ?? 0), 0) ?? + 0; + return ( + <div id={id} key={index} style={{ display: 'flex', gap: 12, alignItems: 'center' }}> + <Link to={item.to} style={{ color: 'var(--pf-global--text--Color)' }}> + <Title + headingLevel="h3" + size="xl" + style={{ whiteSpace: 'nowrap', textDecoration: 'none' }} + > {item.title} - - - {item.count} - - - - - - - - - ))} - + + + {/* if there is a total and the item has counts and counts is not undefined */} + {/* then we want to show the donut chart instead of just a count whisch is in the else */} + {/* Note: even if there is counts but total is 0 we want to just render the count */} + {total && 'counts' in item && item.counts ? ( + <> +
+ ((acc, curr) => acc + curr.count, 0) + .toString()} + titleComponent={} + padding={{ top: 0, left: 0, right: 0, bottom: 0 }} + width={64} + height={64} + data={item.counts.map((count) => ({ x: count.label, y: count.count }))} + colorScale={item.counts.map((count) => count.color)} + cornerRadius={3} + allowTooltip={false} + /> +
+ + + ) : ( + // This renders the total if there are no counts or total is zero + {total} + )} + + ); + })} +
); } + +export function PageChartLegend(props: { + id: string; + legend: { label: string; count: number; color: string; link?: string }[]; +}) { + return ( +
+ {props.legend.map((item, index) => { + if (item.count === 0) return <>; + return ( + <> +
+
+ {item.count} +
+
+ {item.link ? ( + + {item.label} + + ) : ( + item.label + )} +
+ + ); + })} +
+ ); +} + +function PageChartLabel(props: ChartLabelProps & { to?: string }) { + if (props.to) { + return ( + + + + ); + } + return ( + + ); +} diff --git a/frontend/awx/dashboard/AwxDashboard.tsx b/frontend/awx/dashboard/AwxDashboard.tsx index d8f3ee7f12..77527d5db9 100644 --- a/frontend/awx/dashboard/AwxDashboard.tsx +++ b/frontend/awx/dashboard/AwxDashboard.tsx @@ -13,10 +13,8 @@ import { Job } from '../interfaces/Job'; import { Project } from '../interfaces/Project'; import { useAwxView } from '../useAwxView'; import { WelcomeModal } from './WelcomeModal'; -import { AwxHostsCard } from './cards/AwxHostsCard'; -import { AwxInventoriesCard } from './cards/AwxInventoriesCard'; +import { AwxCountsCard } from './cards/AwxCountsCard'; import { AwxJobActivityCard } from './cards/AwxJobActivityCard'; -import { AwxProjectsCard } from './cards/AwxProjectsCard'; import { AwxRecentInventoriesCard } from './cards/AwxRecentInventoriesCard'; import { AwxRecentJobsCard } from './cards/AwxRecentJobsCard'; import { AwxRecentProjectsCard } from './cards/AwxRecentProjectsCard'; @@ -92,7 +90,7 @@ function DashboardInternal(props: { managedResources: Resource[] }) { defaultSort: 'modified', defaultSortDirection: 'desc', }); - const { data, isLoading } = useSWR(`/api/v2/dashboard/`, (url: string) => + const { data, isLoading } = useSWR(`/api/v2/dashboard/`, (url: string) => fetch(url).then((r) => r.json()) ); if (!data || isLoading) { @@ -107,36 +105,27 @@ function DashboardInternal(props: { managedResources: Resource[] }) { return ( - {managedResources.map((r: Resource) => { - switch (true) { - case r.id === 'recent_job_activity': + {managedResources.map((resource: Resource) => { + switch (resource.id) { + case 'counts': + return ; + case 'recent_job_activity': return ; - case r.id === 'project': - return ; - case r.id === 'host': - return ; - case r.id === 'inventory': - return ( - - ); - case r.id === 'recent_jobs': + case 'recent_jobs': return ( ); - case r.id === 'recent_projects': + case 'recent_projects': return ( ); - case r.id === 'recent_inventories': + case 'recent_inventories': return ( { + it('should render the counts card with the correct counts', () => { + const data: IAwxDashboardData = { + inventories: { total: 3, inventory_failed: 1 }, + hosts: { total: 20, failed: 9 }, + projects: { total: 7, failed: 4 }, + } as IAwxDashboardData; + + cy.mount(); + + cy.contains('Hosts') + .parent() + .within(() => { + cy.contains(data.hosts.total).should('have.attr', 'href', RouteObj.Hosts); + cy.contains(data.hosts.total - data.hosts.failed); + cy.contains(data.hosts.failed); + }); + + cy.contains('Projects') + .parent() + .within(() => { + cy.contains(data.projects.total).should('have.attr', 'href', RouteObj.Projects); + + cy.contains(data.projects.total - data.projects.failed) + .parent() + .contains('Ready') + .should('have.attr', 'href', RouteObj.Projects + '?status=successful'); + + cy.contains(data.projects.failed) + .parent() + .contains('Failed') + .should('have.attr', 'href', RouteObj.Projects + '?status=failed,error,canceled,missing'); + }); + + cy.contains('Inventories') + .parent() + .within(() => { + cy.contains(data.inventories.total).should('have.attr', 'href', RouteObj.Inventories); + cy.contains(data.inventories.total - data.inventories.inventory_failed); + cy.contains(data.inventories.inventory_failed); + }); + }); +}); diff --git a/frontend/awx/dashboard/cards/AwxCountsCard.tsx b/frontend/awx/dashboard/cards/AwxCountsCard.tsx new file mode 100644 index 0000000000..802fbadcae --- /dev/null +++ b/frontend/awx/dashboard/cards/AwxCountsCard.tsx @@ -0,0 +1,76 @@ +import { useTranslation } from 'react-i18next'; +import { PageDashboardCountBar } from '../../../../framework/PageDashboard/PageDashboardCountBar'; +import { usePageChartColors } from '../../../../framework/PageDashboard/usePageChartColors'; +import { RouteObj } from '../../../common/Routes'; +import { IAwxDashboardData } from '../AwxDashboard'; + +export function AwxCountsCard(props: { data: IAwxDashboardData }) { + const { t } = useTranslation(); + const { data } = props; + const { successfulColor, failedColor } = usePageChartColors(); + return ( + + ); +} diff --git a/frontend/awx/dashboard/cards/AwxHostsCard.tsx b/frontend/awx/dashboard/cards/AwxHostsCard.tsx deleted file mode 100644 index b1cf6303f0..0000000000 --- a/frontend/awx/dashboard/cards/AwxHostsCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { PageDashboardDonutCard } from '../../../../framework/PageDashboard/PageDonutChart'; -import { usePageChartColors } from '../../../../framework/PageDashboard/usePageChartColors'; -import { RouteObj } from '../../../common/Routes'; - -export function AwxHostsCard(props: { total: number; failed: number }) { - const { t } = useTranslation(); - const { successfulColor, failedColor } = usePageChartColors(); - return ( - - ); -} diff --git a/frontend/awx/dashboard/cards/AwxInventoriesCard.tsx b/frontend/awx/dashboard/cards/AwxInventoriesCard.tsx deleted file mode 100644 index 390064e2f0..0000000000 --- a/frontend/awx/dashboard/cards/AwxInventoriesCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { PageDashboardDonutCard } from '../../../../framework/PageDashboard/PageDonutChart'; -import { usePageChartColors } from '../../../../framework/PageDashboard/usePageChartColors'; -import { RouteObj } from '../../../common/Routes'; - -export function AwxInventoriesCard(props: { total: number; failed: number }) { - const { t } = useTranslation(); - const { successfulColor, failedColor } = usePageChartColors(); - return ( - - ); -} diff --git a/frontend/awx/dashboard/cards/AwxProjectsCard.tsx b/frontend/awx/dashboard/cards/AwxProjectsCard.tsx deleted file mode 100644 index 9b529c4555..0000000000 --- a/frontend/awx/dashboard/cards/AwxProjectsCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { PageDashboardDonutCard } from '../../../../framework/PageDashboard/PageDonutChart'; -import { usePageChartColors } from '../../../../framework/PageDashboard/usePageChartColors'; -import { RouteObj } from '../../../common/Routes'; - -export function AwxProjectsCard(props: { total: number; failed: number }) { - const { t } = useTranslation(); - const { successfulColor, failedColor } = usePageChartColors(); - return ( - - ); -} diff --git a/frontend/awx/dashboard/hooks/useManagedAwxDashboard.tsx b/frontend/awx/dashboard/hooks/useManagedAwxDashboard.tsx index 4a79762f9b..fc87dff914 100644 --- a/frontend/awx/dashboard/hooks/useManagedAwxDashboard.tsx +++ b/frontend/awx/dashboard/hooks/useManagedAwxDashboard.tsx @@ -17,13 +17,11 @@ export function useManagedAwxDashboard() { ); const resources: Resource[] = useMemo( () => [ - { id: 'recent_job_activity', name: t('Recent job activity'), selected: true }, - { id: 'host', name: t('Hosts'), selected: true }, - { id: 'project', name: t('Projects'), selected: true }, - { id: 'inventory', name: t('Inventories'), selected: true }, - { id: 'recent_jobs', name: t('Recent jobs'), selected: true }, - { id: 'recent_projects', name: t('Recent projects'), selected: true }, - { id: 'recent_inventories', name: t('Recent inventories'), selected: true }, + { id: 'counts', name: t('Resource Counts') }, + { id: 'recent_job_activity', name: t('Recent job activity') }, + { id: 'recent_jobs', name: t('Recent jobs') }, + { id: 'recent_projects', name: t('Recent projects') }, + { id: 'recent_inventories', name: t('Recent inventories') }, ], [t] ); diff --git a/frontend/awx/resources/projects/hooks/useProjectsFilters.tsx b/frontend/awx/resources/projects/hooks/useProjectsFilters.tsx index c4c1b6b5d6..196390d0e5 100644 --- a/frontend/awx/resources/projects/hooks/useProjectsFilters.tsx +++ b/frontend/awx/resources/projects/hooks/useProjectsFilters.tsx @@ -18,6 +18,26 @@ export function useProjectsFilters() { () => [ nameToolbarFilter, descriptionToolbarFilter, + { + key: 'status', + label: t('Status'), + type: ToolbarFilterType.MultiSelect, + query: 'status__exact', + placeholder: t('Filter by status'), + options: [ + { label: t('Successful'), value: 'successful' }, + { label: t('Failed'), value: 'failed' }, + { label: t('Errors'), value: 'error' }, + { label: t('Canceled'), value: 'canceled' }, + { label: t('Missing'), value: 'missing' }, + { label: t('Pending'), value: 'pending' }, + { label: t('Running'), value: 'running' }, + { label: t('Waiting'), value: 'waiting' }, + { label: t('New'), value: 'new' }, + { label: t('Never updated'), value: 'never updated' }, + { label: t('OK'), value: 'ok' }, + ], + }, { key: 'type', label: t('Type'),