mirror of
https://github.com/quay/quay.git
synced 2026-01-26 06:21:37 +03:00
ui: Add Mirroring to ui (PROJQUAY-8886) (#4121)
This adds react code and patternfly components to handle Mirroring in the new Quay UI. Functionality should be equivalent to the old Angular code, and new test suite is passing locally. Added the react-hook-form library to simplify and reduce redundant form code. Also added the new Skopeo timeout interval field.
This commit is contained in:
committed by
GitHub
parent
ca4d71df16
commit
71cf930665
709
web/cypress/e2e/mirroring.cy.ts
Normal file
709
web/cypress/e2e/mirroring.cy.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Repository Mirroring', () => {
|
||||
beforeEach(() => {
|
||||
cy.exec('npm run quay:seed');
|
||||
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
|
||||
.then((response) => response.body.csrf_token)
|
||||
.then((token) => {
|
||||
cy.loginByCSRF(token);
|
||||
});
|
||||
// Enable mirroring feature
|
||||
cy.intercept('GET', '/config', (req) =>
|
||||
req.reply((res) => {
|
||||
res.body.features['REPO_MIRROR'] = true;
|
||||
return res;
|
||||
}),
|
||||
).as('getConfig');
|
||||
});
|
||||
|
||||
describe('Feature Flag Behavior', () => {
|
||||
it('should not show mirroring tab when REPO_MIRROR feature is disabled', () => {
|
||||
cy.intercept('GET', '/config', (req) =>
|
||||
req.reply((res) => {
|
||||
res.body.features['REPO_MIRROR'] = false;
|
||||
return res;
|
||||
}),
|
||||
).as('getConfigNoMirror');
|
||||
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
cy.visit('/repository/user1/hello-world');
|
||||
cy.wait('@getConfigNoMirror');
|
||||
cy.get('[data-testid="mirroring-tab"]').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show mirroring tab when REPO_MIRROR feature is enabled', () => {
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
cy.visit('/repository/user1/hello-world');
|
||||
cy.wait('@getConfig');
|
||||
cy.get('[data-testid="mirroring-tab"]').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository State Requirements', () => {
|
||||
it('should show state warning for non-mirror repositories', () => {
|
||||
// Mock repository with NORMAL state - using wildcard pattern to catch all variations
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'NORMAL',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
cy.contains("This repository's state is NORMAL").should('exist');
|
||||
cy.contains('Use the Settings tab and change it to Mirror').should(
|
||||
'exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show mirroring form for mirror repositories', () => {
|
||||
// Mock repository with MIRROR state - using wildcard pattern to catch all variations
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
// Mock no existing mirror config (404)
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 404,
|
||||
}).as('getMirrorConfig404');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
cy.contains('Repository Mirroring').should('exist');
|
||||
cy.contains('External Repository').should('exist');
|
||||
cy.get('[data-testid="mirror-form"]').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('New Mirror Configuration', () => {
|
||||
beforeEach(() => {
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
// Mock no existing mirror config (404)
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world/mirror*', {
|
||||
statusCode: 404,
|
||||
}).as('getMirrorConfig404');
|
||||
|
||||
// Mock robot accounts
|
||||
cy.intercept('GET', '/api/v1/organization/user1/robots*', {
|
||||
fixture: 'robots.json',
|
||||
}).as('getRobots');
|
||||
});
|
||||
|
||||
it('should display new mirror form with correct initial state', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
|
||||
// Wait for all API calls to complete (component should automatically stop loading)
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig404');
|
||||
cy.wait('@getRobots');
|
||||
// Note: Teams API may not be called immediately, test form loading first
|
||||
|
||||
// Give component time to process 404 response and update loading state
|
||||
cy.wait(500);
|
||||
|
||||
// Check form title and description
|
||||
cy.contains('External Repository').should('exist');
|
||||
cy.contains(
|
||||
'This feature will convert user1/hello-world into a mirror',
|
||||
).should('exist');
|
||||
|
||||
// For new mirrors, the enabled checkbox is NOT shown (only for existing configs)
|
||||
// Check for basic form fields that should be present
|
||||
cy.get('[data-testid="registry-location-input"]').should('exist');
|
||||
cy.get('[data-testid="tags-input"]').should('exist');
|
||||
|
||||
// Check empty form fields
|
||||
cy.get('[data-testid="registry-location-input"]').should(
|
||||
'have.value',
|
||||
'',
|
||||
);
|
||||
cy.get('[data-testid="tags-input"]').should('have.value', '');
|
||||
cy.get('[data-testid="username-input"]').should('have.value', '');
|
||||
cy.get('[data-testid="password-input"]').should('have.value', '');
|
||||
|
||||
// Check button says "Enable Mirror"
|
||||
cy.get('[data-testid="submit-button"]').should(
|
||||
'contain.text',
|
||||
'Enable Mirror',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate required fields', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig404');
|
||||
cy.wait('@getRobots');
|
||||
|
||||
// Submit button should be disabled initially
|
||||
cy.get('[data-testid="submit-button"]').should('be.disabled');
|
||||
|
||||
// Fill in required fields one by one
|
||||
cy.get('[data-testid="registry-location-input"]').type(
|
||||
'quay.io/library/hello-world',
|
||||
);
|
||||
cy.get('[data-testid="submit-button"]').should('be.disabled');
|
||||
|
||||
cy.get('[data-testid="tags-input"]').type('latest, stable');
|
||||
cy.get('[data-testid="submit-button"]').should('be.disabled');
|
||||
|
||||
cy.get('[data-testid="sync-interval-input"]').type('60');
|
||||
cy.get('[data-testid="submit-button"]').should('be.disabled');
|
||||
|
||||
// Select robot user
|
||||
cy.get('#robot-user-select').click();
|
||||
cy.contains('testorg+testrobot').click();
|
||||
|
||||
// Now submit button should be enabled
|
||||
cy.get('[data-testid="submit-button"]').should('not.be.disabled');
|
||||
});
|
||||
|
||||
it('should successfully create mirror configuration', () => {
|
||||
cy.intercept('POST', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
is_enabled: true,
|
||||
external_reference: 'quay.io/library/hello-world',
|
||||
sync_status: 'NEVER_RUN',
|
||||
robot_username: 'testorg+testrobot',
|
||||
},
|
||||
}).as('createMirrorConfig');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
// Fill in the form
|
||||
cy.get('[data-testid="registry-location-input"]').type(
|
||||
'quay.io/library/hello-world',
|
||||
);
|
||||
cy.get('[data-testid="tags-input"]').type('latest, stable');
|
||||
cy.get('[data-testid="sync-interval-input"]').type('60');
|
||||
|
||||
// Select robot user
|
||||
cy.get('#robot-user-select').click();
|
||||
cy.contains('testorg+testrobot').click();
|
||||
|
||||
// Submit form
|
||||
cy.get('[data-testid="submit-button"]').click();
|
||||
|
||||
cy.wait('@createMirrorConfig');
|
||||
cy.contains('Mirror configuration saved successfully').should('exist');
|
||||
});
|
||||
|
||||
it('should handle form submission errors', () => {
|
||||
cy.intercept('POST', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 400,
|
||||
body: {message: 'Invalid external reference'},
|
||||
}).as('createMirrorConfigError');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
// Fill in the form with invalid data
|
||||
cy.get('[data-testid="registry-location-input"]').type(
|
||||
'invalid-registry',
|
||||
);
|
||||
cy.get('[data-testid="tags-input"]').type('latest');
|
||||
cy.get('[data-testid="sync-interval-input"]').type('60');
|
||||
|
||||
// Select robot user
|
||||
cy.get('#robot-user-select').click();
|
||||
cy.contains('testorg+testrobot').click();
|
||||
|
||||
// Submit form
|
||||
cy.get('[data-testid="submit-button"]').click();
|
||||
|
||||
cy.wait('@createMirrorConfigError');
|
||||
cy.contains('Error saving mirror configuration').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Existing Mirror Configuration', () => {
|
||||
beforeEach(() => {
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
// Mock existing mirror config
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
is_enabled: true,
|
||||
external_reference: 'quay.io/library/hello-world',
|
||||
external_registry_username: 'testuser',
|
||||
robot_username: 'user1+testrobot',
|
||||
sync_start_date: '2024-01-01T12:00:00Z',
|
||||
sync_interval: 3600,
|
||||
sync_status: 'SYNC_SUCCESS',
|
||||
last_sync: '2024-01-01T12:00:00Z',
|
||||
sync_expiration_date: null,
|
||||
sync_retries_remaining: 3,
|
||||
skopeo_timeout_interval: 300,
|
||||
external_registry_config: {
|
||||
verify_tls: true,
|
||||
unsigned_images: false,
|
||||
proxy: {
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
},
|
||||
},
|
||||
root_rule: {
|
||||
rule_kind: 'tag_glob_csv',
|
||||
rule_value: ['latest', 'stable'],
|
||||
},
|
||||
},
|
||||
}).as('getMirrorConfig');
|
||||
});
|
||||
|
||||
it('should load and display existing mirror configuration', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig');
|
||||
|
||||
// Check form is populated with existing data
|
||||
cy.get('[data-testid="mirror-enabled-checkbox"]').should('be.checked');
|
||||
cy.get('[data-testid="registry-location-input"]').should(
|
||||
'have.value',
|
||||
'quay.io/library/hello-world',
|
||||
);
|
||||
cy.get('[data-testid="tags-input"]').should(
|
||||
'have.value',
|
||||
'latest, stable',
|
||||
);
|
||||
cy.get('[data-testid="username-input"]').should('have.value', 'testuser');
|
||||
cy.get('[data-testid="sync-interval-input"]').should('have.value', '1');
|
||||
|
||||
// Check button says "Update Mirror"
|
||||
cy.get('[data-testid="submit-button"]').should(
|
||||
'contain.text',
|
||||
'Update Mirror',
|
||||
);
|
||||
|
||||
// Check configuration section title
|
||||
cy.contains('Configuration').should('exist');
|
||||
});
|
||||
|
||||
it('should display status section for existing mirrors', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig');
|
||||
|
||||
// Check status section exists
|
||||
cy.contains('Status').should('exist');
|
||||
cy.contains('State').should('exist');
|
||||
cy.contains('Success').should('exist');
|
||||
cy.contains('Timeout').should('exist');
|
||||
cy.contains('Retries Remaining').should('exist');
|
||||
cy.contains('3 / 3').should('exist');
|
||||
});
|
||||
|
||||
it('should enable/disable mirror configuration', () => {
|
||||
cy.intercept('PUT', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
is_enabled: false,
|
||||
external_reference: 'quay.io/library/hello-world',
|
||||
sync_status: 'NEVER_RUN',
|
||||
},
|
||||
}).as('updateMirrorConfig');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig');
|
||||
|
||||
// Disable mirror
|
||||
cy.get('[data-testid="mirror-enabled-checkbox"]').uncheck();
|
||||
cy.contains('Scheduled mirroring disabled').should('exist');
|
||||
|
||||
// Re-enable mirror
|
||||
cy.get('[data-testid="mirror-enabled-checkbox"]').check();
|
||||
cy.contains('Scheduled mirroring enabled').should('exist');
|
||||
});
|
||||
|
||||
it('should update existing mirror configuration', () => {
|
||||
cy.intercept('PUT', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
is_enabled: true,
|
||||
external_reference: 'quay.io/library/nginx',
|
||||
sync_status: 'NEVER_RUN',
|
||||
},
|
||||
}).as('updateMirrorConfig');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig');
|
||||
|
||||
// Update registry location
|
||||
cy.get('[data-testid="registry-location-input"]')
|
||||
.clear()
|
||||
.type('quay.io/library/nginx');
|
||||
|
||||
// Submit form
|
||||
cy.get('[data-testid="submit-button"]').click();
|
||||
|
||||
cy.wait('@updateMirrorConfig');
|
||||
cy.contains('Mirror configuration saved successfully').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync Operations', () => {
|
||||
beforeEach(() => {
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
});
|
||||
|
||||
it('should trigger sync now operation', () => {
|
||||
// Mock mirror config with sync capability
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
is_enabled: true,
|
||||
external_reference: 'quay.io/library/hello-world',
|
||||
sync_status: 'NEVER_RUN',
|
||||
robot_username: 'testorg+testrobot',
|
||||
sync_start_date: '2024-01-01T12:00:00Z',
|
||||
sync_interval: 3600,
|
||||
skopeo_timeout_interval: 300,
|
||||
external_registry_config: {
|
||||
verify_tls: true,
|
||||
unsigned_images: false,
|
||||
proxy: {http_proxy: null, https_proxy: null, no_proxy: null},
|
||||
},
|
||||
root_rule: {
|
||||
rule_kind: 'tag_glob_csv',
|
||||
rule_value: ['latest'],
|
||||
},
|
||||
},
|
||||
}).as('getMirrorConfig');
|
||||
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/user1/hello-world/mirror/sync-now',
|
||||
{
|
||||
statusCode: 204,
|
||||
},
|
||||
).as('syncNow');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig');
|
||||
|
||||
// Click sync now button
|
||||
cy.get('[data-testid="sync-now-button"]').click();
|
||||
|
||||
cy.wait('@syncNow');
|
||||
cy.contains('Sync scheduled successfully').should('exist');
|
||||
});
|
||||
|
||||
it('should cancel ongoing sync operation', () => {
|
||||
// Mock mirror config with ongoing sync
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
is_enabled: true,
|
||||
external_reference: 'quay.io/library/hello-world',
|
||||
sync_status: 'SYNCING',
|
||||
robot_username: 'testorg+testrobot',
|
||||
sync_start_date: '2024-01-01T12:00:00Z',
|
||||
sync_interval: 3600,
|
||||
skopeo_timeout_interval: 300,
|
||||
external_registry_config: {
|
||||
verify_tls: true,
|
||||
unsigned_images: false,
|
||||
proxy: {http_proxy: null, https_proxy: null, no_proxy: null},
|
||||
},
|
||||
root_rule: {
|
||||
rule_kind: 'tag_glob_csv',
|
||||
rule_value: ['latest'],
|
||||
},
|
||||
},
|
||||
}).as('getMirrorConfig');
|
||||
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/user1/hello-world/mirror/sync-cancel',
|
||||
{
|
||||
statusCode: 204,
|
||||
},
|
||||
).as('cancelSync');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfig');
|
||||
|
||||
// Check sync status shows syncing
|
||||
cy.contains('Syncing').should('exist');
|
||||
|
||||
// Click cancel button
|
||||
cy.get('[data-testid="cancel-sync-button"]').click();
|
||||
|
||||
cy.wait('@cancelSync');
|
||||
cy.contains('Sync cancelled successfully').should('exist');
|
||||
});
|
||||
|
||||
it('should disable sync/cancel buttons appropriately', () => {
|
||||
// Mock mirror config with different states
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
is_enabled: true,
|
||||
external_reference: 'quay.io/library/hello-world',
|
||||
sync_status: 'SYNCING',
|
||||
robot_username: 'user1+testrobot',
|
||||
sync_start_date: '2024-01-01T12:00:00Z',
|
||||
sync_interval: 3600,
|
||||
skopeo_timeout_interval: 300,
|
||||
external_registry_config: {
|
||||
verify_tls: true,
|
||||
unsigned_images: false,
|
||||
proxy: {http_proxy: null, https_proxy: null, no_proxy: null},
|
||||
},
|
||||
root_rule: {
|
||||
rule_kind: 'tag_glob_csv',
|
||||
rule_value: ['latest'],
|
||||
},
|
||||
},
|
||||
}).as('getMirrorConfigSyncing');
|
||||
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getMirrorConfigSyncing');
|
||||
|
||||
// When syncing: Sync Now should be disabled, Cancel should be enabled
|
||||
cy.get('[data-testid="sync-now-button"]').should('be.disabled');
|
||||
cy.get('[data-testid="cancel-sync-button"]').should('not.be.disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Robot User Selection', () => {
|
||||
beforeEach(() => {
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
// Mock no existing mirror config
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror*', {
|
||||
statusCode: 404,
|
||||
}).as('getMirrorConfig404');
|
||||
|
||||
// Mock robot accounts
|
||||
cy.intercept('GET', '/api/v1/organization/user1/robots*', {
|
||||
fixture: 'robots.json',
|
||||
}).as('getRobots');
|
||||
});
|
||||
|
||||
it('should display robot user dropdown with options', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getRobots');
|
||||
|
||||
// Click robot user dropdown
|
||||
cy.get('#robot-user-select').click();
|
||||
|
||||
// Check create options are at the top
|
||||
cy.contains('Create team').should('exist');
|
||||
cy.contains('Create robot account').should('exist');
|
||||
|
||||
// Check robots are listed (teams are not shown for user namespaces)
|
||||
cy.contains('Robot accounts').should('exist');
|
||||
cy.contains('testorg+testrobot').should('exist');
|
||||
});
|
||||
|
||||
it('should select robot user from dropdown', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
cy.wait('@getRobots');
|
||||
|
||||
// Select robot user
|
||||
cy.get('#robot-user-select').click();
|
||||
cy.contains('testorg+testrobot').click();
|
||||
|
||||
// Check selection is reflected
|
||||
cy.get('#robot-user-select input').should(
|
||||
'have.value',
|
||||
'testorg+testrobot',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Advanced Settings', () => {
|
||||
beforeEach(() => {
|
||||
// Mock repository with MIRROR state
|
||||
cy.intercept('GET', '**/api/v1/repository/user1/hello-world*', {
|
||||
body: {
|
||||
name: 'hello-world',
|
||||
namespace: 'user1',
|
||||
state: 'MIRROR',
|
||||
kind: 'image',
|
||||
description: '',
|
||||
is_public: true,
|
||||
is_organization: false,
|
||||
is_starred: false,
|
||||
can_write: true,
|
||||
can_admin: true,
|
||||
},
|
||||
}).as('getRepo');
|
||||
|
||||
// Mock no existing mirror config
|
||||
cy.intercept('GET', '/api/v1/repository/user1/hello-world/mirror', {
|
||||
statusCode: 404,
|
||||
}).as('getMirrorConfig404');
|
||||
});
|
||||
|
||||
it('should configure TLS verification', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
// Check TLS verification checkbox
|
||||
cy.get('[data-testid="verify-tls-checkbox"]').should('not.be.checked');
|
||||
cy.get('[data-testid="verify-tls-checkbox"]').check();
|
||||
cy.get('[data-testid="verify-tls-checkbox"]').should('be.checked');
|
||||
});
|
||||
|
||||
it('should configure unsigned images', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
// Check unsigned images checkbox
|
||||
cy.get('[data-testid="unsigned-images-checkbox"]').should(
|
||||
'not.be.checked',
|
||||
);
|
||||
cy.get('[data-testid="unsigned-images-checkbox"]').check();
|
||||
cy.get('[data-testid="unsigned-images-checkbox"]').should('be.checked');
|
||||
});
|
||||
|
||||
it('should configure proxy settings', () => {
|
||||
cy.visit('/repository/user1/hello-world?tab=mirroring');
|
||||
cy.wait('@getRepo');
|
||||
|
||||
// Fill in proxy settings
|
||||
cy.get('[data-testid="http-proxy-input"]').type(
|
||||
'http://proxy.example.com:8080',
|
||||
);
|
||||
cy.get('[data-testid="https-proxy-input"]').type(
|
||||
'https://proxy.example.com:8080',
|
||||
);
|
||||
cy.get('[data-testid="no-proxy-input"]').type('localhost,127.0.0.1');
|
||||
|
||||
// Check values are set
|
||||
cy.get('[data-testid="http-proxy-input"]').should(
|
||||
'have.value',
|
||||
'http://proxy.example.com:8080',
|
||||
);
|
||||
cy.get('[data-testid="https-proxy-input"]').should(
|
||||
'have.value',
|
||||
'https://proxy.example.com:8080',
|
||||
);
|
||||
cy.get('[data-testid="no-proxy-input"]').should(
|
||||
'have.value',
|
||||
'localhost,127.0.0.1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
22
web/package-lock.json
generated
22
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
59
web/src/components/StatusDisplay.tsx
Normal file
59
web/src/components/StatusDisplay.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
DescriptionList,
|
||||
DescriptionListGroup,
|
||||
DescriptionListTerm,
|
||||
DescriptionListDescription,
|
||||
Flex,
|
||||
FlexItem,
|
||||
Title,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
interface StatusItem {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StatusDisplayProps {
|
||||
title?: string;
|
||||
items: StatusItem[];
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export function StatusDisplay({
|
||||
title,
|
||||
items,
|
||||
'data-testid': dataTestId,
|
||||
}: StatusDisplayProps) {
|
||||
return (
|
||||
<>
|
||||
{title && <Title headingLevel="h3">{title}</Title>}
|
||||
<Card isFlat data-testid={dataTestId}>
|
||||
<CardBody>
|
||||
<DescriptionList isHorizontal>
|
||||
{items.map((item, index) => (
|
||||
<DescriptionListGroup key={index}>
|
||||
<DescriptionListTerm>{item.label}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{item.action ? (
|
||||
<Flex>
|
||||
<FlexItem flex={{default: 'flex_1'}}>
|
||||
{item.value}
|
||||
</FlexItem>
|
||||
<FlexItem>{item.action}</FlexItem>
|
||||
</Flex>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
))}
|
||||
</DescriptionList>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
web/src/components/forms/FormCheckbox.tsx
Normal file
53
web/src/components/forms/FormCheckbox.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import {Controller, Control, FieldValues, Path} from 'react-hook-form';
|
||||
import {FormGroup, Checkbox} from '@patternfly/react-core';
|
||||
|
||||
interface FormCheckboxProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
control: Control<T>;
|
||||
label: string;
|
||||
fieldId?: string;
|
||||
description?: string;
|
||||
isStack?: boolean;
|
||||
'data-testid'?: string;
|
||||
customOnChange?: (
|
||||
checked: boolean,
|
||||
onChange: (value: boolean) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function FormCheckbox<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
label,
|
||||
fieldId,
|
||||
description,
|
||||
isStack = true,
|
||||
'data-testid': dataTestId,
|
||||
customOnChange,
|
||||
}: FormCheckboxProps<T>) {
|
||||
return (
|
||||
<FormGroup fieldId={fieldId || name} isStack={isStack}>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({field: {value, onChange}}) => (
|
||||
<Checkbox
|
||||
label={label}
|
||||
id={fieldId || name}
|
||||
description={description}
|
||||
isChecked={value}
|
||||
onChange={(_event, checked) => {
|
||||
if (customOnChange) {
|
||||
customOnChange(checked, onChange);
|
||||
} else {
|
||||
onChange(checked);
|
||||
}
|
||||
}}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
132
web/src/components/forms/FormTextInput.tsx
Normal file
132
web/src/components/forms/FormTextInput.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
Control,
|
||||
FieldErrors,
|
||||
FieldValues,
|
||||
Path,
|
||||
} from 'react-hook-form';
|
||||
import {
|
||||
FormGroup,
|
||||
FormHelperText,
|
||||
TextInput,
|
||||
Text,
|
||||
ValidatedOptions,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
interface FormTextInputProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
control: Control<T>;
|
||||
errors: FieldErrors<T>;
|
||||
label: string;
|
||||
fieldId?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'password' | 'email' | 'datetime-local';
|
||||
required?: boolean;
|
||||
customValidation?: (value: string) => string | boolean;
|
||||
helperText?: string;
|
||||
isStack?: boolean;
|
||||
'data-testid'?: string;
|
||||
pattern?: string;
|
||||
inputMode?:
|
||||
| 'none'
|
||||
| 'text'
|
||||
| 'tel'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'numeric'
|
||||
| 'decimal'
|
||||
| 'search';
|
||||
'aria-label'?: string;
|
||||
showNoneWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function FormTextInput<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
errors,
|
||||
label,
|
||||
fieldId,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
required = false,
|
||||
customValidation,
|
||||
helperText,
|
||||
isStack = true,
|
||||
'data-testid': dataTestId,
|
||||
pattern,
|
||||
inputMode,
|
||||
'aria-label': ariaLabel,
|
||||
showNoneWhenEmpty = false,
|
||||
}: FormTextInputProps<T>) {
|
||||
const rules = {
|
||||
...(required && {
|
||||
required: 'This field is required',
|
||||
validate: (value: string) =>
|
||||
value?.trim() !== '' || 'This field is required',
|
||||
}),
|
||||
...(customValidation && {
|
||||
validate: customValidation,
|
||||
}),
|
||||
};
|
||||
|
||||
const fieldError = errors[name];
|
||||
const validationState = fieldError
|
||||
? ValidatedOptions.error
|
||||
: ValidatedOptions.default;
|
||||
|
||||
return (
|
||||
<FormGroup label={label} fieldId={fieldId || name} isStack={isStack}>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({field: {value, onChange}}) => {
|
||||
const displayValue =
|
||||
showNoneWhenEmpty && (!value || value === '')
|
||||
? 'None'
|
||||
: value || '';
|
||||
const handleChange = (
|
||||
_event: React.FormEvent<HTMLInputElement>,
|
||||
newValue: string,
|
||||
) => {
|
||||
if (showNoneWhenEmpty && newValue === 'None') {
|
||||
onChange('');
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
type={type}
|
||||
id={fieldId || name}
|
||||
placeholder={placeholder}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
validated={validationState}
|
||||
data-testid={dataTestId}
|
||||
pattern={pattern}
|
||||
inputMode={inputMode}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
{fieldError && (
|
||||
<FormHelperText>
|
||||
<Text component="p" className="pf-m-error">
|
||||
{fieldError.message as string}
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
)}
|
||||
{helperText && !fieldError && (
|
||||
<FormHelperText>
|
||||
<Text component="p">{helperText}</Text>
|
||||
</FormHelperText>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
145
web/src/hooks/UseMirroringConfig.ts
Normal file
145
web/src/hooks/UseMirroringConfig.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {useState, useEffect} from 'react';
|
||||
import {
|
||||
MirroringConfigResponse,
|
||||
getMirrorConfig,
|
||||
createMirrorConfig,
|
||||
updateMirrorConfig,
|
||||
} from 'src/resources/MirroringResource';
|
||||
import {MirroringFormData} from 'src/routes/RepositoryDetails/Mirroring/types';
|
||||
import {
|
||||
convertToSeconds,
|
||||
convertFromSeconds,
|
||||
formatDateForInput,
|
||||
} from 'src/libs/utils';
|
||||
import {
|
||||
timestampToISO,
|
||||
timestampFromISO,
|
||||
} from 'src/resources/MirroringResource';
|
||||
import {Entity, EntityKind} from 'src/resources/UserResource';
|
||||
|
||||
export const useMirroringConfig = (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
repoState: string | undefined,
|
||||
reset: (data: MirroringFormData) => void,
|
||||
setSelectedRobot: (robot: Entity | null) => void,
|
||||
) => {
|
||||
const [config, setConfig] = useState<MirroringConfigResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load existing configuration
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getMirrorConfig(namespace, repoName);
|
||||
setConfig(response);
|
||||
|
||||
// Populate form with existing values
|
||||
const {value, unit} = convertFromSeconds(response.sync_interval);
|
||||
|
||||
reset({
|
||||
isEnabled: response.is_enabled,
|
||||
externalReference: response.external_reference || '',
|
||||
tags: response.root_rule.rule_value.join(', '),
|
||||
syncStartDate: formatDateForInput(response.sync_start_date || ''),
|
||||
syncValue: value.toString(),
|
||||
syncUnit: unit,
|
||||
robotUsername: response.robot_username || '',
|
||||
username: response.external_registry_username || '',
|
||||
password: '', // Don't populate password for security
|
||||
verifyTls: response.external_registry_config?.verify_tls ?? true,
|
||||
httpProxy: response.external_registry_config?.proxy?.http_proxy || '',
|
||||
httpsProxy:
|
||||
response.external_registry_config?.proxy?.https_proxy || '',
|
||||
noProxy: response.external_registry_config?.proxy?.no_proxy || '',
|
||||
unsignedImages:
|
||||
response.external_registry_config?.unsigned_images ?? false,
|
||||
skopeoTimeoutInterval: response.skopeo_timeout_interval || 300,
|
||||
});
|
||||
|
||||
// Set selected robot if there's one configured
|
||||
if (response.robot_username) {
|
||||
const robotEntity: Entity = {
|
||||
name: response.robot_username,
|
||||
is_robot: response.robot_username.includes('+'),
|
||||
kind: response.robot_username.includes('+')
|
||||
? EntityKind.user
|
||||
: EntityKind.team,
|
||||
is_org_member: true,
|
||||
};
|
||||
setSelectedRobot(robotEntity);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
(error as {response?: {status?: number}}).response?.status === 404
|
||||
) {
|
||||
setConfig(null);
|
||||
} else {
|
||||
setError(
|
||||
(error as Error).message || 'Failed to load mirror configuration',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (repoState === 'MIRROR') {
|
||||
fetchConfig();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [namespace, repoName, repoState, reset, setSelectedRobot]);
|
||||
|
||||
// Submit configuration
|
||||
const submitConfig = async (data: MirroringFormData) => {
|
||||
// Split and clean up tags to match backend expectation
|
||||
const tagPatterns = data.tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
const mirrorConfig = {
|
||||
is_enabled: data.isEnabled,
|
||||
external_reference: data.externalReference,
|
||||
external_registry_username: data.username || null,
|
||||
external_registry_password: data.password || null,
|
||||
sync_start_date: data.syncStartDate
|
||||
? timestampToISO(timestampFromISO(data.syncStartDate))
|
||||
: timestampToISO(Math.floor(Date.now() / 1000)),
|
||||
sync_interval: convertToSeconds(Number(data.syncValue), data.syncUnit),
|
||||
robot_username: data.robotUsername,
|
||||
skopeo_timeout_interval: data.skopeoTimeoutInterval,
|
||||
external_registry_config: {
|
||||
verify_tls: data.verifyTls,
|
||||
unsigned_images: data.unsignedImages,
|
||||
proxy: {
|
||||
http_proxy: data.httpProxy || null,
|
||||
https_proxy: data.httpsProxy || null,
|
||||
no_proxy: data.noProxy || null,
|
||||
},
|
||||
},
|
||||
root_rule: {
|
||||
rule_kind: 'tag_glob_csv',
|
||||
rule_value: tagPatterns,
|
||||
},
|
||||
};
|
||||
|
||||
if (config) {
|
||||
await updateMirrorConfig(namespace, repoName, mirrorConfig);
|
||||
} else {
|
||||
await createMirrorConfig(namespace, repoName, mirrorConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
isLoading,
|
||||
error,
|
||||
setError,
|
||||
submitConfig,
|
||||
};
|
||||
};
|
||||
143
web/src/hooks/UseMirroringForm.ts
Normal file
143
web/src/hooks/UseMirroringForm.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {MirroringFormData} from 'src/routes/RepositoryDetails/Mirroring/types';
|
||||
import {Entity, EntityKind} from 'src/resources/UserResource';
|
||||
import {AlertVariant} from 'src/atoms/AlertState';
|
||||
|
||||
// Default form values
|
||||
const defaultFormValues: MirroringFormData = {
|
||||
isEnabled: true,
|
||||
externalReference: '',
|
||||
tags: '',
|
||||
syncStartDate: '',
|
||||
syncValue: '24',
|
||||
syncUnit: 'hours',
|
||||
robotUsername: '',
|
||||
username: '',
|
||||
password: '',
|
||||
verifyTls: false,
|
||||
httpProxy: '',
|
||||
httpsProxy: '',
|
||||
noProxy: '',
|
||||
unsignedImages: false,
|
||||
skopeoTimeoutInterval: 300,
|
||||
};
|
||||
|
||||
export const useMirroringForm = (
|
||||
submitConfig: (data: MirroringFormData) => Promise<void>,
|
||||
addAlert: (alert: {
|
||||
variant: AlertVariant;
|
||||
title: string;
|
||||
message?: string;
|
||||
}) => void,
|
||||
setError: (error: string | null) => void,
|
||||
) => {
|
||||
// Initialize react-hook-form
|
||||
const form = useForm<MirroringFormData>({
|
||||
defaultValues: defaultFormValues,
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: {errors, isValid, isDirty},
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
getValues,
|
||||
trigger,
|
||||
} = form;
|
||||
|
||||
// Watch all form values to maintain existing functionality
|
||||
const formValues = watch();
|
||||
|
||||
// Non-form UI state
|
||||
const [selectedRobot, setSelectedRobot] = useState<Entity | null>(null);
|
||||
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isCreateRobotModalOpen, setIsCreateRobotModalOpen] = useState(false);
|
||||
const [isCreateTeamModalOpen, setIsCreateTeamModalOpen] = useState(false);
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [teamDescription, setTeamDescription] = useState('');
|
||||
|
||||
// Form submission
|
||||
const onSubmit = async (data: MirroringFormData) => {
|
||||
try {
|
||||
await submitConfig(data);
|
||||
|
||||
// Reset form with current values to mark it as clean
|
||||
reset(data);
|
||||
|
||||
addAlert({
|
||||
variant: AlertVariant.Success,
|
||||
title: 'Mirror configuration saved successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: 'Error saving mirror configuration',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRobotSelect = (name: string) => {
|
||||
const robotEntity: Entity = {
|
||||
name,
|
||||
is_robot: true,
|
||||
kind: EntityKind.user,
|
||||
is_org_member: true,
|
||||
};
|
||||
setSelectedRobot(robotEntity);
|
||||
setValue('robotUsername', name);
|
||||
};
|
||||
|
||||
const handleTeamSelect = (name: string) => {
|
||||
const teamEntity: Entity = {
|
||||
name,
|
||||
is_robot: false,
|
||||
kind: EntityKind.team,
|
||||
is_org_member: true,
|
||||
};
|
||||
setSelectedRobot(teamEntity);
|
||||
setValue('robotUsername', name);
|
||||
};
|
||||
|
||||
return {
|
||||
// Form methods
|
||||
control,
|
||||
handleSubmit,
|
||||
errors,
|
||||
isValid,
|
||||
isDirty,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
getValues,
|
||||
trigger,
|
||||
formValues,
|
||||
onSubmit,
|
||||
|
||||
// UI state
|
||||
selectedRobot,
|
||||
setSelectedRobot,
|
||||
isSelectOpen,
|
||||
setIsSelectOpen,
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
isCreateRobotModalOpen,
|
||||
setIsCreateRobotModalOpen,
|
||||
isCreateTeamModalOpen,
|
||||
setIsCreateTeamModalOpen,
|
||||
teamName,
|
||||
setTeamName,
|
||||
teamDescription,
|
||||
setTeamDescription,
|
||||
|
||||
// Helper methods
|
||||
handleRobotSelect,
|
||||
handleTeamSelect,
|
||||
};
|
||||
};
|
||||
@@ -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 '';
|
||||
}
|
||||
};
|
||||
|
||||
134
web/src/resources/MirroringResource.ts
Normal file
134
web/src/resources/MirroringResource.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {AxiosResponse} from 'axios';
|
||||
import axios from 'src/libs/axios';
|
||||
import {assertHttpCode} from './ErrorHandling';
|
||||
|
||||
// Mirroring configuration types
|
||||
export interface MirroringConfig {
|
||||
is_enabled: boolean;
|
||||
external_reference: string;
|
||||
external_registry_username?: string | null;
|
||||
external_registry_password?: string | null;
|
||||
robot_username: string;
|
||||
external_registry_config: {
|
||||
verify_tls: boolean;
|
||||
unsigned_images: boolean;
|
||||
proxy: {
|
||||
http_proxy: string | null;
|
||||
https_proxy: string | null;
|
||||
no_proxy: string | null;
|
||||
};
|
||||
};
|
||||
sync_start_date: string;
|
||||
sync_interval: number;
|
||||
root_rule: {
|
||||
rule_kind: string;
|
||||
rule_value: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MirroringConfigResponse extends MirroringConfig {
|
||||
sync_status:
|
||||
| 'NEVER_RUN'
|
||||
| 'SYNC_NOW'
|
||||
| 'SYNC_FAILED'
|
||||
| 'SYNCING'
|
||||
| 'SYNC_SUCCESS';
|
||||
last_sync: string;
|
||||
last_error: string;
|
||||
status_message: string;
|
||||
mirror_type: string;
|
||||
external_reference: string;
|
||||
external_registry_username: string | null;
|
||||
sync_expiration_date: string | null;
|
||||
sync_retries_remaining: number | null;
|
||||
robot_username: string;
|
||||
skopeo_timeout_interval: number;
|
||||
}
|
||||
|
||||
// Date conversion utilities
|
||||
export const timestampToISO = (ts: number): string => {
|
||||
const dt = new Date(ts * 1000).toISOString();
|
||||
return dt.split('.')[0] + 'Z'; // Remove milliseconds
|
||||
};
|
||||
|
||||
export const timestampFromISO = (dt: string): number => {
|
||||
return Math.floor(new Date(dt).getTime() / 1000);
|
||||
};
|
||||
|
||||
// API functions
|
||||
export const getMirrorConfig = async (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
): Promise<MirroringConfigResponse> => {
|
||||
const response: AxiosResponse<MirroringConfigResponse> = await axios.get(
|
||||
`/api/v1/repository/${namespace}/${repoName}/mirror`,
|
||||
);
|
||||
assertHttpCode(response.status, 200);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createMirrorConfig = async (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
config: MirroringConfig,
|
||||
): Promise<MirroringConfigResponse> => {
|
||||
const response: AxiosResponse<MirroringConfigResponse> = await axios.post(
|
||||
`/api/v1/repository/${namespace}/${repoName}/mirror`,
|
||||
config,
|
||||
);
|
||||
assertHttpCode(response.status, 201);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateMirrorConfig = async (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
config: Partial<MirroringConfig>,
|
||||
): Promise<MirroringConfigResponse> => {
|
||||
const response: AxiosResponse<MirroringConfigResponse> = await axios.put(
|
||||
`/api/v1/repository/${namespace}/${repoName}/mirror`,
|
||||
config,
|
||||
);
|
||||
assertHttpCode(response.status, 201);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const toggleMirroring = async (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
isEnabled: boolean,
|
||||
): Promise<MirroringConfigResponse> => {
|
||||
return updateMirrorConfig(namespace, repoName, {is_enabled: isEnabled});
|
||||
};
|
||||
|
||||
export const syncMirror = async (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.post(
|
||||
`/api/v1/repository/${namespace}/${repoName}/mirror/sync-now`,
|
||||
);
|
||||
assertHttpCode(response.status, 204);
|
||||
};
|
||||
|
||||
export const cancelSync = async (
|
||||
namespace: string,
|
||||
repoName: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.post(
|
||||
`/api/v1/repository/${namespace}/${repoName}/mirror/sync-cancel`,
|
||||
);
|
||||
assertHttpCode(response.status, 204);
|
||||
};
|
||||
|
||||
// Status message mapping
|
||||
export const statusLabels: Record<
|
||||
MirroringConfigResponse['sync_status'],
|
||||
string
|
||||
> = {
|
||||
NEVER_RUN: 'Scheduled',
|
||||
SYNC_NOW: 'Scheduled Now',
|
||||
SYNC_FAILED: 'Failed',
|
||||
SYNCING: 'Syncing',
|
||||
SYNC_SUCCESS: 'Success',
|
||||
};
|
||||
14
web/src/routes/RepositoryDetails/Mirroring/Mirroring.css
Normal file
14
web/src/routes/RepositoryDetails/Mirroring/Mirroring.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.pf-v5-c-title.pf-m-2xl {
|
||||
text-align: left;
|
||||
margin-top: var(--pf-v5-global--spacer--md);
|
||||
}
|
||||
|
||||
.pf-v5-c-title.pf-m-xl {
|
||||
text-align: left;
|
||||
margin-top: var(--pf-v5-global--spacer--sm);
|
||||
margin-bottom: var(--pf-v5-global--spacer--xl);
|
||||
}
|
||||
|
||||
.pf-v5-c-title.pf-m-lg {
|
||||
text-align: center;
|
||||
}
|
||||
254
web/src/routes/RepositoryDetails/Mirroring/Mirroring.tsx
Normal file
254
web/src/routes/RepositoryDetails/Mirroring/Mirroring.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import {MirroringHeader} from './MirroringHeader';
|
||||
import {MirroringConfiguration} from './MirroringConfiguration';
|
||||
import {MirroringCredentials} from './MirroringCredentials';
|
||||
import {MirroringAdvancedSettings} from './MirroringAdvancedSettings';
|
||||
import {MirroringStatus} from './MirroringStatus';
|
||||
import {MirroringModals} from './MirroringModals';
|
||||
import {useMirroringConfig} from 'src/hooks/UseMirroringConfig';
|
||||
import {useMirroringForm} from 'src/hooks/UseMirroringForm';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
ActionGroup,
|
||||
Divider,
|
||||
Text,
|
||||
TextContent,
|
||||
SelectOption,
|
||||
SelectGroup,
|
||||
Spinner,
|
||||
} from '@patternfly/react-core';
|
||||
import {DesktopIcon, UsersIcon} from '@patternfly/react-icons';
|
||||
import {useRepository} from 'src/hooks/UseRepository';
|
||||
import {useAlerts} from 'src/hooks/UseAlerts';
|
||||
import FormError from 'src/components/errors/FormError';
|
||||
import {useFetchRobotAccounts} from 'src/hooks/useRobotAccounts';
|
||||
import {useFetchTeams} from 'src/hooks/UseTeams';
|
||||
import {Entity} from 'src/resources/UserResource';
|
||||
import {useQueryClient} from '@tanstack/react-query';
|
||||
import './Mirroring.css';
|
||||
|
||||
interface MirroringProps {
|
||||
namespace: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
export const Mirroring: React.FC<MirroringProps> = ({namespace, repoName}) => {
|
||||
const {
|
||||
repoDetails,
|
||||
errorLoadingRepoDetails,
|
||||
isLoading: isLoadingRepo,
|
||||
} = useRepository(namespace, repoName);
|
||||
const {addAlert} = useAlerts();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Initialize form hook
|
||||
const formHook = useMirroringForm(
|
||||
async (data) => {
|
||||
await configHook.submitConfig(data);
|
||||
},
|
||||
addAlert,
|
||||
(error) => configHook.setError(error),
|
||||
);
|
||||
|
||||
// Initialize config hook
|
||||
const configHook = useMirroringConfig(
|
||||
namespace,
|
||||
repoName,
|
||||
repoDetails?.state,
|
||||
formHook.reset,
|
||||
formHook.setSelectedRobot,
|
||||
);
|
||||
|
||||
// Fetch robot accounts and teams
|
||||
const {robots} = useFetchRobotAccounts(namespace);
|
||||
const {teams} = useFetchTeams(namespace);
|
||||
|
||||
// Create dropdown options
|
||||
const robotOptions = [
|
||||
<React.Fragment key="dropdown-options">
|
||||
<SelectOption
|
||||
key="create-team"
|
||||
component="button"
|
||||
onClick={() => formHook.setIsCreateTeamModalOpen(true)}
|
||||
>
|
||||
<UsersIcon /> Create team
|
||||
</SelectOption>
|
||||
<SelectOption
|
||||
key="create-robot"
|
||||
component="button"
|
||||
onClick={() => formHook.setIsCreateRobotModalOpen(true)}
|
||||
>
|
||||
<DesktopIcon /> Create robot account
|
||||
</SelectOption>
|
||||
<Divider component="li" key="divider" />
|
||||
<SelectGroup label="Teams" key="teams">
|
||||
{teams?.map(({name}) => (
|
||||
<SelectOption
|
||||
key={name}
|
||||
value={name}
|
||||
onClick={() => formHook.handleTeamSelect(name)}
|
||||
>
|
||||
{name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup label="Robot accounts" key="robot-accounts">
|
||||
{robots?.map(({name}) => (
|
||||
<SelectOption
|
||||
key={name}
|
||||
value={name}
|
||||
onClick={() => formHook.handleRobotSelect(name)}
|
||||
>
|
||||
{name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</React.Fragment>,
|
||||
];
|
||||
|
||||
if (isLoadingRepo) {
|
||||
return <Spinner size="md" />;
|
||||
}
|
||||
|
||||
if (errorLoadingRepoDetails) {
|
||||
return (
|
||||
<FormError
|
||||
message={
|
||||
typeof errorLoadingRepoDetails === 'object' &&
|
||||
errorLoadingRepoDetails !== null &&
|
||||
'message' in errorLoadingRepoDetails
|
||||
? String(errorLoadingRepoDetails.message)
|
||||
: 'Error loading repository'
|
||||
}
|
||||
setErr={configHook.setError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repoDetails) {
|
||||
return <Text>Repository not found</Text>;
|
||||
}
|
||||
|
||||
if (repoDetails.state !== 'MIRROR') {
|
||||
return (
|
||||
<div className="pf-v5-u-max-width-lg pf-v5-u-p-md">
|
||||
<TextContent>
|
||||
<Text>
|
||||
This repository's state is <strong>{repoDetails.state}</strong>
|
||||
. Use the settings tab and change it to <strong>Mirror</strong> to
|
||||
manage its mirroring configuration.
|
||||
</Text>
|
||||
</TextContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (configHook.isLoading) {
|
||||
return <Spinner size="md" />;
|
||||
}
|
||||
|
||||
if (configHook.error) {
|
||||
return (
|
||||
<FormError message={configHook.error} setErr={configHook.setError} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pf-v5-u-max-width-lg pf-v5-u-p-md">
|
||||
<Form
|
||||
isWidthLimited
|
||||
data-testid="mirror-form"
|
||||
onSubmit={formHook.handleSubmit(formHook.onSubmit)}
|
||||
>
|
||||
<MirroringHeader
|
||||
namespace={namespace}
|
||||
repoName={repoName}
|
||||
isConfigured={!!configHook.config}
|
||||
/>
|
||||
<Divider className="pf-v5-u-mt-sm" />
|
||||
<MirroringConfiguration
|
||||
control={formHook.control}
|
||||
errors={formHook.errors}
|
||||
formValues={formHook.formValues}
|
||||
config={configHook.config}
|
||||
namespace={namespace}
|
||||
repoName={repoName}
|
||||
selectedRobot={formHook.selectedRobot}
|
||||
setSelectedRobot={formHook.setSelectedRobot}
|
||||
isSelectOpen={formHook.isSelectOpen}
|
||||
setIsSelectOpen={formHook.setIsSelectOpen}
|
||||
isHovered={formHook.isHovered}
|
||||
setIsHovered={formHook.setIsHovered}
|
||||
robotOptions={robotOptions}
|
||||
setConfig={configHook.setConfig}
|
||||
addAlert={addAlert}
|
||||
/>
|
||||
<MirroringCredentials
|
||||
control={formHook.control}
|
||||
errors={formHook.errors}
|
||||
config={configHook.config}
|
||||
/>
|
||||
<MirroringAdvancedSettings
|
||||
control={formHook.control}
|
||||
errors={formHook.errors}
|
||||
config={configHook.config}
|
||||
/>
|
||||
<MirroringStatus
|
||||
config={configHook.config}
|
||||
namespace={namespace}
|
||||
repoName={repoName}
|
||||
setConfig={configHook.setConfig}
|
||||
addAlert={addAlert}
|
||||
/>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
variant={ButtonVariant.primary}
|
||||
className="pf-v5-u-display-block pf-v5-u-mx-auto"
|
||||
type="button"
|
||||
onClick={() => formHook.onSubmit(formHook.formValues)}
|
||||
isDisabled={
|
||||
(configHook.config && !formHook.isDirty) ||
|
||||
!formHook.formValues.externalReference?.trim() ||
|
||||
!formHook.formValues.tags?.trim() ||
|
||||
(configHook.config &&
|
||||
!formHook.formValues.syncStartDate?.trim()) ||
|
||||
!formHook.formValues.syncValue?.trim() ||
|
||||
!formHook.formValues.robotUsername?.trim() ||
|
||||
!formHook.formValues.skopeoTimeoutInterval ||
|
||||
formHook.formValues.skopeoTimeoutInterval < 300 ||
|
||||
formHook.formValues.skopeoTimeoutInterval > 43200
|
||||
}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{configHook.config ? 'Update Mirror' : 'Enable Mirror'}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
<MirroringModals
|
||||
isCreateRobotModalOpen={formHook.isCreateRobotModalOpen}
|
||||
setIsCreateRobotModalOpen={formHook.setIsCreateRobotModalOpen}
|
||||
isCreateTeamModalOpen={formHook.isCreateTeamModalOpen}
|
||||
setIsCreateTeamModalOpen={formHook.setIsCreateTeamModalOpen}
|
||||
teamName={formHook.teamName}
|
||||
setTeamName={formHook.setTeamName}
|
||||
teamDescription={formHook.teamDescription}
|
||||
setTeamDescription={formHook.setTeamDescription}
|
||||
namespace={namespace}
|
||||
teams={teams}
|
||||
onRobotCreated={(robot: Entity) => {
|
||||
formHook.setSelectedRobot(robot);
|
||||
formHook.setValue('robotUsername', robot.name);
|
||||
// Invalidate robot cache to refresh the list
|
||||
queryClient.invalidateQueries(['robots']);
|
||||
}}
|
||||
onTeamCreated={(team: Entity) => {
|
||||
formHook.setSelectedRobot(team);
|
||||
formHook.setValue('robotUsername', team.name);
|
||||
}}
|
||||
addAlert={addAlert}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import {Control, FieldErrors} from 'react-hook-form';
|
||||
import {Divider, Title} from '@patternfly/react-core';
|
||||
import {FormTextInput} from 'src/components/forms/FormTextInput';
|
||||
import {FormCheckbox} from 'src/components/forms/FormCheckbox';
|
||||
import {MirroringConfigResponse} from 'src/resources/MirroringResource';
|
||||
import {MirroringFormData} from './types';
|
||||
|
||||
interface MirroringAdvancedSettingsProps {
|
||||
control: Control<MirroringFormData>;
|
||||
errors: FieldErrors<MirroringFormData>;
|
||||
config: MirroringConfigResponse | null;
|
||||
}
|
||||
|
||||
export const MirroringAdvancedSettings: React.FC<
|
||||
MirroringAdvancedSettingsProps
|
||||
> = ({control, errors, config}) => {
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Title headingLevel="h3">Advanced Settings</Title>
|
||||
|
||||
<FormCheckbox
|
||||
name="verifyTls"
|
||||
control={control}
|
||||
label="Verify TLS"
|
||||
fieldId="verify_tls"
|
||||
description="Require HTTPS and verify certificates when talking to the external registry."
|
||||
data-testid="verify-tls-checkbox"
|
||||
/>
|
||||
|
||||
<FormCheckbox
|
||||
name="unsignedImages"
|
||||
control={control}
|
||||
label="Accept Unsigned Images"
|
||||
fieldId="unsigned_images"
|
||||
description="Allow unsigned images to be mirrored."
|
||||
data-testid="unsigned-images-checkbox"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
name="httpProxy"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="HTTP Proxy"
|
||||
fieldId="http_proxy"
|
||||
placeholder="proxy.example.com"
|
||||
showNoneWhenEmpty={!!config}
|
||||
data-testid="http-proxy-input"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
name="httpsProxy"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="HTTPs Proxy"
|
||||
fieldId="https_proxy"
|
||||
placeholder="proxy.example.com"
|
||||
showNoneWhenEmpty={!!config}
|
||||
data-testid="https-proxy-input"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
name="noProxy"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="No Proxy"
|
||||
fieldId="no_proxy"
|
||||
placeholder="example.com"
|
||||
showNoneWhenEmpty={!!config}
|
||||
data-testid="no-proxy-input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
import React from 'react';
|
||||
import {Control, FieldErrors, Controller} from 'react-hook-form';
|
||||
import {
|
||||
FormGroup,
|
||||
FormHelperText,
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
Title,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Select,
|
||||
SelectOption,
|
||||
MenuToggle,
|
||||
ValidatedOptions,
|
||||
} from '@patternfly/react-core';
|
||||
import {FormTextInput} from 'src/components/forms/FormTextInput';
|
||||
import {FormCheckbox} from 'src/components/forms/FormCheckbox';
|
||||
import EntitySearch from 'src/components/EntitySearch';
|
||||
import {Entity} from 'src/resources/UserResource';
|
||||
import {AlertVariant} from 'src/atoms/AlertState';
|
||||
import {
|
||||
MirroringConfigResponse,
|
||||
getMirrorConfig,
|
||||
toggleMirroring,
|
||||
syncMirror,
|
||||
} from 'src/resources/MirroringResource';
|
||||
import {MirroringFormData} from './types';
|
||||
|
||||
interface MirroringConfigurationProps {
|
||||
control: Control<MirroringFormData>;
|
||||
errors: FieldErrors<MirroringFormData>;
|
||||
formValues: MirroringFormData;
|
||||
config: MirroringConfigResponse | null;
|
||||
namespace: string;
|
||||
repoName: string;
|
||||
selectedRobot: Entity | null;
|
||||
setSelectedRobot: (robot: Entity | null) => void;
|
||||
isSelectOpen: boolean;
|
||||
setIsSelectOpen: (open: boolean) => void;
|
||||
isHovered: boolean;
|
||||
setIsHovered: (hovered: boolean) => void;
|
||||
robotOptions: React.ReactNode[];
|
||||
setConfig: (config: MirroringConfigResponse) => void;
|
||||
addAlert: (alert: {
|
||||
variant: AlertVariant;
|
||||
title: string;
|
||||
message?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const MirroringConfiguration: React.FC<MirroringConfigurationProps> = ({
|
||||
control,
|
||||
errors,
|
||||
formValues,
|
||||
config,
|
||||
namespace,
|
||||
repoName,
|
||||
selectedRobot,
|
||||
setSelectedRobot,
|
||||
isSelectOpen,
|
||||
setIsSelectOpen,
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
robotOptions,
|
||||
setConfig,
|
||||
addAlert,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Title headingLevel="h3">
|
||||
{config ? 'Configuration' : 'External Repository'}
|
||||
</Title>
|
||||
|
||||
{config && (
|
||||
<FormCheckbox
|
||||
name="isEnabled"
|
||||
control={control}
|
||||
label="Enabled"
|
||||
fieldId="is_enabled"
|
||||
description={
|
||||
formValues.isEnabled
|
||||
? 'Scheduled mirroring enabled. Immediate sync available via Sync Now.'
|
||||
: 'Scheduled mirroring disabled. Immediate sync available via Sync Now.'
|
||||
}
|
||||
data-testid="mirror-enabled-checkbox"
|
||||
customOnChange={async (checked, onChange) => {
|
||||
try {
|
||||
await toggleMirroring(namespace, repoName, checked);
|
||||
onChange(checked);
|
||||
addAlert({
|
||||
variant: AlertVariant.Success,
|
||||
title: `Mirror ${
|
||||
checked ? 'enabled' : 'disabled'
|
||||
} successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: 'Error toggling mirror',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormTextInput
|
||||
name="externalReference"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="Registry Location"
|
||||
fieldId="external_reference"
|
||||
placeholder="quay.io/redhat/quay"
|
||||
required
|
||||
data-testid="registry-location-input"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
name="tags"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="Tags"
|
||||
fieldId="tags"
|
||||
placeholder="Examples: latest, 3.3*, *"
|
||||
required
|
||||
helperText="Comma-separated list of tag patterns to synchronize."
|
||||
data-testid="tags-input"
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
label={config ? 'Next Sync Date' : 'Start Date'}
|
||||
fieldId="sync_start_date"
|
||||
isStack
|
||||
>
|
||||
{config ? (
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
|
||||
<Controller
|
||||
name="syncStartDate"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'This field is required',
|
||||
validate: (value) =>
|
||||
value?.trim() !== '' || 'This field is required',
|
||||
}}
|
||||
render={({field: {value, onChange}}) => (
|
||||
<div style={{flex: 1}}>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
id="sync_start_date"
|
||||
value={value}
|
||||
onChange={(_event, newValue) => onChange(newValue)}
|
||||
validated={
|
||||
errors.syncStartDate
|
||||
? ValidatedOptions.error
|
||||
: ValidatedOptions.default
|
||||
}
|
||||
/>
|
||||
{errors.syncStartDate && (
|
||||
<FormHelperText>
|
||||
<Text component="p" className="pf-m-error pf-v5-u-mt-sm">
|
||||
{errors.syncStartDate.message}
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
isDisabled={
|
||||
config.sync_status === 'SYNCING' ||
|
||||
config.sync_status === 'SYNC_NOW'
|
||||
}
|
||||
data-testid="sync-now-button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await syncMirror(namespace, repoName);
|
||||
addAlert({
|
||||
variant: AlertVariant.Success,
|
||||
title: 'Sync scheduled successfully',
|
||||
});
|
||||
const response = await getMirrorConfig(namespace, repoName);
|
||||
setConfig(response);
|
||||
} catch (err) {
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: 'Error scheduling sync',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FormTextInput
|
||||
name="syncStartDate"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label=""
|
||||
fieldId="sync_start_date"
|
||||
type="datetime-local"
|
||||
required
|
||||
isStack={false}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Sync Interval" fieldId="sync_interval" isStack>
|
||||
<InputGroup
|
||||
onPointerEnterCapture={() => setIsHovered(true)}
|
||||
onPointerLeaveCapture={() => setIsHovered(false)}
|
||||
className={isHovered ? 'pf-v5-u-background-color-200' : ''}
|
||||
>
|
||||
<Controller
|
||||
name="syncValue"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'This field is required',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'This field is required';
|
||||
}
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue) || numValue <= 0) {
|
||||
return 'Must be a positive number';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({field: {value, onChange}}) => (
|
||||
<TextInput
|
||||
type="text"
|
||||
id="sync_interval"
|
||||
value={value}
|
||||
onChange={(_event, newValue) => {
|
||||
const numericValue = newValue.replace(/[^0-9]/g, '');
|
||||
onChange(numericValue);
|
||||
}}
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
validated={
|
||||
errors.syncValue
|
||||
? ValidatedOptions.error
|
||||
: ValidatedOptions.default
|
||||
}
|
||||
aria-label="Sync interval value"
|
||||
data-testid="sync-interval-input"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="syncUnit"
|
||||
control={control}
|
||||
render={({field: {value, onChange}}) => (
|
||||
<Select
|
||||
isOpen={isSelectOpen}
|
||||
onOpenChange={(isOpen) => setIsSelectOpen(isOpen)}
|
||||
onSelect={(_event, selectedValue) => {
|
||||
onChange(selectedValue as string);
|
||||
setIsSelectOpen(false);
|
||||
}}
|
||||
selected={value}
|
||||
aria-label="Sync interval unit"
|
||||
toggle={(toggleRef) => (
|
||||
<MenuToggle
|
||||
ref={toggleRef}
|
||||
onClick={() => setIsSelectOpen(!isSelectOpen)}
|
||||
isExpanded={isSelectOpen}
|
||||
>
|
||||
{value}
|
||||
</MenuToggle>
|
||||
)}
|
||||
>
|
||||
<SelectOption value="seconds">seconds</SelectOption>
|
||||
<SelectOption value="minutes">minutes</SelectOption>
|
||||
<SelectOption value="hours">hours</SelectOption>
|
||||
<SelectOption value="days">days</SelectOption>
|
||||
<SelectOption value="weeks">weeks</SelectOption>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{errors.syncValue && (
|
||||
<FormHelperText>
|
||||
<Text component="p" className="pf-m-error">
|
||||
{errors.syncValue.message}
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Skopeo timeout interval"
|
||||
fieldId="skopeo_timeout_interval"
|
||||
isStack
|
||||
>
|
||||
<Controller
|
||||
name="skopeoTimeoutInterval"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'This field is required',
|
||||
validate: (value) => {
|
||||
if (!value || value < 300) {
|
||||
return 'Minimum timeout is 300 seconds (5 minutes)';
|
||||
}
|
||||
if (value > 43200) {
|
||||
return 'Maximum timeout is 43200 seconds (12 hours)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({field: {value, onChange}}) => (
|
||||
<InputGroup
|
||||
onPointerEnterCapture={() => setIsHovered(true)}
|
||||
onPointerLeaveCapture={() => setIsHovered(false)}
|
||||
className={isHovered ? 'pf-v5-u-background-color-200' : ''}
|
||||
>
|
||||
<TextInput
|
||||
type="number"
|
||||
id="skopeo_timeout_interval"
|
||||
value={value?.toString() || ''}
|
||||
onChange={(_event, newValue) => {
|
||||
const numericValue = parseInt(newValue) || 300;
|
||||
onChange(numericValue);
|
||||
}}
|
||||
min="300"
|
||||
max="43200"
|
||||
validated={
|
||||
errors.skopeoTimeoutInterval
|
||||
? ValidatedOptions.error
|
||||
: ValidatedOptions.default
|
||||
}
|
||||
aria-label="Skopeo timeout interval"
|
||||
data-testid="skopeo-timeout-input"
|
||||
/>
|
||||
<InputGroupText>seconds</InputGroupText>
|
||||
</InputGroup>
|
||||
)}
|
||||
/>
|
||||
{errors.skopeoTimeoutInterval && (
|
||||
<FormHelperText>
|
||||
<Text component="p" className="pf-m-error">
|
||||
{errors.skopeoTimeoutInterval.message}
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
)}
|
||||
<FormHelperText>
|
||||
<Text component="p">
|
||||
Minimum timeout length: 300 seconds (5 minutes). Maximum timeout
|
||||
length: 43200 seconds (12 hours).
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Robot User" fieldId="robot_username" isStack>
|
||||
<Controller
|
||||
name="robotUsername"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'This field is required',
|
||||
validate: (value) =>
|
||||
value?.trim() !== '' || 'This field is required',
|
||||
}}
|
||||
render={({field}) => (
|
||||
<>
|
||||
<EntitySearch
|
||||
id="robot-user-select"
|
||||
org={namespace}
|
||||
includeTeams={true}
|
||||
onSelect={(robot: Entity) => {
|
||||
setSelectedRobot(robot);
|
||||
field.onChange(robot.name);
|
||||
}}
|
||||
onClear={() => {
|
||||
setSelectedRobot(null);
|
||||
field.onChange('');
|
||||
}}
|
||||
value={selectedRobot?.name}
|
||||
onError={() =>
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: 'Error loading robot users',
|
||||
message: 'Failed to load available robots',
|
||||
})
|
||||
}
|
||||
defaultOptions={robotOptions}
|
||||
placeholderText="Select a team or user..."
|
||||
data-testid="robot-user-select"
|
||||
/>
|
||||
{errors.robotUsername && (
|
||||
<FormHelperText>
|
||||
<Text component="p" className="pf-m-error">
|
||||
{errors.robotUsername.message}
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import {Control, FieldErrors} from 'react-hook-form';
|
||||
import {Divider, Text, Title} from '@patternfly/react-core';
|
||||
import {FormTextInput} from 'src/components/forms/FormTextInput';
|
||||
import {MirroringConfigResponse} from 'src/resources/MirroringResource';
|
||||
import {MirroringFormData} from './types';
|
||||
|
||||
interface MirroringCredentialsProps {
|
||||
control: Control<MirroringFormData>;
|
||||
errors: FieldErrors<MirroringFormData>;
|
||||
config: MirroringConfigResponse | null;
|
||||
}
|
||||
|
||||
export const MirroringCredentials: React.FC<MirroringCredentialsProps> = ({
|
||||
control,
|
||||
errors,
|
||||
config,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Title headingLevel="h3">Credentials</Title>
|
||||
<Text
|
||||
component="small"
|
||||
className="pf-v5-c-form__helper-text pf-v5-u-text-align-center pf-v5-u-display-block"
|
||||
>
|
||||
Required if the external repository is private.
|
||||
</Text>
|
||||
|
||||
<FormTextInput
|
||||
name="username"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="Username"
|
||||
fieldId="username"
|
||||
showNoneWhenEmpty={!!config}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
name="password"
|
||||
control={control}
|
||||
errors={errors}
|
||||
label="Password"
|
||||
fieldId="external_registry_password"
|
||||
type="password"
|
||||
showNoneWhenEmpty={!!config}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import {TextContent, Text, Title} from '@patternfly/react-core';
|
||||
|
||||
interface MirroringHeaderProps {
|
||||
namespace: string;
|
||||
repoName: string;
|
||||
isConfigured: boolean;
|
||||
}
|
||||
|
||||
export const MirroringHeader: React.FC<MirroringHeaderProps> = ({
|
||||
namespace,
|
||||
repoName,
|
||||
isConfigured,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<TextContent>
|
||||
<Title headingLevel="h2">Repository Mirroring</Title>
|
||||
</TextContent>
|
||||
|
||||
<TextContent>
|
||||
{isConfigured ? (
|
||||
<Text>
|
||||
This repository is configured as a mirror. While enabled, Quay will
|
||||
periodically replicate any matching images on the external registry.
|
||||
Users cannot manually push to this repository.
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
This feature will convert{' '}
|
||||
<strong>
|
||||
{namespace}/{repoName}
|
||||
</strong>{' '}
|
||||
into a mirror. Changes to the external repository will be duplicated
|
||||
here. While enabled, users will be unable to push images to this
|
||||
repository.
|
||||
</Text>
|
||||
)}
|
||||
</TextContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import {AlertVariant} from 'src/atoms/AlertState';
|
||||
import CreateRobotAccountModal from 'src/components/modals/CreateRobotAccountModal';
|
||||
import {CreateTeamModal} from 'src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreateTeamModal';
|
||||
import {Entity} from 'src/resources/UserResource';
|
||||
import {RepoPermissionDropdownItems} from 'src/routes/RepositoriesList/RobotAccountsList';
|
||||
import {validateTeamName} from 'src/libs/utils';
|
||||
import {ITeams} from 'src/hooks/UseTeams';
|
||||
|
||||
interface MirroringModalsProps {
|
||||
// Robot modal props
|
||||
isCreateRobotModalOpen: boolean;
|
||||
setIsCreateRobotModalOpen: (open: boolean) => void;
|
||||
|
||||
// Team modal props
|
||||
isCreateTeamModalOpen: boolean;
|
||||
setIsCreateTeamModalOpen: (open: boolean) => void;
|
||||
teamName: string;
|
||||
setTeamName: (name: string) => void;
|
||||
teamDescription: string;
|
||||
setTeamDescription: (description: string) => void;
|
||||
|
||||
// Common props
|
||||
namespace: string;
|
||||
teams: ITeams[];
|
||||
|
||||
// Callbacks
|
||||
onRobotCreated: (robot: Entity) => void;
|
||||
onTeamCreated: (team: Entity) => void;
|
||||
addAlert: (alert: {
|
||||
variant: AlertVariant;
|
||||
title: string;
|
||||
message?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const MirroringModals: React.FC<MirroringModalsProps> = ({
|
||||
isCreateRobotModalOpen,
|
||||
setIsCreateRobotModalOpen,
|
||||
isCreateTeamModalOpen,
|
||||
setIsCreateTeamModalOpen,
|
||||
teamName,
|
||||
setTeamName,
|
||||
teamDescription,
|
||||
setTeamDescription,
|
||||
namespace,
|
||||
teams,
|
||||
onRobotCreated,
|
||||
onTeamCreated,
|
||||
addAlert,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Robot Creation Modal */}
|
||||
<CreateRobotAccountModal
|
||||
isModalOpen={isCreateRobotModalOpen}
|
||||
handleModalToggle={() => setIsCreateRobotModalOpen(false)}
|
||||
orgName={namespace}
|
||||
teams={teams}
|
||||
RepoPermissionDropdownItems={RepoPermissionDropdownItems}
|
||||
setEntity={onRobotCreated}
|
||||
showSuccessAlert={(msg) =>
|
||||
addAlert({variant: AlertVariant.Success, title: msg})
|
||||
}
|
||||
showErrorAlert={(msg) =>
|
||||
addAlert({variant: AlertVariant.Failure, title: msg})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Team Creation Modal */}
|
||||
<CreateTeamModal
|
||||
teamName={teamName}
|
||||
setTeamName={setTeamName}
|
||||
description={teamDescription}
|
||||
setDescription={setTeamDescription}
|
||||
orgName={namespace}
|
||||
nameLabel="Provide a name for your new team:"
|
||||
descriptionLabel="Provide an optional description for your new team"
|
||||
helperText="Enter a description to provide extra information to your teammates about this team:"
|
||||
nameHelperText="Choose a name to inform your teammates about this team. Must match ^([a-z0-9]+(?:[._-][a-z0-9]+)*)$"
|
||||
isModalOpen={isCreateTeamModalOpen}
|
||||
handleModalToggle={() => setIsCreateTeamModalOpen(false)}
|
||||
validateName={validateTeamName}
|
||||
setAppliedTo={onTeamCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import {Divider, Button} from '@patternfly/react-core';
|
||||
import {StatusDisplay} from 'src/components/StatusDisplay';
|
||||
import {AlertVariant} from 'src/atoms/AlertState';
|
||||
import {
|
||||
MirroringConfigResponse,
|
||||
getMirrorConfig,
|
||||
cancelSync,
|
||||
statusLabels,
|
||||
} from 'src/resources/MirroringResource';
|
||||
|
||||
interface MirroringStatusProps {
|
||||
config: MirroringConfigResponse | null;
|
||||
namespace: string;
|
||||
repoName: string;
|
||||
setConfig: (config: MirroringConfigResponse) => void;
|
||||
addAlert: (alert: {
|
||||
variant: AlertVariant;
|
||||
title: string;
|
||||
message?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const MirroringStatus: React.FC<MirroringStatusProps> = ({
|
||||
config,
|
||||
namespace,
|
||||
repoName,
|
||||
setConfig,
|
||||
addAlert,
|
||||
}) => {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<StatusDisplay
|
||||
title="Status"
|
||||
data-testid="mirror-status-display"
|
||||
items={[
|
||||
{
|
||||
label: 'State',
|
||||
value: statusLabels[config.sync_status] || config.sync_status,
|
||||
action: (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
type="button"
|
||||
isDisabled={
|
||||
config.sync_status !== 'SYNCING' &&
|
||||
config.sync_status !== 'SYNC_NOW'
|
||||
}
|
||||
data-testid="cancel-sync-button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancelSync(namespace, repoName);
|
||||
addAlert({
|
||||
variant: AlertVariant.Success,
|
||||
title: 'Sync cancelled successfully',
|
||||
});
|
||||
const response = await getMirrorConfig(namespace, repoName);
|
||||
setConfig(response);
|
||||
} catch (err) {
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: 'Error cancelling sync',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Timeout',
|
||||
value: config.sync_expiration_date || 'None',
|
||||
},
|
||||
{
|
||||
label: 'Retries Remaining',
|
||||
value:
|
||||
config.sync_retries_remaining != null
|
||||
? `${config.sync_retries_remaining} / 3`
|
||||
: '3 / 3',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
18
web/src/routes/RepositoryDetails/Mirroring/types.ts
Normal file
18
web/src/routes/RepositoryDetails/Mirroring/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Form data types for Mirroring components
|
||||
export interface MirroringFormData {
|
||||
isEnabled: boolean;
|
||||
externalReference: string;
|
||||
tags: string;
|
||||
syncStartDate: string;
|
||||
syncValue: string;
|
||||
syncUnit: string;
|
||||
robotUsername: string;
|
||||
username: string;
|
||||
password: string;
|
||||
verifyTls: boolean;
|
||||
httpProxy: string;
|
||||
httpsProxy: string;
|
||||
noProxy: string;
|
||||
unsignedImages: boolean;
|
||||
skopeoTimeoutInterval: number;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import TagHistory from './TagHistory/TagHistory';
|
||||
import TagsList from './Tags/TagsList';
|
||||
import {DrawerContentType} from './Types';
|
||||
import UsageLogs from '../UsageLogs/UsageLogs';
|
||||
import {Mirroring} from './Mirroring/Mirroring';
|
||||
|
||||
enum TabIndex {
|
||||
Tags = 'tags',
|
||||
@@ -49,6 +50,7 @@ enum TabIndex {
|
||||
TagHistory = 'history',
|
||||
Builds = 'builds',
|
||||
Logs = 'logs',
|
||||
Mirroring = 'mirroring',
|
||||
Settings = 'settings',
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ export default function RepositoryDetails() {
|
||||
const [activeTabKey, setActiveTabKey] = useState(TabIndex.Tags);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [drawerContent, setDrawerContent] = useState<DrawerContentType>(
|
||||
DrawerContentType.None,
|
||||
);
|
||||
@@ -235,6 +237,8 @@ export default function RepositoryDetails() {
|
||||
<Tab
|
||||
eventKey={TabIndex.Tags}
|
||||
title={<TabTitleText>Tags</TabTitleText>}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
>
|
||||
<TagsList
|
||||
organization={organization}
|
||||
@@ -245,6 +249,8 @@ export default function RepositoryDetails() {
|
||||
<Tab
|
||||
eventKey={TabIndex.TagHistory}
|
||||
title={<TabTitleText>Tag history</TabTitleText>}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
>
|
||||
<TagHistory
|
||||
org={organization}
|
||||
@@ -255,6 +261,8 @@ export default function RepositoryDetails() {
|
||||
<Tab
|
||||
eventKey={TabIndex.Logs}
|
||||
title={<TabTitleText>Logs</TabTitleText>}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
>
|
||||
<UsageLogs
|
||||
organization={organization}
|
||||
@@ -262,6 +270,35 @@ export default function RepositoryDetails() {
|
||||
type="repository"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={TabIndex.Mirroring}
|
||||
title={<TabTitleText>Mirroring</TabTitleText>}
|
||||
data-testid="mirroring-tab"
|
||||
isHidden={
|
||||
!config?.features?.REPO_MIRROR || !repoDetails?.can_admin
|
||||
}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
>
|
||||
{repoDetails?.state !== 'MIRROR' ? (
|
||||
<div>
|
||||
This repository's state is{' '}
|
||||
<strong>{repoDetails?.state}</strong>. Use the{' '}
|
||||
<a
|
||||
href={`/repository/${repoDetails?.namespace}/${repoDetails?.name}?tab=settings`}
|
||||
>
|
||||
Settings tab
|
||||
</a>{' '}
|
||||
and change it to <strong>Mirror</strong> to manage its
|
||||
mirroring configuration.
|
||||
</div>
|
||||
) : (
|
||||
<Mirroring
|
||||
namespace={organization}
|
||||
repoName={repository}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={TabIndex.Builds}
|
||||
title={<TabTitleText>Builds</TabTitleText>}
|
||||
@@ -270,6 +307,8 @@ export default function RepositoryDetails() {
|
||||
repoDetails?.state !== 'NORMAL' ||
|
||||
(!repoDetails?.can_write && !repoDetails?.can_admin)
|
||||
}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
>
|
||||
<Builds
|
||||
org={organization}
|
||||
@@ -282,6 +321,8 @@ export default function RepositoryDetails() {
|
||||
eventKey={TabIndex.Settings}
|
||||
title={<TabTitleText>Settings</TabTitleText>}
|
||||
isHidden={!repoDetails?.can_admin}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
>
|
||||
<Settings
|
||||
org={organization}
|
||||
|
||||
Reference in New Issue
Block a user