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} >