1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

feat(ui): add system status banner (PROJQUAY-9494) (#4417)

displays warning banners when Quay is in read-only mode or account
recovery mode, integrated into both standalone and plugin layouts with
Cypress e2e tests

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
jbpratt
2025-10-29 11:36:14 -05:00
committed by GitHub
parent 2d42e46eca
commit 76e5798385
5 changed files with 255 additions and 12 deletions

View File

@@ -0,0 +1,176 @@
describe('System Status Banner', () => {
beforeEach(() => {
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
.then((response) => response.body.csrf_token)
.then((token) => {
cy.loginByCSRF(token);
});
});
describe('Read-only mode banner', () => {
it('displays read-only banner when registry_state is readonly', () => {
// Mock config with read-only mode enabled
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.registry_state = 'readonly';
return res;
}),
).as('getConfig');
cy.visit('/organization');
cy.wait('@getConfig');
// Verify banner is visible
cy.get('[data-testid="readonly-mode-banner"]').should('be.visible');
// Verify banner contains expected text
cy.get('[data-testid="readonly-mode-banner"]').should(
'contain',
'is currently in read-only mode',
);
cy.get('[data-testid="readonly-mode-banner"]').should(
'contain',
'Pulls and other read-only operations will succeed',
);
cy.get('[data-testid="readonly-mode-banner"]').should(
'contain',
'all other operations are currently suspended',
);
});
it('does not display read-only banner in normal mode', () => {
cy.visit('/organization');
// Verify banner is not present
cy.get('[data-testid="readonly-mode-banner"]').should('not.exist');
});
it('displays registry name in the banner', () => {
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.registry_state = 'readonly';
return res;
}),
).as('getConfig');
cy.visit('/organization');
cy.wait('@getConfig');
// Verify registry name is displayed (from config)
cy.get('[data-testid="readonly-mode-banner"]')
.invoke('text')
.should('match', /\S+\s+is currently in read-only mode/);
});
});
describe('Account recovery mode banner', () => {
it('displays account recovery banner when account_recovery_mode is true', () => {
// Mock config with account recovery mode enabled
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.account_recovery_mode = true;
return res;
}),
).as('getConfig');
cy.visit('/organization');
cy.wait('@getConfig');
// Verify banner is visible
cy.get('[data-testid="account-recovery-mode-banner"]').should(
'be.visible',
);
// Verify banner contains expected text
cy.get('[data-testid="account-recovery-mode-banner"]').should(
'contain',
'is currently in account recovery mode',
);
cy.get('[data-testid="account-recovery-mode-banner"]').should(
'contain',
'This instance should only be used to link accounts',
);
cy.get('[data-testid="account-recovery-mode-banner"]').should(
'contain',
'Registry operations such as pushes/pulls will not work',
);
});
it('does not display account recovery banner in normal mode', () => {
// Config defaults to account_recovery_mode: false, no intercept needed
cy.visit('/organization');
// Verify banner is not present
cy.get('[data-testid="account-recovery-mode-banner"]').should(
'not.exist',
);
});
});
describe('Banner positioning', () => {
it('appears after the feedback banner', () => {
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.registry_state = 'readonly';
return res;
}),
).as('getConfig');
cy.visit('/organization');
cy.wait('@getConfig');
// The read-only banner should exist after the feedback banner
// We can verify this by checking the DOM order
cy.get('[data-testid="readonly-mode-banner"]')
.should('be.visible')
.parent()
.should('exist');
});
});
describe('Both banners', () => {
it('displays both banners when both modes are active', () => {
// Create a config with both flags enabled
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.registry_state = 'readonly';
res.body.account_recovery_mode = true;
return res;
}),
).as('getConfig');
cy.visit('/organization');
cy.wait('@getConfig');
// Verify both banners are visible
cy.get('[data-testid="readonly-mode-banner"]').should('be.visible');
cy.get('[data-testid="account-recovery-mode-banner"]').should(
'be.visible',
);
});
});
describe('Multiple page navigation', () => {
it('displays banner across different pages', () => {
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.registry_state = 'readonly';
return res;
}),
).as('getConfig');
// Visit organization page
cy.visit('/organization');
cy.wait('@getConfig');
cy.get('[data-testid="readonly-mode-banner"]').should('be.visible');
// Navigate to repositories page
cy.visit('/repository');
cy.get('[data-testid="readonly-mode-banner"]').should('be.visible');
// Navigate to overview page
cy.visit('/overview');
cy.get('[data-testid="readonly-mode-banner"]').should('be.visible');
});
});
});

