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

ui: Add Mirroring to ui (PROJQUAY-8886) (#4121)

This adds react code and patternfly components to handle Mirroring in the new Quay UI. Functionality should be equivalent to the old Angular code, and new test suite is passing locally. Added the react-hook-form library to simplify and reduce redundant form code. Also added the new Skopeo timeout interval field.
This commit is contained in:
Mark Franceschelli
2025-07-17 15:01:56 -04:00
committed by GitHub
parent ca4d71df16
commit 71cf930665
20 changed files with 2522 additions and 1 deletions

View File

@@ -0,0 +1,709 @@
/// <reference types="cypress" />
describe('Repository Mirroring', () => {
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);
});
// Enable mirroring feature
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.features['REPO_MIRROR'] = true;
return res;
}),
).as('getConfig');
});
describe('Feature Flag Behavior', () => {
it('should not show mirroring tab when REPO_MIRROR feature is disabled', () => {
cy.intercept('GET', '/config', (req) =>
req.reply((res) => {
res.body.features['REPO_MIRROR'] = false;
return res;
}),
).as('getConfigNoMirror');
// Mock repository with MIRROR state
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
cy.visit('/repository/user1/hello-world');
cy.wait('@getConfigNoMirror');
cy.get('[data-testid="mirroring-tab"]').should('not.exist');
});
it('should show mirroring tab when REPO_MIRROR feature is enabled', () => {
// Mock repository with MIRROR state
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
cy.visit('/repository/user1/hello-world');
cy.wait('@getConfig');
cy.get('[data-testid="mirroring-tab"]').should('exist');
});
});
describe('Repository State Requirements', () => {
it('should show state warning for non-mirror repositories', () => {
// Mock repository with NORMAL state - using wildcard pattern to catch all variations
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'NORMAL',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.contains("This repository's state is NORMAL").should('exist');
cy.contains('Use the Settings tab and change it to Mirror').should(
'exist',
);
});
it('should show mirroring form for mirror repositories', () => {
// Mock repository with MIRROR state - using wildcard pattern to catch all variations
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
// Mock no existing mirror config (404)
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 404,
}).as('getMirrorConfig404');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.contains('Repository Mirroring').should('exist');
cy.contains('External Repository').should('exist');
cy.get('[data-testid="mirror-form"]').should('exist');
});
});
describe('New Mirror Configuration', () => {
beforeEach(() => {
// Mock repository with MIRROR state
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
// Mock no existing mirror config (404)
cy.intercept('GET', '**/api/v1/repository/user1/hello-world/mirror*', {
statusCode: 404,
}).as('getMirrorConfig404');
// Mock robot accounts
cy.intercept('GET', '/api/v1/organization/user1/robots*', {
fixture: 'robots.json',
}).as('getRobots');
});
it('should display new mirror form with correct initial state', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
// Wait for all API calls to complete (component should automatically stop loading)
cy.wait('@getRepo');
cy.wait('@getMirrorConfig404');
cy.wait('@getRobots');
// Note: Teams API may not be called immediately, test form loading first
// Give component time to process 404 response and update loading state
cy.wait(500);
// Check form title and description
cy.contains('External Repository').should('exist');
cy.contains(
'This feature will convert user1/hello-world into a mirror',
).should('exist');
// For new mirrors, the enabled checkbox is NOT shown (only for existing configs)
// Check for basic form fields that should be present
cy.get('[data-testid="registry-location-input"]').should('exist');
cy.get('[data-testid="tags-input"]').should('exist');
// Check empty form fields
cy.get('[data-testid="registry-location-input"]').should(
'have.value',
'',
);
cy.get('[data-testid="tags-input"]').should('have.value', '');
cy.get('[data-testid="username-input"]').should('have.value', '');
cy.get('[data-testid="password-input"]').should('have.value', '');
// Check button says "Enable Mirror"
cy.get('[data-testid="submit-button"]').should(
'contain.text',
'Enable Mirror',
);
});
it('should validate required fields', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig404');
cy.wait('@getRobots');
// Submit button should be disabled initially
cy.get('[data-testid="submit-button"]').should('be.disabled');
// Fill in required fields one by one
cy.get('[data-testid="registry-location-input"]').type(
'quay.io/library/hello-world',
);
cy.get('[data-testid="submit-button"]').should('be.disabled');
cy.get('[data-testid="tags-input"]').type('latest, stable');
cy.get('[data-testid="submit-button"]').should('be.disabled');
cy.get('[data-testid="sync-interval-input"]').type('60');
cy.get('[data-testid="submit-button"]').should('be.disabled');
// Select robot user
cy.get('#robot-user-select').click();
cy.contains('testorg+testrobot').click();
// Now submit button should be enabled
cy.get('[data-testid="submit-button"]').should('not.be.disabled');
});
it('should successfully create mirror configuration', () => {
cy.intercept('POST', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 201,
body: {
is_enabled: true,
external_reference: 'quay.io/library/hello-world',
sync_status: 'NEVER_RUN',
robot_username: 'testorg+testrobot',
},
}).as('createMirrorConfig');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
// Fill in the form
cy.get('[data-testid="registry-location-input"]').type(
'quay.io/library/hello-world',
);
cy.get('[data-testid="tags-input"]').type('latest, stable');
cy.get('[data-testid="sync-interval-input"]').type('60');
// Select robot user
cy.get('#robot-user-select').click();
cy.contains('testorg+testrobot').click();
// Submit form
cy.get('[data-testid="submit-button"]').click();
cy.wait('@createMirrorConfig');
cy.contains('Mirror configuration saved successfully').should('exist');
});
it('should handle form submission errors', () => {
cy.intercept('POST', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 400,
body: {message: 'Invalid external reference'},
}).as('createMirrorConfigError');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
// Fill in the form with invalid data
cy.get('[data-testid="registry-location-input"]').type(
'invalid-registry',
);
cy.get('[data-testid="tags-input"]').type('latest');
cy.get('[data-testid="sync-interval-input"]').type('60');
// Select robot user
cy.get('#robot-user-select').click();
cy.contains('testorg+testrobot').click();
// Submit form
cy.get('[data-testid="submit-button"]').click();
cy.wait('@createMirrorConfigError');
cy.contains('Error saving mirror configuration').should('exist');
});
});
describe('Existing Mirror Configuration', () => {
beforeEach(() => {
// Mock repository with MIRROR state
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
// Mock existing mirror config
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 200,
body: {
is_enabled: true,
external_reference: 'quay.io/library/hello-world',
external_registry_username: 'testuser',
robot_username: 'user1+testrobot',
sync_start_date: '2024-01-01T12:00:00Z',
sync_interval: 3600,
sync_status: 'SYNC_SUCCESS',
last_sync: '2024-01-01T12:00:00Z',
sync_expiration_date: null,
sync_retries_remaining: 3,
skopeo_timeout_interval: 300,
external_registry_config: {
verify_tls: true,
unsigned_images: false,
proxy: {
http_proxy: null,
https_proxy: null,
no_proxy: null,
},
},
root_rule: {
rule_kind: 'tag_glob_csv',
rule_value: ['latest', 'stable'],
},
},
}).as('getMirrorConfig');
});
it('should load and display existing mirror configuration', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig');
// Check form is populated with existing data
cy.get('[data-testid="mirror-enabled-checkbox"]').should('be.checked');
cy.get('[data-testid="registry-location-input"]').should(
'have.value',
'quay.io/library/hello-world',
);
cy.get('[data-testid="tags-input"]').should(
'have.value',
'latest, stable',
);
cy.get('[data-testid="username-input"]').should('have.value', 'testuser');
cy.get('[data-testid="sync-interval-input"]').should('have.value', '1');
// Check button says "Update Mirror"
cy.get('[data-testid="submit-button"]').should(
'contain.text',
'Update Mirror',
);
// Check configuration section title
cy.contains('Configuration').should('exist');
});
it('should display status section for existing mirrors', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig');
// Check status section exists
cy.contains('Status').should('exist');
cy.contains('State').should('exist');
cy.contains('Success').should('exist');
cy.contains('Timeout').should('exist');
cy.contains('Retries Remaining').should('exist');
cy.contains('3 / 3').should('exist');
});
it('should enable/disable mirror configuration', () => {
cy.intercept('PUT', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 201,
body: {
is_enabled: false,
external_reference: 'quay.io/library/hello-world',
sync_status: 'NEVER_RUN',
},
}).as('updateMirrorConfig');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig');
// Disable mirror
cy.get('[data-testid="mirror-enabled-checkbox"]').uncheck();
cy.contains('Scheduled mirroring disabled').should('exist');
// Re-enable mirror
cy.get('[data-testid="mirror-enabled-checkbox"]').check();
cy.contains('Scheduled mirroring enabled').should('exist');
});
it('should update existing mirror configuration', () => {
cy.intercept('PUT', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 201,
body: {
is_enabled: true,
external_reference: 'quay.io/library/nginx',
sync_status: 'NEVER_RUN',
},
}).as('updateMirrorConfig');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig');
// Update registry location
cy.get('[data-testid="registry-location-input"]')
.clear()
.type('quay.io/library/nginx');
// Submit form
cy.get('[data-testid="submit-button"]').click();
cy.wait('@updateMirrorConfig');
cy.contains('Mirror configuration saved successfully').should('exist');
});
});
describe('Sync Operations', () => {
beforeEach(() => {
// Mock repository with MIRROR state
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
});
it('should trigger sync now operation', () => {
// Mock mirror config with sync capability
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 200,
body: {
is_enabled: true,
external_reference: 'quay.io/library/hello-world',
sync_status: 'NEVER_RUN',
robot_username: 'testorg+testrobot',
sync_start_date: '2024-01-01T12:00:00Z',
sync_interval: 3600,
skopeo_timeout_interval: 300,
external_registry_config: {
verify_tls: true,
unsigned_images: false,
proxy: {http_proxy: null, https_proxy: null, no_proxy: null},
},
root_rule: {
rule_kind: 'tag_glob_csv',
rule_value: ['latest'],
},
},
}).as('getMirrorConfig');
cy.intercept(
'POST',
'/api/v1/repository/user1/hello-world/mirror/sync-now',
{
statusCode: 204,
},
).as('syncNow');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig');
// Click sync now button
cy.get('[data-testid="sync-now-button"]').click();
cy.wait('@syncNow');
cy.contains('Sync scheduled successfully').should('exist');
});
it('should cancel ongoing sync operation', () => {
// Mock mirror config with ongoing sync
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 200,
body: {
is_enabled: true,
external_reference: 'quay.io/library/hello-world',
sync_status: 'SYNCING',
robot_username: 'testorg+testrobot',
sync_start_date: '2024-01-01T12:00:00Z',
sync_interval: 3600,
skopeo_timeout_interval: 300,
external_registry_config: {
verify_tls: true,
unsigned_images: false,
proxy: {http_proxy: null, https_proxy: null, no_proxy: null},
},
root_rule: {
rule_kind: 'tag_glob_csv',
rule_value: ['latest'],
},
},
}).as('getMirrorConfig');
cy.intercept(
'POST',
'/api/v1/repository/user1/hello-world/mirror/sync-cancel',
{
statusCode: 204,
},
).as('cancelSync');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfig');
// Check sync status shows syncing
cy.contains('Syncing').should('exist');
// Click cancel button
cy.get('[data-testid="cancel-sync-button"]').click();
cy.wait('@cancelSync');
cy.contains('Sync cancelled successfully').should('exist');
});
it('should disable sync/cancel buttons appropriately', () => {
// Mock mirror config with different states
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 200,
body: {
is_enabled: true,
external_reference: 'quay.io/library/hello-world',
sync_status: 'SYNCING',
robot_username: 'user1+testrobot',
sync_start_date: '2024-01-01T12:00:00Z',
sync_interval: 3600,
skopeo_timeout_interval: 300,
external_registry_config: {
verify_tls: true,
unsigned_images: false,
proxy: {http_proxy: null, https_proxy: null, no_proxy: null},
},
root_rule: {
rule_kind: 'tag_glob_csv',
rule_value: ['latest'],
},
},
}).as('getMirrorConfigSyncing');
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getMirrorConfigSyncing');
// When syncing: Sync Now should be disabled, Cancel should be enabled
cy.get('[data-testid="sync-now-button"]').should('be.disabled');
cy.get('[data-testid="cancel-sync-button"]').should('not.be.disabled');
});
});
describe('Robot User Selection', () => {
beforeEach(() => {
// Mock repository with MIRROR state
cy.intercept('GET', '/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
// Mock no existing mirror config
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror*', {
statusCode: 404,
}).as('getMirrorConfig404');
// Mock robot accounts
cy.intercept('GET', '/api/v1/organization/user1/robots*', {
fixture: 'robots.json',
}).as('getRobots');
});
it('should display robot user dropdown with options', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getRobots');
// Click robot user dropdown
cy.get('#robot-user-select').click();
// Check create options are at the top
cy.contains('Create team').should('exist');
cy.contains('Create robot account').should('exist');
// Check robots are listed (teams are not shown for user namespaces)
cy.contains('Robot accounts').should('exist');
cy.contains('testorg+testrobot').should('exist');
});
it('should select robot user from dropdown', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
cy.wait('@getRobots');
// Select robot user
cy.get('#robot-user-select').click();
cy.contains('testorg+testrobot').click();
// Check selection is reflected
cy.get('#robot-user-select input').should(
'have.value',
'testorg+testrobot',
);
});
});
describe('Advanced Settings', () => {
beforeEach(() => {
// Mock repository with MIRROR state
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
body: {
name: 'hello-world',
namespace: 'user1',
state: 'MIRROR',
kind: 'image',
description: '',
is_public: true,
is_organization: false,
is_starred: false,
can_write: true,
can_admin: true,
},
}).as('getRepo');
// Mock no existing mirror config
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
statusCode: 404,
}).as('getMirrorConfig404');
});
it('should configure TLS verification', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
// Check TLS verification checkbox
cy.get('[data-testid="verify-tls-checkbox"]').should('not.be.checked');
cy.get('[data-testid="verify-tls-checkbox"]').check();
cy.get('[data-testid="verify-tls-checkbox"]').should('be.checked');
});
it('should configure unsigned images', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
// Check unsigned images checkbox
cy.get('[data-testid="unsigned-images-checkbox"]').should(
'not.be.checked',
);
cy.get('[data-testid="unsigned-images-checkbox"]').check();
cy.get('[data-testid="unsigned-images-checkbox"]').should('be.checked');
});
it('should configure proxy settings', () => {
cy.visit('/repository/user1/hello-world?tab=mirroring');
cy.wait('@getRepo');
// Fill in proxy settings
cy.get('[data-testid="http-proxy-input"]').type(
'http://proxy.example.com:8080',
);
cy.get('[data-testid="https-proxy-input"]').type(
'https://proxy.example.com:8080',
);
cy.get('[data-testid="no-proxy-input"]').type('localhost,127.0.0.1');
// Check values are set
cy.get('[data-testid="http-proxy-input"]').should(
'have.value',
'http://proxy.example.com:8080',
);
cy.get('[data-testid="https-proxy-input"]').should(
'have.value',
'https://proxy.example.com:8080',
);
cy.get('[data-testid="no-proxy-input"]').should(
'have.value',
'localhost,127.0.0.1',
);
});
});
});

