From 71cf930665609668f56a67ee8c730bf170245074 Mon Sep 17 00:00:00 2001 From: Mark Franceschelli <39063664+mfrances17@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:01:56 -0400 Subject: [PATCH] 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. --- web/cypress/e2e/mirroring.cy.ts | 709 ++++++++++++++++++ web/package-lock.json | 22 + web/package.json | 1 + web/src/components/StatusDisplay.tsx | 59 ++ web/src/components/forms/FormCheckbox.tsx | 53 ++ web/src/components/forms/FormTextInput.tsx | 132 ++++ web/src/hooks/UseMirroringConfig.ts | 145 ++++ web/src/hooks/UseMirroringForm.ts | 143 ++++ web/src/libs/utils.ts | 39 + web/src/resources/MirroringResource.ts | 134 ++++ .../RepositoryDetails/Mirroring/Mirroring.css | 14 + .../RepositoryDetails/Mirroring/Mirroring.tsx | 254 +++++++ .../Mirroring/MirroringAdvancedSettings.tsx | 75 ++ .../Mirroring/MirroringConfiguration.tsx | 408 ++++++++++ .../Mirroring/MirroringCredentials.tsx | 52 ++ .../Mirroring/MirroringHeader.tsx | 42 ++ .../Mirroring/MirroringModals.tsx | 88 +++ .../Mirroring/MirroringStatus.tsx | 92 +++ .../RepositoryDetails/Mirroring/types.ts | 18 + .../RepositoryDetails/RepositoryDetails.tsx | 43 +- 20 files changed, 2522 insertions(+), 1 deletion(-) create mode 100644 web/cypress/e2e/mirroring.cy.ts create mode 100644 web/src/components/StatusDisplay.tsx create mode 100644 web/src/components/forms/FormCheckbox.tsx create mode 100644 web/src/components/forms/FormTextInput.tsx create mode 100644 web/src/hooks/UseMirroringConfig.ts create mode 100644 web/src/hooks/UseMirroringForm.ts create mode 100644 web/src/resources/MirroringResource.ts create mode 100644 web/src/routes/RepositoryDetails/Mirroring/Mirroring.css create mode 100644 web/src/routes/RepositoryDetails/Mirroring/Mirroring.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/MirroringAdvancedSettings.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/MirroringConfiguration.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/MirroringCredentials.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/MirroringHeader.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/MirroringModals.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/MirroringStatus.tsx create mode 100644 web/src/routes/RepositoryDetails/Mirroring/types.ts 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 ( +
+
+ + + + + + + + + + { + 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} + /> + +
+ ); +}; 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} + + + )} +
+ )} + /> + +
+ ) : ( + + )} +
+ + + 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" + /> + )} + /> + ( + + )} + /> + + {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} >