View File

@@ -3,6 +3,7 @@ import {ListVariant, LoginPage} from '@patternfly/react-core';
import logo from 'src/assets/quay.svg';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import {useLoginFooterItems} from 'src/components/LoginFooter';
import SystemStatusBanner from 'src/components/SystemStatusBanner';
import './LoginPageLayout.css';
interface LoginPageLayoutProps {
@@ -35,17 +36,20 @@ export function LoginPageLayout({
}, [quayConfig]);
return (
<LoginPage
className={className}
brandImgSrc={logoUrl}
brandImgAlt="Red Hat Quay"
backgroundImgSrc="assets/images/rh_login.jpeg"
textContent={description}
loginTitle={title}
footerListItems={footerListItems}
footerListVariants={ListVariant.inline}
>
{children}
</LoginPage>
<>
<SystemStatusBanner />
<LoginPage
className={className}
brandImgSrc={logoUrl}
brandImgAlt="Red Hat Quay"
backgroundImgSrc="assets/images/rh_login.jpeg"
textContent={description}
loginTitle={title}
footerListItems={footerListItems}
footerListVariants={ListVariant.inline}
>
{children}
</LoginPage>
</>
);
}

View File

@@ -0,0 +1,59 @@
import {Banner, Flex, FlexItem} from '@patternfly/react-core';
import {ExclamationTriangleIcon} from '@patternfly/react-icons';
import {useQuayState} from 'src/hooks/UseQuayState';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import PropTypes from 'prop-types';
const BannerContent = ({icon, children}) => (
<Flex
spaceItems={{default: 'spaceItemsSm'}}
justifyContent={{default: 'justifyContentCenter'}}
alignItems={{default: 'alignItemsCenter'}}
>
<FlexItem>{icon}</FlexItem>
<FlexItem>{children}</FlexItem>
</Flex>
);
BannerContent.propTypes = {
icon: PropTypes.node.isRequired,
children: PropTypes.node.isRequired,
};
export default function SystemStatusBanner() {
const {inReadOnlyMode, inAccountRecoveryMode} = useQuayState();
const config = useQuayConfig();
const registryName = config?.config?.REGISTRY_TITLE || 'Quay';
return (
<>
{inReadOnlyMode && (
<Banner
variant="default"
data-testid="readonly-mode-banner"
screenReaderText="Read-only mode warning"
>
<BannerContent icon={<ExclamationTriangleIcon />}>
<strong>{registryName}</strong> is currently in read-only mode.
Pulls and other read-only operations will succeed but all other
operations are currently suspended.
</BannerContent>
</Banner>
)}
{inAccountRecoveryMode && (
<Banner
variant="gold"
data-testid="account-recovery-mode-banner"
screenReaderText="Account recovery mode warning"
>
<BannerContent icon={<ExclamationTriangleIcon />}>
<strong>{registryName}</strong> is currently in account recovery
mode. This instance should only be used to link accounts to an
external login service, e.g., Red Hat. Registry operations such as
pushes/pulls will not work.
</BannerContent>
</Banner>
)}
</>
);
}

View File

@@ -25,6 +25,7 @@ import axiosIns from 'src/libs/axios';
import ManageMembersList from './OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList';
import OverviewList from './OverviewList/OverviewList';
import {LoadingPage} from 'src/components/LoadingPage';
import SystemStatusBanner from 'src/components/SystemStatusBanner';
const NavigationRoutes = [
{
@@ -138,6 +139,7 @@ function PluginMain() {
</FlexItem>
</Flex>
</Banner>
<SystemStatusBanner />
{user && (
<CreateNewUser
user={user}

View File

@@ -35,6 +35,7 @@ import Conditional from 'src/components/empty/Conditional';
import RegistryStatus from './RegistryStatus';
import {NotificationDrawerListComponent} from 'src/components/notifications/NotificationDrawerList';
import {OAuthError} from 'src/routes/OAuthCallback/OAuthError';
import SystemStatusBanner from 'src/components/SystemStatusBanner';
const NavigationRoutes = [
{
@@ -135,6 +136,7 @@ export function StandaloneMain() {
</FlexItem>
</Flex>
</Banner>
<SystemStatusBanner />
<Conditional if={quayConfig?.features?.BILLING}>
<ErrorBoundary fallback={<>Error loading registry status</>}>
<RegistryStatus />