22
web/package-lock.json generated
View File

@@ -29,6 +29,7 @@
"null-loader": "^4.0.1",
"process": "^0.11.10",
"react": "^18.2.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.15.0",
"recoil": "^0.7.7",
@@ -22521,6 +22522,21 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-hook-form": {
"version": "7.60.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz",
"integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -44876,6 +44892,12 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"react-hook-form": {
"version": "7.60.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz",
"integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==",
"requires": {}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

View File

@@ -28,6 +28,7 @@
"null-loader": "^4.0.1",
"process": "^0.11.10",
"react": "^18.2.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.15.0",
"recoil": "^0.7.7",

View File

@@ -0,0 +1,59 @@
import React from 'react';
import {
Card,
CardBody,
DescriptionList,
DescriptionListGroup,
DescriptionListTerm,
DescriptionListDescription,
Flex,
FlexItem,
Title,
} from '@patternfly/react-core';
interface StatusItem {
label: string;
value: string | React.ReactNode;
action?: React.ReactNode;
}
interface StatusDisplayProps {
title?: string;
items: StatusItem[];
'data-testid'?: string;
}
export function StatusDisplay({
title,
items,
'data-testid': dataTestId,
}: StatusDisplayProps) {
return (
<>
{title && <Title headingLevel="h3">{title}</Title>}
<Card isFlat data-testid={dataTestId}>
<CardBody>
<DescriptionList isHorizontal>
{items.map((item, index) => (
<DescriptionListGroup key={index}>
<DescriptionListTerm>{item.label}</DescriptionListTerm>
<DescriptionListDescription>
{item.action ? (
<Flex>
<FlexItem flex={{default: 'flex_1'}}>
{item.value}
</FlexItem>
<FlexItem>{item.action}</FlexItem>
</Flex>
) : (
item.value
)}
</DescriptionListDescription>
</DescriptionListGroup>
))}
</DescriptionList>
</CardBody>
</Card>
</>
);
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import {Controller, Control, FieldValues, Path} from 'react-hook-form';
import {FormGroup, Checkbox} from '@patternfly/react-core';
interface FormCheckboxProps<T extends FieldValues> {
name: Path<T>;
control: Control<T>;
label: string;
fieldId?: string;
description?: string;
isStack?: boolean;
'data-testid'?: string;
customOnChange?: (
checked: boolean,
onChange: (value: boolean) => void,
) => void;
}
export function FormCheckbox<T extends FieldValues>({
name,
control,
label,
fieldId,
description,
isStack = true,
'data-testid': dataTestId,
customOnChange,
}: FormCheckboxProps<T>) {
return (
<FormGroup fieldId={fieldId || name} isStack={isStack}>
<Controller
name={name}
control={control}
render={({field: {value, onChange}}) => (
<Checkbox
label={label}
id={fieldId || name}
description={description}
isChecked={value}
onChange={(_event, checked) => {
if (customOnChange) {
customOnChange(checked, onChange);
} else {
onChange(checked);
}
}}
data-testid={dataTestId}
/>
)}
/>
</FormGroup>
);
}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import {
Controller,
Control,
FieldErrors,
FieldValues,
Path,
} from 'react-hook-form';
import {
FormGroup,
FormHelperText,
TextInput,
Text,
ValidatedOptions,
} from '@patternfly/react-core';
interface FormTextInputProps<T extends FieldValues> {
name: Path<T>;
control: Control<T>;
errors: FieldErrors<T>;
label: string;
fieldId?: string;
placeholder?: string;
type?: 'text' | 'password' | 'email' | 'datetime-local';
required?: boolean;
customValidation?: (value: string) => string | boolean;
helperText?: string;
isStack?: boolean;
'data-testid'?: string;
pattern?: string;
inputMode?:
| 'none'
| 'text'
| 'tel'
| 'url'
| 'email'
| 'numeric'
| 'decimal'
| 'search';
'aria-label'?: string;
showNoneWhenEmpty?: boolean;
}
export function FormTextInput<T extends FieldValues>({
name,
control,
errors,
label,
fieldId,
placeholder,
type = 'text',
required = false,
customValidation,
helperText,
isStack = true,
'data-testid': dataTestId,
pattern,
inputMode,
'aria-label': ariaLabel,
showNoneWhenEmpty = false,
}: FormTextInputProps<T>) {
const rules = {
...(required && {
required: 'This field is required',
validate: (value: string) =>
value?.trim() !== '' || 'This field is required',
}),
...(customValidation && {
validate: customValidation,
}),
};
const fieldError = errors[name];
const validationState = fieldError
? ValidatedOptions.error
: ValidatedOptions.default;
return (
<FormGroup label={label} fieldId={fieldId || name} isStack={isStack}>
<Controller
name={name}
control={control}
rules={rules}
render={({field: {value, onChange}}) => {
const displayValue =
showNoneWhenEmpty && (!value || value === '')
? 'None'
: value || '';
const handleChange = (
_event: React.FormEvent<HTMLInputElement>,
newValue: string,
) => {
if (showNoneWhenEmpty && newValue === 'None') {
onChange('');
} else {
onChange(newValue);
}
};
return (
<>
<TextInput
type={type}
id={fieldId || name}
placeholder={placeholder}
value={displayValue}
onChange={handleChange}
validated={validationState}
data-testid={dataTestId}
pattern={pattern}
inputMode={inputMode}
aria-label={ariaLabel}
/>
{fieldError && (
<FormHelperText>
<Text component="p" className="pf-m-error">
{fieldError.message as string}
</Text>
</FormHelperText>
)}
{helperText && !fieldError && (
<FormHelperText>
<Text component="p">{helperText}</Text>
</FormHelperText>
)}
</>
);
}}
/>
</FormGroup>
);
}

View File

@@ -0,0 +1,145 @@
import {useState, useEffect} from 'react';
import {
MirroringConfigResponse,
getMirrorConfig,
createMirrorConfig,
updateMirrorConfig,
} from 'src/resources/MirroringResource';
import {MirroringFormData} from 'src/routes/RepositoryDetails/Mirroring/types';
import {
convertToSeconds,
convertFromSeconds,
formatDateForInput,
} from 'src/libs/utils';
import {
timestampToISO,
timestampFromISO,
} from 'src/resources/MirroringResource';
import {Entity, EntityKind} from 'src/resources/UserResource';
export const useMirroringConfig = (
namespace: string,
repoName: string,
repoState: string | undefined,
reset: (data: MirroringFormData) => void,
setSelectedRobot: (robot: Entity | null) => void,
) => {
const [config, setConfig] = useState<MirroringConfigResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load existing configuration
useEffect(() => {
const fetchConfig = async () => {
try {
setIsLoading(true);
const response = await getMirrorConfig(namespace, repoName);
setConfig(response);
// Populate form with existing values
const {value, unit} = convertFromSeconds(response.sync_interval);
reset({
isEnabled: response.is_enabled,
externalReference: response.external_reference || '',
tags: response.root_rule.rule_value.join(', '),
syncStartDate: formatDateForInput(response.sync_start_date || ''),
syncValue: value.toString(),
syncUnit: unit,
robotUsername: response.robot_username || '',
username: response.external_registry_username || '',
password: '', // Don't populate password for security
verifyTls: response.external_registry_config?.verify_tls ?? true,
httpProxy: response.external_registry_config?.proxy?.http_proxy || '',
httpsProxy:
response.external_registry_config?.proxy?.https_proxy || '',
noProxy: response.external_registry_config?.proxy?.no_proxy || '',
unsignedImages:
response.external_registry_config?.unsigned_images ?? false,
skopeoTimeoutInterval: response.skopeo_timeout_interval || 300,
});
// Set selected robot if there's one configured
if (response.robot_username) {
const robotEntity: Entity = {
name: response.robot_username,
is_robot: response.robot_username.includes('+'),
kind: response.robot_username.includes('+')
? EntityKind.user
: EntityKind.team,
is_org_member: true,
};
setSelectedRobot(robotEntity);
}
} catch (error: unknown) {
if (
(error as {response?: {status?: number}}).response?.status === 404
) {
setConfig(null);
} else {
setError(
(error as Error).message || 'Failed to load mirror configuration',
);
}
} finally {
setIsLoading(false);
}
};
if (repoState === 'MIRROR') {
fetchConfig();
} else {
setIsLoading(false);
}
}, [namespace, repoName, repoState, reset, setSelectedRobot]);
// Submit configuration
const submitConfig = async (data: MirroringFormData) => {
// Split and clean up tags to match backend expectation
const tagPatterns = data.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
const mirrorConfig = {
is_enabled: data.isEnabled,
external_reference: data.externalReference,
external_registry_username: data.username || null,
external_registry_password: data.password || null,
sync_start_date: data.syncStartDate
? timestampToISO(timestampFromISO(data.syncStartDate))
: timestampToISO(Math.floor(Date.now() / 1000)),
sync_interval: convertToSeconds(Number(data.syncValue), data.syncUnit),
robot_username: data.robotUsername,
skopeo_timeout_interval: data.skopeoTimeoutInterval,
external_registry_config: {
verify_tls: data.verifyTls,
unsigned_images: data.unsignedImages,
proxy: {
http_proxy: data.httpProxy || null,
https_proxy: data.httpsProxy || null,
no_proxy: data.noProxy || null,
},
},
root_rule: {
rule_kind: 'tag_glob_csv',
rule_value: tagPatterns,
},
};
if (config) {
await updateMirrorConfig(namespace, repoName, mirrorConfig);
} else {
await createMirrorConfig(namespace, repoName, mirrorConfig);
}
};
return {
config,
setConfig,
isLoading,
error,
setError,
submitConfig,
};
};

View File

@@ -0,0 +1,143 @@
import {useState} from 'react';
import {useForm} from 'react-hook-form';
import {MirroringFormData} from 'src/routes/RepositoryDetails/Mirroring/types';
import {Entity, EntityKind} from 'src/resources/UserResource';
import {AlertVariant} from 'src/atoms/AlertState';
// Default form values
const defaultFormValues: MirroringFormData = {
isEnabled: true,
externalReference: '',
tags: '',
syncStartDate: '',
syncValue: '24',
syncUnit: 'hours',
robotUsername: '',
username: '',
password: '',
verifyTls: false,
httpProxy: '',
httpsProxy: '',
noProxy: '',
unsignedImages: false,
skopeoTimeoutInterval: 300,
};
export const useMirroringForm = (
submitConfig: (data: MirroringFormData) => Promise<void>,
addAlert: (alert: {
variant: AlertVariant;
title: string;
message?: string;
}) => void,
setError: (error: string | null) => void,
) => {
// Initialize react-hook-form
const form = useForm<MirroringFormData>({
defaultValues: defaultFormValues,
mode: 'onChange',
});
const {
control,
handleSubmit,
formState: {errors, isValid, isDirty},
setValue,
watch,
reset,
getValues,
trigger,
} = form;
// Watch all form values to maintain existing functionality
const formValues = watch();
// Non-form UI state
const [selectedRobot, setSelectedRobot] = useState<Entity | null>(null);
const [isSelectOpen, setIsSelectOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isCreateRobotModalOpen, setIsCreateRobotModalOpen] = useState(false);
const [isCreateTeamModalOpen, setIsCreateTeamModalOpen] = useState(false);
const [teamName, setTeamName] = useState('');
const [teamDescription, setTeamDescription] = useState('');
// Form submission
const onSubmit = async (data: MirroringFormData) => {
try {
await submitConfig(data);
// Reset form with current values to mark it as clean
reset(data);
addAlert({
variant: AlertVariant.Success,
title: 'Mirror configuration saved successfully',
});
} catch (err) {
setError(err.message);
addAlert({
variant: AlertVariant.Failure,
title: 'Error saving mirror configuration',
message: err.message,
});
}
};
const handleRobotSelect = (name: string) => {
const robotEntity: Entity = {
name,
is_robot: true,
kind: EntityKind.user,
is_org_member: true,
};
setSelectedRobot(robotEntity);
setValue('robotUsername', name);
};
const handleTeamSelect = (name: string) => {
const teamEntity: Entity = {
name,
is_robot: false,
kind: EntityKind.team,
is_org_member: true,
};
setSelectedRobot(teamEntity);
setValue('robotUsername', name);
};
return {
// Form methods
control,
handleSubmit,
errors,
isValid,
isDirty,
setValue,
watch,
reset,
getValues,
trigger,
formValues,
onSubmit,
// UI state
selectedRobot,
setSelectedRobot,
isSelectOpen,
setIsSelectOpen,
isHovered,
setIsHovered,
isCreateRobotModalOpen,
setIsCreateRobotModalOpen,
isCreateTeamModalOpen,
setIsCreateTeamModalOpen,
teamName,
setTeamName,
teamDescription,
setTeamDescription,
// Helper methods
handleRobotSelect,
handleTeamSelect,
};
};

View File

@@ -215,3 +215,42 @@ export const escapeHtmlString = function (text) {
return adjusted;
};
// Mirroring utility functions
const timeUnits = {
seconds: 1,
minutes: 60,
hours: 60 * 60,
days: 60 * 60 * 24,
weeks: 60 * 60 * 24 * 7,
};
export const convertToSeconds = (value: number, unit: string): number => {
return value * (timeUnits[unit] || 1);
};
export const convertFromSeconds = (
seconds: number,
): {value: number; unit: string} => {
const units = ['weeks', 'days', 'hours', 'minutes', 'seconds'];
for (const unit of units) {
const divisor = timeUnits[unit];
if (seconds % divisor === 0) {
return {value: seconds / divisor, unit};
}
}
return {value: seconds, unit: 'seconds'};
};
// Convert ISO date to datetime-local format
export const formatDateForInput = (isoDate: string): string => {
if (!isoDate) return '';
try {
const date = new Date(isoDate);
// Format as YYYY-MM-DDTHH:MM (datetime-local format)
return date.toISOString().slice(0, 16);
} catch (error) {
console.error('Error formatting date:', error);
return '';
}
};

View File

@@ -0,0 +1,134 @@
import {AxiosResponse} from 'axios';
import axios from 'src/libs/axios';
import {assertHttpCode} from './ErrorHandling';
// Mirroring configuration types
export interface MirroringConfig {
is_enabled: boolean;
external_reference: string;
external_registry_username?: string | null;
external_registry_password?: string | null;
robot_username: string;
external_registry_config: {
verify_tls: boolean;
unsigned_images: boolean;
proxy: {
http_proxy: string | null;
https_proxy: string | null;
no_proxy: string | null;
};
};
sync_start_date: string;
sync_interval: number;
root_rule: {
rule_kind: string;
rule_value: string[];
};
}
export interface MirroringConfigResponse extends MirroringConfig {
sync_status:
| 'NEVER_RUN'
| 'SYNC_NOW'
| 'SYNC_FAILED'
| 'SYNCING'
| 'SYNC_SUCCESS';
last_sync: string;
last_error: string;
status_message: string;
mirror_type: string;
external_reference: string;
external_registry_username: string | null;
sync_expiration_date: string | null;
sync_retries_remaining: number | null;
robot_username: string;
skopeo_timeout_interval: number;
}
// Date conversion utilities
export const timestampToISO = (ts: number): string => {
const dt = new Date(ts * 1000).toISOString();
return dt.split('.')[0] + 'Z'; // Remove milliseconds
};
export const timestampFromISO = (dt: string): number => {
return Math.floor(new Date(dt).getTime() / 1000);
};
// API functions
export const getMirrorConfig = async (
namespace: string,
repoName: string,
): Promise<MirroringConfigResponse> => {
const response: AxiosResponse<MirroringConfigResponse> = await axios.get(
`/api/v1/repository/${namespace}/${repoName}/mirror`,
);
assertHttpCode(response.status, 200);
return response.data;
};
export const createMirrorConfig = async (
namespace: string,
repoName: string,
config: MirroringConfig,
): Promise<MirroringConfigResponse> => {
const response: AxiosResponse<MirroringConfigResponse> = await axios.post(
`/api/v1/repository/${namespace}/${repoName}/mirror`,
config,
);
assertHttpCode(response.status, 201);
return response.data;
};
export const updateMirrorConfig = async (
namespace: string,
repoName: string,
config: Partial<MirroringConfig>,
): Promise<MirroringConfigResponse> => {
const response: AxiosResponse<MirroringConfigResponse> = await axios.put(
`/api/v1/repository/${namespace}/${repoName}/mirror`,
config,
);
assertHttpCode(response.status, 201);
return response.data;
};
export const toggleMirroring = async (
namespace: string,
repoName: string,
isEnabled: boolean,
): Promise<MirroringConfigResponse> => {
return updateMirrorConfig(namespace, repoName, {is_enabled: isEnabled});
};
export const syncMirror = async (
namespace: string,
repoName: string,
): Promise<void> => {
const response = await axios.post(
`/api/v1/repository/${namespace}/${repoName}/mirror/sync-now`,
);
assertHttpCode(response.status, 204);
};
export const cancelSync = async (
namespace: string,
repoName: string,
): Promise<void> => {
const response = await axios.post(
`/api/v1/repository/${namespace}/${repoName}/mirror/sync-cancel`,
);
assertHttpCode(response.status, 204);
};
// Status message mapping
export const statusLabels: Record<
MirroringConfigResponse['sync_status'],
string
> = {
NEVER_RUN: 'Scheduled',
SYNC_NOW: 'Scheduled Now',
SYNC_FAILED: 'Failed',
SYNCING: 'Syncing',
SYNC_SUCCESS: 'Success',
};

View File

@@ -0,0 +1,14 @@
.pf-v5-c-title.pf-m-2xl {
text-align: left;
margin-top: var(--pf-v5-global--spacer--md);
}
.pf-v5-c-title.pf-m-xl {
text-align: left;
margin-top: var(--pf-v5-global--spacer--sm);
margin-bottom: var(--pf-v5-global--spacer--xl);
}
.pf-v5-c-title.pf-m-lg {
text-align: center;
}

View File

@@ -0,0 +1,254 @@
import React from 'react';
import {MirroringHeader} from './MirroringHeader';
import {MirroringConfiguration} from './MirroringConfiguration';
import {MirroringCredentials} from './MirroringCredentials';
import {MirroringAdvancedSettings} from './MirroringAdvancedSettings';
import {MirroringStatus} from './MirroringStatus';
import {MirroringModals} from './MirroringModals';
import {useMirroringConfig} from 'src/hooks/UseMirroringConfig';
import {useMirroringForm} from 'src/hooks/UseMirroringForm';
import {
Form,
Button,
ButtonVariant,
ActionGroup,
Divider,
Text,
TextContent,
SelectOption,
SelectGroup,
Spinner,
} from '@patternfly/react-core';
import {DesktopIcon, UsersIcon} from '@patternfly/react-icons';
import {useRepository} from 'src/hooks/UseRepository';
import {useAlerts} from 'src/hooks/UseAlerts';
import FormError from 'src/components/errors/FormError';
import {useFetchRobotAccounts} from 'src/hooks/useRobotAccounts';
import {useFetchTeams} from 'src/hooks/UseTeams';
import {Entity} from 'src/resources/UserResource';
import {useQueryClient} from '@tanstack/react-query';
import './Mirroring.css';
interface MirroringProps {
namespace: string;
repoName: string;
}
export const Mirroring: React.FC<MirroringProps> = ({namespace, repoName}) => {
const {
repoDetails,
errorLoadingRepoDetails,
isLoading: isLoadingRepo,
} = useRepository(namespace, repoName);
const {addAlert} = useAlerts();
const queryClient = useQueryClient();
// Initialize form hook
const formHook = useMirroringForm(
async (data) => {
await configHook.submitConfig(data);
},
addAlert,
(error) => configHook.setError(error),
);
// Initialize config hook
const configHook = useMirroringConfig(
namespace,
repoName,
repoDetails?.state,
formHook.reset,
formHook.setSelectedRobot,
);
// Fetch robot accounts and teams
const {robots} = useFetchRobotAccounts(namespace);
const {teams} = useFetchTeams(namespace);
// Create dropdown options
const robotOptions = [
<React.Fragment key="dropdown-options">
<SelectOption
key="create-team"
component="button"
onClick={() => formHook.setIsCreateTeamModalOpen(true)}
>
<UsersIcon /> &nbsp; Create team
</SelectOption>
<SelectOption
key="create-robot"
component="button"
onClick={() => formHook.setIsCreateRobotModalOpen(true)}
>
<DesktopIcon /> &nbsp; Create robot account
</SelectOption>
<Divider component="li" key="divider" />
<SelectGroup label="Teams" key="teams">
{teams?.map(({name}) => (
<SelectOption
key={name}
value={name}
onClick={() => formHook.handleTeamSelect(name)}
>
{name}
</SelectOption>
))}
</SelectGroup>
<SelectGroup label="Robot accounts" key="robot-accounts">
{robots?.map(({name}) => (
<SelectOption
key={name}
value={name}
onClick={() => formHook.handleRobotSelect(name)}
>
{name}
</SelectOption>
))}
</SelectGroup>
</React.Fragment>,
];
if (isLoadingRepo) {
return <Spinner size="md" />;
}
if (errorLoadingRepoDetails) {
return (
<FormError
message={
typeof errorLoadingRepoDetails === 'object' &&
errorLoadingRepoDetails !== null &&
'message' in errorLoadingRepoDetails
? String(errorLoadingRepoDetails.message)
: 'Error loading repository'
}
setErr={configHook.setError}
/>
);
}
if (!repoDetails) {
return <Text>Repository not found</Text>;
}
if (repoDetails.state !== 'MIRROR') {
return (
<div className="pf-v5-u-max-width-lg pf-v5-u-p-md">
<TextContent>
<Text>
This repository&apos;s state is <strong>{repoDetails.state}</strong>
. Use the settings tab and change it to <strong>Mirror</strong> to
manage its mirroring configuration.
</Text>
</TextContent>
</div>
);
}
if (configHook.isLoading) {
return <Spinner size="md" />;
}
if (configHook.error) {
return (
<FormError message={configHook.error} setErr={configHook.setError} />
);
}
return (
<div className="pf-v5-u-max-width-lg pf-v5-u-p-md">
<Form
isWidthLimited
data-testid="mirror-form"
onSubmit={formHook.handleSubmit(formHook.onSubmit)}
>
<MirroringHeader
namespace={namespace}
repoName={repoName}
isConfigured={!!configHook.config}
/>
<Divider className="pf-v5-u-mt-sm" />
<MirroringConfiguration
control={formHook.control}
errors={formHook.errors}
formValues={formHook.formValues}
config={configHook.config}
namespace={namespace}
repoName={repoName}
selectedRobot={formHook.selectedRobot}
setSelectedRobot={formHook.setSelectedRobot}
isSelectOpen={formHook.isSelectOpen}
setIsSelectOpen={formHook.setIsSelectOpen}
isHovered={formHook.isHovered}
setIsHovered={formHook.setIsHovered}
robotOptions={robotOptions}
setConfig={configHook.setConfig}
addAlert={addAlert}
/>
<MirroringCredentials
control={formHook.control}
errors={formHook.errors}
config={configHook.config}
/>
<MirroringAdvancedSettings
control={formHook.control}
errors={formHook.errors}
config={configHook.config}
/>
<MirroringStatus
config={configHook.config}
namespace={namespace}
repoName={repoName}
setConfig={configHook.setConfig}
addAlert={addAlert}
/>
<ActionGroup>
<Button
variant={ButtonVariant.primary}
className="pf-v5-u-display-block pf-v5-u-mx-auto"
type="button"
onClick={() => formHook.onSubmit(formHook.formValues)}
isDisabled={
(configHook.config && !formHook.isDirty) ||
!formHook.formValues.externalReference?.trim() ||
!formHook.formValues.tags?.trim() ||
(configHook.config &&
!formHook.formValues.syncStartDate?.trim()) ||
!formHook.formValues.syncValue?.trim() ||
!formHook.formValues.robotUsername?.trim() ||
!formHook.formValues.skopeoTimeoutInterval ||
formHook.formValues.skopeoTimeoutInterval < 300 ||
formHook.formValues.skopeoTimeoutInterval > 43200
}
data-testid="submit-button"
>
{configHook.config ? 'Update Mirror' : 'Enable Mirror'}
</Button>
</ActionGroup>
<MirroringModals
isCreateRobotModalOpen={formHook.isCreateRobotModalOpen}
setIsCreateRobotModalOpen={formHook.setIsCreateRobotModalOpen}
isCreateTeamModalOpen={formHook.isCreateTeamModalOpen}
setIsCreateTeamModalOpen={formHook.setIsCreateTeamModalOpen}
teamName={formHook.teamName}
setTeamName={formHook.setTeamName}
teamDescription={formHook.teamDescription}
setTeamDescription={formHook.setTeamDescription}
namespace={namespace}
teams={teams}
onRobotCreated={(robot: Entity) => {
formHook.setSelectedRobot(robot);
formHook.setValue('robotUsername', robot.name);
// Invalidate robot cache to refresh the list
queryClient.invalidateQueries(['robots']);
}}
onTeamCreated={(team: Entity) => {
formHook.setSelectedRobot(team);
formHook.setValue('robotUsername', team.name);
}}
addAlert={addAlert}
/>
</Form>
</div>
);
};

View File

@@ -0,0 +1,75 @@
import React from 'react';
import {Control, FieldErrors} from 'react-hook-form';
import {Divider, Title} from '@patternfly/react-core';
import {FormTextInput} from 'src/components/forms/FormTextInput';
import {FormCheckbox} from 'src/components/forms/FormCheckbox';
import {MirroringConfigResponse} from 'src/resources/MirroringResource';
import {MirroringFormData} from './types';
interface MirroringAdvancedSettingsProps {
control: Control<MirroringFormData>;
errors: FieldErrors<MirroringFormData>;
config: MirroringConfigResponse | null;
}
export const MirroringAdvancedSettings: React.FC<
MirroringAdvancedSettingsProps
> = ({control, errors, config}) => {
return (
<>
<Divider />
<Title headingLevel="h3">Advanced Settings</Title>
<FormCheckbox
name="verifyTls"
control={control}
label="Verify TLS"
fieldId="verify_tls"
description="Require HTTPS and verify certificates when talking to the external registry."
data-testid="verify-tls-checkbox"
/>
<FormCheckbox
name="unsignedImages"
control={control}
label="Accept Unsigned Images"
fieldId="unsigned_images"
description="Allow unsigned images to be mirrored."
data-testid="unsigned-images-checkbox"
/>
<FormTextInput
name="httpProxy"
control={control}
errors={errors}
label="HTTP Proxy"
fieldId="http_proxy"
placeholder="proxy.example.com"
showNoneWhenEmpty={!!config}
data-testid="http-proxy-input"
/>
<FormTextInput
name="httpsProxy"
control={control}
errors={errors}
label="HTTPs Proxy"
fieldId="https_proxy"
placeholder="proxy.example.com"
showNoneWhenEmpty={!!config}
data-testid="https-proxy-input"
/>
<FormTextInput
name="noProxy"
control={control}
errors={errors}
label="No Proxy"
fieldId="no_proxy"
placeholder="example.com"
showNoneWhenEmpty={!!config}
data-testid="no-proxy-input"
/>
</>
);
};

View File

@@ -0,0 +1,408 @@
import React from 'react';
import {Control, FieldErrors, Controller} from 'react-hook-form';
import {
FormGroup,
FormHelperText,
TextInput,
Button,
Text,
Title,
InputGroup,
InputGroupText,
Select,
SelectOption,
MenuToggle,
ValidatedOptions,
} from '@patternfly/react-core';
import {FormTextInput} from 'src/components/forms/FormTextInput';
import {FormCheckbox} from 'src/components/forms/FormCheckbox';
import EntitySearch from 'src/components/EntitySearch';
import {Entity} from 'src/resources/UserResource';
import {AlertVariant} from 'src/atoms/AlertState';
import {
MirroringConfigResponse,
getMirrorConfig,
toggleMirroring,
syncMirror,
} from 'src/resources/MirroringResource';
import {MirroringFormData} from './types';
interface MirroringConfigurationProps {
control: Control<MirroringFormData>;
errors: FieldErrors<MirroringFormData>;
formValues: MirroringFormData;
config: MirroringConfigResponse | null;
namespace: string;
repoName: string;
selectedRobot: Entity | null;
setSelectedRobot: (robot: Entity | null) => void;
isSelectOpen: boolean;
setIsSelectOpen: (open: boolean) => void;
isHovered: boolean;
setIsHovered: (hovered: boolean) => void;
robotOptions: React.ReactNode[];
setConfig: (config: MirroringConfigResponse) => void;
addAlert: (alert: {
variant: AlertVariant;
title: string;
message?: string;
}) => void;
}
export const MirroringConfiguration: React.FC<MirroringConfigurationProps> = ({
control,
errors,
formValues,
config,
namespace,
repoName,
selectedRobot,
setSelectedRobot,
isSelectOpen,
setIsSelectOpen,
isHovered,
setIsHovered,
robotOptions,
setConfig,
addAlert,
}) => {
return (
<>
<Title headingLevel="h3">
{config ? 'Configuration' : 'External Repository'}
</Title>
{config && (
<FormCheckbox
name="isEnabled"
control={control}
label="Enabled"
fieldId="is_enabled"
description={
formValues.isEnabled
? 'Scheduled mirroring enabled. Immediate sync available via Sync Now.'
: 'Scheduled mirroring disabled. Immediate sync available via Sync Now.'
}
data-testid="mirror-enabled-checkbox"
customOnChange={async (checked, onChange) => {
try {
await toggleMirroring(namespace, repoName, checked);
onChange(checked);
addAlert({
variant: AlertVariant.Success,
title: `Mirror ${
checked ? 'enabled' : 'disabled'
} successfully`,
});
} catch (err) {
addAlert({
variant: AlertVariant.Failure,
title: 'Error toggling mirror',
message: err.message,
});
}
}}
/>
)}
<FormTextInput
name="externalReference"
control={control}
errors={errors}
label="Registry Location"
fieldId="external_reference"
placeholder="quay.io/redhat/quay"
required
data-testid="registry-location-input"
/>
<FormTextInput
name="tags"
control={control}
errors={errors}
label="Tags"
fieldId="tags"
placeholder="Examples: latest, 3.3*, *"
required
helperText="Comma-separated list of tag patterns to synchronize."
data-testid="tags-input"
/>
<FormGroup
label={config ? 'Next Sync Date' : 'Start Date'}
fieldId="sync_start_date"
isStack
>
{config ? (
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
<Controller
name="syncStartDate"
control={control}
rules={{
required: 'This field is required',
validate: (value) =>
value?.trim() !== '' || 'This field is required',
}}
render={({field: {value, onChange}}) => (
<div style={{flex: 1}}>
<TextInput
type="datetime-local"
id="sync_start_date"
value={value}
onChange={(_event, newValue) => onChange(newValue)}
validated={
errors.syncStartDate
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
{errors.syncStartDate && (
<FormHelperText>
<Text component="p" className="pf-m-error pf-v5-u-mt-sm">
{errors.syncStartDate.message}
</Text>
</FormHelperText>
)}
</div>
)}
/>
<Button
variant="primary"
size="sm"
type="button"
isDisabled={
config.sync_status === 'SYNCING' ||
config.sync_status === 'SYNC_NOW'
}
data-testid="sync-now-button"
onClick={async () => {
try {
await syncMirror(namespace, repoName);
addAlert({
variant: AlertVariant.Success,
title: 'Sync scheduled successfully',
});
const response = await getMirrorConfig(namespace, repoName);
setConfig(response);
} catch (err) {
addAlert({
variant: AlertVariant.Failure,
title: 'Error scheduling sync',
message: err.message,
});
}
}}
>
Sync Now
</Button>
</div>
) : (
<FormTextInput
name="syncStartDate"
control={control}
errors={errors}
label=""
fieldId="sync_start_date"
type="datetime-local"
required
isStack={false}
/>
)}
</FormGroup>
<FormGroup label="Sync Interval" fieldId="sync_interval" isStack>
<InputGroup
onPointerEnterCapture={() => setIsHovered(true)}
onPointerLeaveCapture={() => setIsHovered(false)}
className={isHovered ? 'pf-v5-u-background-color-200' : ''}
>
<Controller
name="syncValue"
control={control}
rules={{
required: 'This field is required',
validate: (value) => {
if (!value || value.trim() === '') {
return 'This field is required';
}
const numValue = Number(value);
if (isNaN(numValue) || numValue <= 0) {
return 'Must be a positive number';
}
return true;
},
}}
render={({field: {value, onChange}}) => (
<TextInput
type="text"
id="sync_interval"
value={value}
onChange={(_event, newValue) => {
const numericValue = newValue.replace(/[^0-9]/g, '');
onChange(numericValue);
}}
pattern="[0-9]*"
inputMode="numeric"
validated={
errors.syncValue
? ValidatedOptions.error
: ValidatedOptions.default
}
aria-label="Sync interval value"
data-testid="sync-interval-input"
/>
)}
/>
<Controller
name="syncUnit"
control={control}
render={({field: {value, onChange}}) => (
<Select
isOpen={isSelectOpen}
onOpenChange={(isOpen) => setIsSelectOpen(isOpen)}
onSelect={(_event, selectedValue) => {
onChange(selectedValue as string);
setIsSelectOpen(false);
}}
selected={value}
aria-label="Sync interval unit"
toggle={(toggleRef) => (
<MenuToggle
ref={toggleRef}
onClick={() => setIsSelectOpen(!isSelectOpen)}
isExpanded={isSelectOpen}
>
{value}
</MenuToggle>
)}
>
<SelectOption value="seconds">seconds</SelectOption>
<SelectOption value="minutes">minutes</SelectOption>
<SelectOption value="hours">hours</SelectOption>
<SelectOption value="days">days</SelectOption>
<SelectOption value="weeks">weeks</SelectOption>
</Select>
)}
/>
</InputGroup>
{errors.syncValue && (
<FormHelperText>
<Text component="p" className="pf-m-error">
{errors.syncValue.message}
</Text>
</FormHelperText>
)}
</FormGroup>
<FormGroup
label="Skopeo timeout interval"
fieldId="skopeo_timeout_interval"
isStack
>
<Controller
name="skopeoTimeoutInterval"
control={control}
rules={{
required: 'This field is required',
validate: (value) => {
if (!value || value < 300) {
return 'Minimum timeout is 300 seconds (5 minutes)';
}
if (value > 43200) {
return 'Maximum timeout is 43200 seconds (12 hours)';
}
return true;
},
}}
render={({field: {value, onChange}}) => (
<InputGroup
onPointerEnterCapture={() => setIsHovered(true)}
onPointerLeaveCapture={() => setIsHovered(false)}
className={isHovered ? 'pf-v5-u-background-color-200' : ''}
>
<TextInput
type="number"
id="skopeo_timeout_interval"
value={value?.toString() || ''}
onChange={(_event, newValue) => {
const numericValue = parseInt(newValue) || 300;
onChange(numericValue);
}}
min="300"
max="43200"
validated={
errors.skopeoTimeoutInterval
? ValidatedOptions.error
: ValidatedOptions.default
}
aria-label="Skopeo timeout interval"
data-testid="skopeo-timeout-input"
/>
<InputGroupText>seconds</InputGroupText>
</InputGroup>
)}
/>
{errors.skopeoTimeoutInterval && (
<FormHelperText>
<Text component="p" className="pf-m-error">
{errors.skopeoTimeoutInterval.message}
</Text>
</FormHelperText>
)}
<FormHelperText>
<Text component="p">
Minimum timeout length: 300 seconds (5 minutes). Maximum timeout
length: 43200 seconds (12 hours).
</Text>
</FormHelperText>
</FormGroup>
<FormGroup label="Robot User" fieldId="robot_username" isStack>
<Controller
name="robotUsername"
control={control}
rules={{
required: 'This field is required',
validate: (value) =>
value?.trim() !== '' || 'This field is required',
}}
render={({field}) => (
<>
<EntitySearch
id="robot-user-select"
org={namespace}
includeTeams={true}
onSelect={(robot: Entity) => {
setSelectedRobot(robot);
field.onChange(robot.name);
}}
onClear={() => {
setSelectedRobot(null);
field.onChange('');
}}
value={selectedRobot?.name}
onError={() =>
addAlert({
variant: AlertVariant.Failure,
title: 'Error loading robot users',
message: 'Failed to load available robots',
})
}
defaultOptions={robotOptions}
placeholderText="Select a team or user..."
data-testid="robot-user-select"
/>
{errors.robotUsername && (
<FormHelperText>
<Text component="p" className="pf-m-error">
{errors.robotUsername.message}
</Text>
</FormHelperText>
)}
</>
)}
/>
</FormGroup>
</>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {Control, FieldErrors} from 'react-hook-form';
import {Divider, Text, Title} from '@patternfly/react-core';
import {FormTextInput} from 'src/components/forms/FormTextInput';
import {MirroringConfigResponse} from 'src/resources/MirroringResource';
import {MirroringFormData} from './types';
interface MirroringCredentialsProps {
control: Control<MirroringFormData>;
errors: FieldErrors<MirroringFormData>;
config: MirroringConfigResponse | null;
}
export const MirroringCredentials: React.FC<MirroringCredentialsProps> = ({
control,
errors,
config,
}) => {
return (
<>
<Divider />
<Title headingLevel="h3">Credentials</Title>
<Text
component="small"
className="pf-v5-c-form__helper-text pf-v5-u-text-align-center pf-v5-u-display-block"
>
Required if the external repository is private.
</Text>
<FormTextInput
name="username"
control={control}
errors={errors}
label="Username"
fieldId="username"
showNoneWhenEmpty={!!config}
data-testid="username-input"
/>
<FormTextInput
name="password"
control={control}
errors={errors}
label="Password"
fieldId="external_registry_password"
type="password"
showNoneWhenEmpty={!!config}
data-testid="password-input"
/>
</>
);
};

View File

@@ -0,0 +1,42 @@
import React from 'react';
import {TextContent, Text, Title} from '@patternfly/react-core';
interface MirroringHeaderProps {
namespace: string;
repoName: string;
isConfigured: boolean;
}
export const MirroringHeader: React.FC<MirroringHeaderProps> = ({
namespace,
repoName,
isConfigured,
}) => {
return (
<>
<TextContent>
<Title headingLevel="h2">Repository Mirroring</Title>
</TextContent>
<TextContent>
{isConfigured ? (
<Text>
This repository is configured as a mirror. While enabled, Quay will
periodically replicate any matching images on the external registry.
Users cannot manually push to this repository.
</Text>
) : (
<Text>
This feature will convert{' '}
<strong>
{namespace}/{repoName}
</strong>{' '}
into a mirror. Changes to the external repository will be duplicated
here. While enabled, users will be unable to push images to this
repository.
</Text>
)}
</TextContent>
</>
);
};

View File

@@ -0,0 +1,88 @@
import React from 'react';
import {AlertVariant} from 'src/atoms/AlertState';
import CreateRobotAccountModal from 'src/components/modals/CreateRobotAccountModal';
import {CreateTeamModal} from 'src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreateTeamModal';
import {Entity} from 'src/resources/UserResource';
import {RepoPermissionDropdownItems} from 'src/routes/RepositoriesList/RobotAccountsList';
import {validateTeamName} from 'src/libs/utils';
import {ITeams} from 'src/hooks/UseTeams';
interface MirroringModalsProps {
// Robot modal props
isCreateRobotModalOpen: boolean;
setIsCreateRobotModalOpen: (open: boolean) => void;
// Team modal props
isCreateTeamModalOpen: boolean;
setIsCreateTeamModalOpen: (open: boolean) => void;
teamName: string;
setTeamName: (name: string) => void;
teamDescription: string;
setTeamDescription: (description: string) => void;
// Common props
namespace: string;
teams: ITeams[];
// Callbacks
onRobotCreated: (robot: Entity) => void;
onTeamCreated: (team: Entity) => void;
addAlert: (alert: {
variant: AlertVariant;
title: string;
message?: string;
}) => void;
}
export const MirroringModals: React.FC<MirroringModalsProps> = ({
isCreateRobotModalOpen,
setIsCreateRobotModalOpen,
isCreateTeamModalOpen,
setIsCreateTeamModalOpen,
teamName,
setTeamName,
teamDescription,
setTeamDescription,
namespace,
teams,
onRobotCreated,
onTeamCreated,
addAlert,
}) => {
return (
<>
{/* Robot Creation Modal */}
<CreateRobotAccountModal
isModalOpen={isCreateRobotModalOpen}
handleModalToggle={() => setIsCreateRobotModalOpen(false)}
orgName={namespace}
teams={teams}
RepoPermissionDropdownItems={RepoPermissionDropdownItems}
setEntity={onRobotCreated}
showSuccessAlert={(msg) =>
addAlert({variant: AlertVariant.Success, title: msg})
}
showErrorAlert={(msg) =>
addAlert({variant: AlertVariant.Failure, title: msg})
}
/>
{/* Team Creation Modal */}
<CreateTeamModal
teamName={teamName}
setTeamName={setTeamName}
description={teamDescription}
setDescription={setTeamDescription}
orgName={namespace}
nameLabel="Provide a name for your new team:"
descriptionLabel="Provide an optional description for your new team"
helperText="Enter a description to provide extra information to your teammates about this team:"
nameHelperText="Choose a name to inform your teammates about this team. Must match ^([a-z0-9]+(?:[._-][a-z0-9]+)*)$"
isModalOpen={isCreateTeamModalOpen}
handleModalToggle={() => setIsCreateTeamModalOpen(false)}
validateName={validateTeamName}
setAppliedTo={onTeamCreated}
/>
</>
);
};

View File

@@ -0,0 +1,92 @@
import React from 'react';
import {Divider, Button} from '@patternfly/react-core';
import {StatusDisplay} from 'src/components/StatusDisplay';
import {AlertVariant} from 'src/atoms/AlertState';
import {
MirroringConfigResponse,
getMirrorConfig,
cancelSync,
statusLabels,
} from 'src/resources/MirroringResource';
interface MirroringStatusProps {
config: MirroringConfigResponse | null;
namespace: string;
repoName: string;
setConfig: (config: MirroringConfigResponse) => void;
addAlert: (alert: {
variant: AlertVariant;
title: string;
message?: string;
}) => void;
}
export const MirroringStatus: React.FC<MirroringStatusProps> = ({
config,
namespace,
repoName,
setConfig,
addAlert,
}) => {
if (!config) {
return null;
}
return (
<>
<Divider />
<StatusDisplay
title="Status"
data-testid="mirror-status-display"
items={[
{
label: 'State',
value: statusLabels[config.sync_status] || config.sync_status,
action: (
<Button
variant="danger"
size="sm"
type="button"
isDisabled={
config.sync_status !== 'SYNCING' &&
config.sync_status !== 'SYNC_NOW'
}
data-testid="cancel-sync-button"
onClick={async () => {
try {
await cancelSync(namespace, repoName);
addAlert({
variant: AlertVariant.Success,
title: 'Sync cancelled successfully',
});
const response = await getMirrorConfig(namespace, repoName);
setConfig(response);
} catch (err) {
addAlert({
variant: AlertVariant.Failure,
title: 'Error cancelling sync',
message: err.message,
});
}
}}
>
Cancel
</Button>
),
},
{
label: 'Timeout',
value: config.sync_expiration_date || 'None',
},
{
label: 'Retries Remaining',
value:
config.sync_retries_remaining != null
? `${config.sync_retries_remaining} / 3`
: '3 / 3',
},
]}
/>
</>
);
};

View File

@@ -0,0 +1,18 @@
// Form data types for Mirroring components
export interface MirroringFormData {
isEnabled: boolean;
externalReference: string;
tags: string;
syncStartDate: string;
syncValue: string;
syncUnit: string;
robotUsername: string;
username: string;
password: string;
verifyTls: boolean;
httpProxy: string;
httpsProxy: string;
noProxy: string;
unsignedImages: boolean;
skopeoTimeoutInterval: number;
}

View File

@@ -42,6 +42,7 @@ import TagHistory from './TagHistory/TagHistory';
import TagsList from './Tags/TagsList';
import {DrawerContentType} from './Types';
import UsageLogs from '../UsageLogs/UsageLogs';
import {Mirroring} from './Mirroring/Mirroring';
enum TabIndex {
Tags = 'tags',
@@ -49,6 +50,7 @@ enum TabIndex {
TagHistory = 'history',
Builds = 'builds',
Logs = 'logs',
Mirroring = 'mirroring',
Settings = 'settings',
}
@@ -64,7 +66,7 @@ export default function RepositoryDetails() {
const [activeTabKey, setActiveTabKey] = useState(TabIndex.Tags);
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const [drawerContent, setDrawerContent] = useState<DrawerContentType>(
DrawerContentType.None,
);
@@ -235,6 +237,8 @@ export default function RepositoryDetails() {
<Tab
eventKey={TabIndex.Tags}
title={<TabTitleText>Tags</TabTitleText>}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
>
<TagsList
organization={organization}
@@ -245,6 +249,8 @@ export default function RepositoryDetails() {
<Tab
eventKey={TabIndex.TagHistory}
title={<TabTitleText>Tag history</TabTitleText>}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
>
<TagHistory
org={organization}
@@ -255,6 +261,8 @@ export default function RepositoryDetails() {
<Tab
eventKey={TabIndex.Logs}
title={<TabTitleText>Logs</TabTitleText>}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
>
<UsageLogs
organization={organization}
@@ -262,6 +270,35 @@ export default function RepositoryDetails() {
type="repository"
/>
</Tab>
<Tab
eventKey={TabIndex.Mirroring}
title={<TabTitleText>Mirroring</TabTitleText>}
data-testid="mirroring-tab"
isHidden={
!config?.features?.REPO_MIRROR || !repoDetails?.can_admin
}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
>
{repoDetails?.state !== 'MIRROR' ? (
<div>
This repository&apos;s state is{' '}
<strong>{repoDetails?.state}</strong>. Use the{' '}
<a
href={`/repository/${repoDetails?.namespace}/${repoDetails?.name}?tab=settings`}
>
Settings tab
</a>{' '}
and change it to <strong>Mirror</strong> to manage its
mirroring configuration.
</div>
) : (
<Mirroring
namespace={organization}
repoName={repository}
/>
)}
</Tab>
<Tab
eventKey={TabIndex.Builds}
title={<TabTitleText>Builds</TabTitleText>}
@@ -270,6 +307,8 @@ export default function RepositoryDetails() {
repoDetails?.state !== 'NORMAL' ||
(!repoDetails?.can_write && !repoDetails?.can_admin)
}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
>
<Builds
org={organization}
@@ -282,6 +321,8 @@ export default function RepositoryDetails() {
eventKey={TabIndex.Settings}
title={<TabTitleText>Settings</TabTitleText>}
isHidden={!repoDetails?.can_admin}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
>
<Settings
org={organization}