diff --git a/web/cypress/e2e/mirroring.cy.ts b/web/cypress/e2e/mirroring.cy.ts
new file mode 100644
index 000000000..dac6611c5
--- /dev/null
+++ b/web/cypress/e2e/mirroring.cy.ts
@@ -0,0 +1,709 @@
+///
+
+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',
+ );
+ });
+ });
+});
diff --git a/web/package-lock.json b/web/package-lock.json
index 8be6badf8..461d1e6ba 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -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",
diff --git a/web/package.json b/web/package.json
index 0b1a0489b..4871dc1d0 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/src/components/StatusDisplay.tsx b/web/src/components/StatusDisplay.tsx
new file mode 100644
index 000000000..294a6521e
--- /dev/null
+++ b/web/src/components/StatusDisplay.tsx
@@ -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} }
+
+
+
+ {items.map((item, index) => (
+
+ {item.label}
+
+ {item.action ? (
+
+
+ {item.value}
+
+ {item.action}
+
+ ) : (
+ item.value
+ )}
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/forms/FormCheckbox.tsx b/web/src/components/forms/FormCheckbox.tsx
new file mode 100644
index 000000000..ee23df809
--- /dev/null
+++ b/web/src/components/forms/FormCheckbox.tsx
@@ -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 {
+ name: Path;
+ control: Control;
+ label: string;
+ fieldId?: string;
+ description?: string;
+ isStack?: boolean;
+ 'data-testid'?: string;
+ customOnChange?: (
+ checked: boolean,
+ onChange: (value: boolean) => void,
+ ) => void;
+}
+
+export function FormCheckbox({
+ name,
+ control,
+ label,
+ fieldId,
+ description,
+ isStack = true,
+ 'data-testid': dataTestId,
+ customOnChange,
+}: FormCheckboxProps) {
+ return (
+
+ (
+ {
+ if (customOnChange) {
+ customOnChange(checked, onChange);
+ } else {
+ onChange(checked);
+ }
+ }}
+ data-testid={dataTestId}
+ />
+ )}
+ />
+
+ );
+}
diff --git a/web/src/components/forms/FormTextInput.tsx b/web/src/components/forms/FormTextInput.tsx
new file mode 100644
index 000000000..fa5c5bfc5
--- /dev/null
+++ b/web/src/components/forms/FormTextInput.tsx
@@ -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 {
+ name: Path;
+ control: Control;
+ errors: FieldErrors;
+ 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({
+ 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) {
+ 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 (
+
+ {
+ const displayValue =
+ showNoneWhenEmpty && (!value || value === '')
+ ? 'None'
+ : value || '';
+ const handleChange = (
+ _event: React.FormEvent,
+ newValue: string,
+ ) => {
+ if (showNoneWhenEmpty && newValue === 'None') {
+ onChange('');
+ } else {
+ onChange(newValue);
+ }
+ };
+
+ return (
+ <>
+
+ {fieldError && (
+
+
+ {fieldError.message as string}
+
+
+ )}
+ {helperText && !fieldError && (
+
+ {helperText}
+
+ )}
+ >
+ );
+ }}
+ />
+
+ );
+}
diff --git a/web/src/hooks/UseMirroringConfig.ts b/web/src/hooks/UseMirroringConfig.ts
new file mode 100644
index 000000000..fe3dbbdd5
--- /dev/null
+++ b/web/src/hooks/UseMirroringConfig.ts
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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,
+ };
+};
diff --git a/web/src/hooks/UseMirroringForm.ts b/web/src/hooks/UseMirroringForm.ts
new file mode 100644
index 000000000..7d3d3e270
--- /dev/null
+++ b/web/src/hooks/UseMirroringForm.ts
@@ -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,
+ addAlert: (alert: {
+ variant: AlertVariant;
+ title: string;
+ message?: string;
+ }) => void,
+ setError: (error: string | null) => void,
+) => {
+ // Initialize react-hook-form
+ const form = useForm({
+ 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(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,
+ };
+};
diff --git a/web/src/libs/utils.ts b/web/src/libs/utils.ts
index 4f851507c..68a792a37 100644
--- a/web/src/libs/utils.ts
+++ b/web/src/libs/utils.ts
@@ -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 '';
+ }
+};
diff --git a/web/src/resources/MirroringResource.ts b/web/src/resources/MirroringResource.ts
new file mode 100644
index 000000000..d9d89dfbb
--- /dev/null
+++ b/web/src/resources/MirroringResource.ts
@@ -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 => {
+ const response: AxiosResponse = 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 => {
+ const response: AxiosResponse = 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,
+): Promise => {
+ const response: AxiosResponse = 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 => {
+ return updateMirrorConfig(namespace, repoName, {is_enabled: isEnabled});
+};
+
+export const syncMirror = async (
+ namespace: string,
+ repoName: string,
+): Promise => {
+ 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 => {
+ 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',
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/Mirroring.css b/web/src/routes/RepositoryDetails/Mirroring/Mirroring.css
new file mode 100644
index 000000000..a5682c269
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/Mirroring.css
@@ -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;
+}
diff --git a/web/src/routes/RepositoryDetails/Mirroring/Mirroring.tsx b/web/src/routes/RepositoryDetails/Mirroring/Mirroring.tsx
new file mode 100644
index 000000000..2ab5eb5a2
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/Mirroring.tsx
@@ -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 = ({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 = [
+
+ formHook.setIsCreateTeamModalOpen(true)}
+ >
+ Create team
+
+ formHook.setIsCreateRobotModalOpen(true)}
+ >
+ Create robot account
+
+
+
+ {teams?.map(({name}) => (
+ formHook.handleTeamSelect(name)}
+ >
+ {name}
+
+ ))}
+
+
+ {robots?.map(({name}) => (
+ formHook.handleRobotSelect(name)}
+ >
+ {name}
+
+ ))}
+
+ ,
+ ];
+
+ if (isLoadingRepo) {
+ return ;
+ }
+
+ if (errorLoadingRepoDetails) {
+ return (
+
+ );
+ }
+
+ if (!repoDetails) {
+ return Repository not found ;
+ }
+
+ if (repoDetails.state !== 'MIRROR') {
+ return (
+
+
+
+ This repository's state is {repoDetails.state}
+ . Use the settings tab and change it to Mirror to
+ manage its mirroring configuration.
+
+
+
+ );
+ }
+
+ if (configHook.isLoading) {
+ return ;
+ }
+
+ if (configHook.error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/MirroringAdvancedSettings.tsx b/web/src/routes/RepositoryDetails/Mirroring/MirroringAdvancedSettings.tsx
new file mode 100644
index 000000000..4b5dc18cf
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/MirroringAdvancedSettings.tsx
@@ -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;
+ errors: FieldErrors;
+ config: MirroringConfigResponse | null;
+}
+
+export const MirroringAdvancedSettings: React.FC<
+ MirroringAdvancedSettingsProps
+> = ({control, errors, config}) => {
+ return (
+ <>
+
+ Advanced Settings
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/MirroringConfiguration.tsx b/web/src/routes/RepositoryDetails/Mirroring/MirroringConfiguration.tsx
new file mode 100644
index 000000000..20f1af3ef
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/MirroringConfiguration.tsx
@@ -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;
+ errors: FieldErrors;
+ 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 = ({
+ control,
+ errors,
+ formValues,
+ config,
+ namespace,
+ repoName,
+ selectedRobot,
+ setSelectedRobot,
+ isSelectOpen,
+ setIsSelectOpen,
+ isHovered,
+ setIsHovered,
+ robotOptions,
+ setConfig,
+ addAlert,
+}) => {
+ return (
+ <>
+
+ {config ? 'Configuration' : 'External Repository'}
+
+
+ {config && (
+ {
+ 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,
+ });
+ }
+ }}
+ />
+ )}
+
+
+
+
+
+
+ {config ? (
+
+
+ value?.trim() !== '' || 'This field is required',
+ }}
+ render={({field: {value, onChange}}) => (
+
+ onChange(newValue)}
+ validated={
+ errors.syncStartDate
+ ? ValidatedOptions.error
+ : ValidatedOptions.default
+ }
+ />
+ {errors.syncStartDate && (
+
+
+ {errors.syncStartDate.message}
+
+
+ )}
+
+ )}
+ />
+ {
+ 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
+
+
+ ) : (
+
+ )}
+
+
+
+ setIsHovered(true)}
+ onPointerLeaveCapture={() => setIsHovered(false)}
+ className={isHovered ? 'pf-v5-u-background-color-200' : ''}
+ >
+ {
+ 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}}) => (
+ {
+ 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"
+ />
+ )}
+ />
+ (
+ setIsSelectOpen(isOpen)}
+ onSelect={(_event, selectedValue) => {
+ onChange(selectedValue as string);
+ setIsSelectOpen(false);
+ }}
+ selected={value}
+ aria-label="Sync interval unit"
+ toggle={(toggleRef) => (
+ setIsSelectOpen(!isSelectOpen)}
+ isExpanded={isSelectOpen}
+ >
+ {value}
+
+ )}
+ >
+ seconds
+ minutes
+ hours
+ days
+ weeks
+
+ )}
+ />
+
+ {errors.syncValue && (
+
+
+ {errors.syncValue.message}
+
+
+ )}
+
+
+
+ {
+ 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}}) => (
+ setIsHovered(true)}
+ onPointerLeaveCapture={() => setIsHovered(false)}
+ className={isHovered ? 'pf-v5-u-background-color-200' : ''}
+ >
+ {
+ 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"
+ />
+ seconds
+
+ )}
+ />
+ {errors.skopeoTimeoutInterval && (
+
+
+ {errors.skopeoTimeoutInterval.message}
+
+
+ )}
+
+
+ Minimum timeout length: 300 seconds (5 minutes). Maximum timeout
+ length: 43200 seconds (12 hours).
+
+
+
+
+
+
+ value?.trim() !== '' || 'This field is required',
+ }}
+ render={({field}) => (
+ <>
+ {
+ 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 && (
+
+
+ {errors.robotUsername.message}
+
+
+ )}
+ >
+ )}
+ />
+
+ >
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/MirroringCredentials.tsx b/web/src/routes/RepositoryDetails/Mirroring/MirroringCredentials.tsx
new file mode 100644
index 000000000..dacbced03
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/MirroringCredentials.tsx
@@ -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;
+ errors: FieldErrors;
+ config: MirroringConfigResponse | null;
+}
+
+export const MirroringCredentials: React.FC = ({
+ control,
+ errors,
+ config,
+}) => {
+ return (
+ <>
+
+ Credentials
+
+ Required if the external repository is private.
+
+
+
+
+
+ >
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/MirroringHeader.tsx b/web/src/routes/RepositoryDetails/Mirroring/MirroringHeader.tsx
new file mode 100644
index 000000000..73428e612
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/MirroringHeader.tsx
@@ -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 = ({
+ namespace,
+ repoName,
+ isConfigured,
+}) => {
+ return (
+ <>
+
+ Repository Mirroring
+
+
+
+ {isConfigured ? (
+
+ 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.
+
+ ) : (
+
+ This feature will convert{' '}
+
+ {namespace}/{repoName}
+ {' '}
+ into a mirror. Changes to the external repository will be duplicated
+ here. While enabled, users will be unable to push images to this
+ repository.
+
+ )}
+
+ >
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/MirroringModals.tsx b/web/src/routes/RepositoryDetails/Mirroring/MirroringModals.tsx
new file mode 100644
index 000000000..fbf7c4dcd
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/MirroringModals.tsx
@@ -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 = ({
+ isCreateRobotModalOpen,
+ setIsCreateRobotModalOpen,
+ isCreateTeamModalOpen,
+ setIsCreateTeamModalOpen,
+ teamName,
+ setTeamName,
+ teamDescription,
+ setTeamDescription,
+ namespace,
+ teams,
+ onRobotCreated,
+ onTeamCreated,
+ addAlert,
+}) => {
+ return (
+ <>
+ {/* Robot Creation Modal */}
+ 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 */}
+ setIsCreateTeamModalOpen(false)}
+ validateName={validateTeamName}
+ setAppliedTo={onTeamCreated}
+ />
+ >
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/MirroringStatus.tsx b/web/src/routes/RepositoryDetails/Mirroring/MirroringStatus.tsx
new file mode 100644
index 000000000..e9e4bf807
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/MirroringStatus.tsx
@@ -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 = ({
+ config,
+ namespace,
+ repoName,
+ setConfig,
+ addAlert,
+}) => {
+ if (!config) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {
+ 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
+
+ ),
+ },
+ {
+ label: 'Timeout',
+ value: config.sync_expiration_date || 'None',
+ },
+ {
+ label: 'Retries Remaining',
+ value:
+ config.sync_retries_remaining != null
+ ? `${config.sync_retries_remaining} / 3`
+ : '3 / 3',
+ },
+ ]}
+ />
+ >
+ );
+};
diff --git a/web/src/routes/RepositoryDetails/Mirroring/types.ts b/web/src/routes/RepositoryDetails/Mirroring/types.ts
new file mode 100644
index 000000000..1cc1f1b90
--- /dev/null
+++ b/web/src/routes/RepositoryDetails/Mirroring/types.ts
@@ -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;
+}
diff --git a/web/src/routes/RepositoryDetails/RepositoryDetails.tsx b/web/src/routes/RepositoryDetails/RepositoryDetails.tsx
index 27c9472bb..4577fb9d5 100644
--- a/web/src/routes/RepositoryDetails/RepositoryDetails.tsx
+++ b/web/src/routes/RepositoryDetails/RepositoryDetails.tsx
@@ -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.None,
);
@@ -235,6 +237,8 @@ export default function RepositoryDetails() {
Tags}
+ onPointerEnterCapture={undefined}
+ onPointerLeaveCapture={undefined}
>
Tag history}
+ onPointerEnterCapture={undefined}
+ onPointerLeaveCapture={undefined}
>
Logs}
+ onPointerEnterCapture={undefined}
+ onPointerLeaveCapture={undefined}
>
+ Mirroring}
+ data-testid="mirroring-tab"
+ isHidden={
+ !config?.features?.REPO_MIRROR || !repoDetails?.can_admin
+ }
+ onPointerEnterCapture={undefined}
+ onPointerLeaveCapture={undefined}
+ >
+ {repoDetails?.state !== 'MIRROR' ? (
+
+ This repository's state is{' '}
+
{repoDetails?.state} . Use the{' '}
+
+ Settings tab
+ {' '}
+ and change it to
Mirror to manage its
+ mirroring configuration.
+
+ ) : (
+
+ )}
+
Builds}
@@ -270,6 +307,8 @@ export default function RepositoryDetails() {
repoDetails?.state !== 'NORMAL' ||
(!repoDetails?.can_write && !repoDetails?.can_admin)
}
+ onPointerEnterCapture={undefined}
+ onPointerLeaveCapture={undefined}
>
Settings}
isHidden={!repoDetails?.can_admin}
+ onPointerEnterCapture={undefined}
+ onPointerLeaveCapture={undefined}
>