diff --git a/allowedSnakeCase.cjs b/allowedSnakeCase.cjs index b257daf6f7..c23947595c 100644 --- a/allowedSnakeCase.cjs +++ b/allowedSnakeCase.cjs @@ -622,6 +622,7 @@ module.exports = [ 'start_minute', 'start_time', 'start_timezone', + 'sync_not_available', 'subgroup_column', 'subject_dn', 'subject_type', diff --git a/public/locales/gsa-de.json b/public/locales/gsa-de.json index 415716aeba..6c63b9d1e7 100644 --- a/public/locales/gsa-de.json +++ b/public/locales/gsa-de.json @@ -482,6 +482,7 @@ "Diffuse": "Diffus", "Disable Tag": "Tag deaktivieren", "Distance": "Entfernung", + "Documentation": "Dokumentation", "Do not automatically delete reports": "Berichte nicht automatisch löschen", "Do not change": "Nicht ändern", "Do not start automatically": "Nicht automatisch starten", @@ -582,6 +583,7 @@ "Entire Operation": "Gesamte Ausführung", "Entity": "Objekt", "Error": "Fehler", + "Error fetching the feed": "Fehler beim Abrufen des Feeds", "Error Message": "Fehlermeldung", "Error Messages": "Fehlermeldungen", "Error while loading Report {{reportId}}": "Fehler beim Laden des Berichts {{reportId}}", @@ -674,6 +676,8 @@ "Families: Trend": "Familien: Trend", "Family": "Familie", "Feed Status": "Feed-Status", + "Feed is currently syncing.": "Der Feed wird derzeit synchronisiert.", + "Feed is currently syncing. Please try again later.": "Der Feed wird derzeit synchronisiert. Bitte versuchen Sie es später erneut.", "Feeds": "Feeds", "File path": "Dateipfad", "Filter": "Filter", @@ -1198,6 +1202,7 @@ "Please note that assigning a tag to {{count}} items may take several minutes.": "Bitte beachten Sie, dass es einige Minuten dauern kann, einen Tag {{count}} Objekten zuzuordnen.", "Please note: You are about to change your own personal user data as Super Admin! It is not possible to change the login name. If you have modified the login name, neither the login name nor any other changes made will be saved. If you have made any modifications other than the login name, the data will be saved when clicking OK, and you will be logged out immediately.": "Bitte beachten Sie: Sie sind dabei Ihre eigenen persönlichen Nutzerdaten als Super Administrator zu ändern! Es ist nicht möglich, den Loginnamen zu ändern. Wenn Sie den Loginnamen modifiziert haben, werden weder der Loginname noch jedwede andere Änderungen gespeichert. Wenn Sie andere Änderungen vorgenommen haben, werden die Daten beim Klick auf OK gespeichert und Sie werden umgehend ausgeloggt.", "Please note: You are about to create a user without a role. This user will not have any permissions and as a result will not be able to login.": "Bitte beachten Sie: Sie sind dabei einen Benutzer ohne Rolle zu erstellen. Dieser Benutzer wird keine Berechtigungen haben und deshalb nicht in der Lage sein, sich einzuloggen.", + "Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the": "Bitte warten Sie, während der Feed synchronisiert wird. Scans sind während dieser Zeit nicht verfügbar. Für weitere Informationen besuchen Sie die", "Please try again.": "Bitte versuchen Sie es erneut.", "POC": "POC", "Policies": "Richtlinien", @@ -1536,6 +1541,7 @@ "Support for RADIUS is not available.": "Unterstützung für RADIUS ist nicht verfügbar.", "System Logger": "System-Logger", "System Reports": "Systemberichte", + "Synchronization issue: {{error}}": "Synchronisationsproblem: {{error}}", "TCP": "TCP", "TCP Port Count": "TCP-Portanzahl", "TLS Certificate": "TLS-Zertifikat", @@ -1693,6 +1699,7 @@ "There are notes for this result": "Es gibt Notizen zu diesem Ergebnis", "There are overrides for this result": "Es gibt Übersteuerungen zu diesem Ergebnis", "There are tickets for this result": "Es gibt Tickets zu diesem Ergebnis", + "There was an error fetching the feed. It will be retried in a few minutes.": "Beim Abrufen des Feeds ist ein Fehler aufgetreten. Es wird in wenigen Minuten erneut versucht.", "There may be results below the currently selected Quality of Detection (QoD).": "Es könnten Ergebnisse vorliegen, die unterhalb der aktuellen Grenze für die Qualität der Erkennung (QdE) liegen.", "Third Party": "Drittanbieter", "This CPE does not appear in the CPE dictionary but is referenced by one or more CVE.": "Diese CPE ist nicht im CPE-Dictionary, wird aber von einem oder mehreren CVE referenziert.", diff --git a/src/gmp/commands/__tests__/feedstatus.js b/src/gmp/commands/__tests__/feedstatus.js index f36d60ce5c..c9f53ce7c4 100644 --- a/src/gmp/commands/__tests__/feedstatus.js +++ b/src/gmp/commands/__tests__/feedstatus.js @@ -38,7 +38,7 @@ describe('FeedStatusCommand tests', () => { }); const {data} = resp; - expect(data[0].feed_type).toEqual('NVT'); + expect(data[0].feedType).toEqual('NVT'); expect(data[0].name).toEqual('foo'); expect(data[0].description).toEqual('bar'); expect(data[0].currentlySyncing).toEqual({timestamp: 'baz'}); @@ -46,4 +46,71 @@ describe('FeedStatusCommand tests', () => { expect(data[0].version).toEqual('20190625T1319'); }); }); + + test('should return isSyncing true when feeds are currently syncing', async () => { + const response = createResponse({ + get_feeds: { + get_feeds_response: { + feed: [ + {type: 'NVT', currently_syncing: true, sync_not_available: false}, + {type: 'SCAP', currently_syncing: false, sync_not_available: false}, + ], + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new FeedStatus(fakeHttp); + + const result = await cmd.checkFeedSync(); + expect(result.isSyncing).toBe(true); + }); + + test('should return isSyncing true when feeds are not present', async () => { + const response = createResponse({ + get_feeds: { + get_feeds_response: { + feed: [ + { + type: 'OTHER', + currently_syncing: false, + sync_not_available: false, + }, + ], + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new FeedStatus(fakeHttp); + + const result = await cmd.checkFeedSync(); + expect(result.isSyncing).toBe(true); + }); + + test('should return isSyncing false when feeds are not syncing and are present', async () => { + const response = createResponse({ + get_feeds: { + get_feeds_response: { + feed: [ + {type: 'NVT', currently_syncing: false, sync_not_available: false}, + {type: 'SCAP', currently_syncing: false, sync_not_available: false}, + ], + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new FeedStatus(fakeHttp); + + const result = await cmd.checkFeedSync(); + expect(result.isSyncing).toBe(false); + }); + + test('should throw an error when readFeedInformation fails', async () => { + const fakeHttp = createHttp(Promise.reject(new Error('Network error'))); + const cmd = new FeedStatus(fakeHttp); + + await expect(cmd.checkFeedSync()).rejects.toThrow('Network error'); + }); }); diff --git a/src/gmp/commands/__tests__/task.js b/src/gmp/commands/__tests__/tasks.js similarity index 89% rename from src/gmp/commands/__tests__/task.js rename to src/gmp/commands/__tests__/tasks.js index c7dce9ba09..5a71c440da 100644 --- a/src/gmp/commands/__tests__/task.js +++ b/src/gmp/commands/__tests__/tasks.js @@ -6,8 +6,13 @@ import {describe, test, expect} from '@gsa/testing'; import {TaskCommand} from '../tasks'; +import {FeedStatus} from '../feedstatus'; -import {createActionResultResponse, createHttp} from '../testing'; +import { + createActionResultResponse, + createHttp, + createResponse, +} from '../testing'; import { HOSTS_ORDERING_RANDOM, AUTO_DELETE_KEEP_DEFAULT_VALUE, @@ -280,4 +285,29 @@ describe('TaskCommand tests', () => { expect(data.id).toEqual('foo'); }); }); + + test('should throw an error if feed is currently syncing', async () => { + const response = createResponse({ + get_feeds: { + get_feeds_response: { + feed: [ + {type: 'NVT', currently_syncing: true, sync_not_available: false}, + {type: 'SCAP', currently_syncing: false, sync_not_available: false}, + ], + }, + }, + }); + const fakeHttp = createHttp(response); + + const taskCmd = new TaskCommand(fakeHttp); + + const feedCmd = new FeedStatus(fakeHttp); + + const result = await feedCmd.checkFeedSync(); + expect(result.isSyncing).toBe(true); + + await expect(taskCmd.start({id: 'task1'})).rejects.toThrow( + 'Feed is currently syncing. Please try again later.', + ); + }); }); diff --git a/src/gmp/commands/feedstatus.js b/src/gmp/commands/feedstatus.js index 1150c54cb0..6f2be2dd0d 100644 --- a/src/gmp/commands/feedstatus.js +++ b/src/gmp/commands/feedstatus.js @@ -21,20 +21,20 @@ export const CERT_FEED = 'CERT'; export const SCAP_FEED = 'SCAP'; export const GVMD_DATA_FEED = 'GVMD_DATA'; -export class Feed { - constructor({type, name, description, status, version, currently_syncing}) { - this.feed_type = type; - this.name = name; - this.description = description; - this.status = status; - this.currentlySyncing = currently_syncing; - - const versionDate = convertVersion(version); - this.version = versionDate; - - const lastUpdate = parseDate(versionDate); - this.age = duration(date().diff(lastUpdate)); - } +export function createFeed(feed) { + const versionDate = convertVersion(feed.version); + const lastUpdate = parseDate(versionDate); + + return { + feedType: feed.type, + name: feed.name, + description: feed.description, + status: feed.status, + currentlySyncing: feed.currently_syncing, + syncNotAvailable: feed.sync_not_available, + version: versionDate, + age: duration(date().diff(lastUpdate)), + }; } export class FeedStatus extends HttpCommand { @@ -45,13 +45,41 @@ export class FeedStatus extends HttpCommand { readFeedInformation() { return this.httpGet().then(response => { const {data: envelope} = response; - const {get_feeds_response: feedsresponse} = envelope.get_feeds; + const {get_feeds_response: feedsResponse} = envelope.get_feeds; - const feeds = map(feedsresponse.feed, feed => new Feed(feed)); + const feeds = map(feedsResponse.feed, feed => createFeed(feed)); return response.setData(feeds); }); } + + /** + * Checks if any feed is currently syncing or if required feeds are not present. + * + * @returns {Promise<{isSyncing: boolean string}>} - A promise that resolves to an object indicating if any feed is syncing or if required feeds are not present or if there was an error. + * @throws {Error} - Throws an error if there is an issue fetching feed information. + */ + + async checkFeedSync() { + try { + const response = await this.readFeedInformation(); + + const isFeedSyncing = response.data.some( + feed => feed.currentlySyncing || feed.syncNotAvailable, + ); + + const isNotPresent = + !response.data.some(feed => feed.feedType === NVT_FEED) || + !response.data.some(feed => feed.feedType === SCAP_FEED); + + return { + isSyncing: isFeedSyncing || isNotPresent, + }; + } catch (error) { + console.error('Error checking if feed is syncing:', error); + throw error; + } + } } registerCommand('feedstatus', FeedStatus); diff --git a/src/gmp/commands/tasks.js b/src/gmp/commands/tasks.js index 73339a464a..4829286ae7 100644 --- a/src/gmp/commands/tasks.js +++ b/src/gmp/commands/tasks.js @@ -13,6 +13,7 @@ import Task, { HOSTS_ORDERING_SEQUENTIAL, AUTO_DELETE_KEEP_DEFAULT_VALUE, } from 'gmp/models/task'; +import {FeedStatus} from './feedstatus'; import EntitiesCommand from './entities'; import EntityCommand from './entity'; @@ -24,21 +25,28 @@ export class TaskCommand extends EntityCommand { super(http, 'task', Task); } - start({id}) { + async start({id}) { log.debug('Starting task...'); - return this.httpPost({ - cmd: 'start_task', - id, - }) - .then(() => { - log.debug('Started task'); - return this.get({id}); - }) - .catch(err => { - log.error('An error occurred while starting the task', id, err); - throw err; + try { + const feeds = new FeedStatus(this.http); + + const status = await feeds.checkFeedSync(); + + if (status.isSyncing) { + throw new Error('Feed is currently syncing. Please try again later.'); + } + + await this.httpPost({ + cmd: 'start_task', + id, }); + + log.debug('Started task'); + } catch (error) { + log.error('An error occurred while starting the task', id, error); + throw error; + } } stop({id}) { diff --git a/src/web/components/notification/FeedSyncNotification/FeedSyncNotification.jsx b/src/web/components/notification/FeedSyncNotification/FeedSyncNotification.jsx new file mode 100644 index 0000000000..2a68c94a01 --- /dev/null +++ b/src/web/components/notification/FeedSyncNotification/FeedSyncNotification.jsx @@ -0,0 +1,69 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import InfoPanel from 'web/components/panel/infopanel'; +import BlankLink from 'web/components/link/blanklink'; +import useTranslation from 'web/hooks/useTranslation'; +import styled from 'styled-components'; + +import { + useFeedSyncStatus, + useFeedSyncDialog, +} from 'web/components/notification/FeedSyncNotification/helpers'; + +const NotificationWrapper = styled.div` + padding-bottom: 20px; +`; + +const FeedSyncNotification = () => { + const [_] = useTranslation(); + useFeedSyncStatus(); + + const [isFeedSyncDialogOpened, setIsFeedSyncDialogOpened, feedStatus] = + useFeedSyncDialog(); + + const handleCloseFeedSyncNotification = () => { + setIsFeedSyncDialogOpened(false); + }; + + if (!isFeedSyncDialogOpened) return null; + + return ( + + + {feedStatus.error ? ( +

+ {_( + `There was an error fetching the feed. It will be retried in a few minutes.`, + )} +

+ ) : ( +

+ {_( + `Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the`, + )}{' '} + + {_('Documentation')}. + +

+ )} +
+
+ ); +}; + +export default FeedSyncNotification; diff --git a/src/web/components/notification/FeedSyncNotification/__tests__/FeedSyncNotification.jsx b/src/web/components/notification/FeedSyncNotification/__tests__/FeedSyncNotification.jsx new file mode 100644 index 0000000000..1ae9e7270a --- /dev/null +++ b/src/web/components/notification/FeedSyncNotification/__tests__/FeedSyncNotification.jsx @@ -0,0 +1,66 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; +import {rendererWith, waitFor, screen} from 'web/utils/testing'; + +import FeedSyncNotification from 'web/components/notification/FeedSyncNotification/FeedSyncNotification'; + +describe('FeedSyncNotification', () => { + test('should display syncing message when feed is syncing', async () => { + const {render, store} = rendererWith({store: true}); + + render(); + + store.dispatch({type: 'SET_SYNC_STATUS', payload: {isSyncing: true}}); + + await waitFor(() => { + expect(screen.getByText('Feed is currently syncing.')).toBeVisible(); + }); + expect( + screen.getByText( + 'Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the', + ), + ).toBeVisible(); + expect(screen.getByText('Documentation.')).toBeVisible(); + }); + + test('should display error message when there is an error', () => { + const {render, store} = rendererWith({store: true}); + + render(); + + store.dispatch({ + type: 'SET_ERROR', + payload: 'Error fetching the feed', + }); + + expect(screen.getByText('Error fetching the feed')).toBeVisible(); + expect( + screen.getByText( + 'There was an error fetching the feed. It will be retried in a few minutes.', + ), + ).toBeVisible(); + expect( + screen.getByText( + 'There was an error fetching the feed. It will be retried in a few minutes.', + ), + ).toBeVisible(); + }); + test('should not render anything when isFeedSyncDialogOpened is false', async () => { + const {render} = rendererWith({store: true}); + + render(); + expect(screen.queryByText('Feed is currently syncing.')).toBeNull(); + + const closeButton = screen.getByTestId('panel-close-button'); + closeButton.click(); + + await waitFor(() => { + expect(screen.queryByText('Feed is currently syncing.')).toBeNull(); + }); + expect(screen.queryByText('Error fetching the feed')).toBeNull(); + }); +}); diff --git a/src/web/components/notification/FeedSyncNotification/__tests__/helpers.jsx b/src/web/components/notification/FeedSyncNotification/__tests__/helpers.jsx new file mode 100644 index 0000000000..76b7a097e1 --- /dev/null +++ b/src/web/components/notification/FeedSyncNotification/__tests__/helpers.jsx @@ -0,0 +1,107 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + useFeedSyncStatus, + useFeedSyncDialog, +} from 'web/components/notification/FeedSyncNotification/helpers'; + +import {describe, test, expect, testing} from '@gsa/testing'; + +import {act, rendererWith} from 'web/utils/testing'; + +const mockCheckFeedSync = testing.fn(); + +const gmp = { + feedstatus: { + checkFeedSync: () => mockCheckFeedSync(), + }, +}; +const FIVE_MINUTES = 5 * 60 * 1000; +const THIRTY_SECONDS = 30 * 1000; + +testing.useFakeTimers(); +describe('FeedSyncNotification helpers', () => { + describe('useFeedSyncStatus Hook', () => { + beforeEach(() => { + testing.clearAllMocks(); + testing.useFakeTimers(); + }); + + test('should dispatch setSyncStatus on successful API call', async () => { + mockCheckFeedSync.mockResolvedValue({isSyncing: true}); + + const {renderHook, store} = rendererWith({store: true, gmp}); + + renderHook(() => useFeedSyncStatus()); + + await act(async () => Promise.resolve()); + + expect(store.getState().feedStatus.isSyncing).toBe(true); + expect(store.getState().feedStatus.error).toBeNull(); + }); + test('should dispatch setError on API call failure', async () => { + const errorMessage = 'Network Error'; + mockCheckFeedSync.mockRejectedValue(new Error(errorMessage)); + + const {renderHook, store} = rendererWith({store: true, gmp}); + + renderHook(() => useFeedSyncStatus()); + + await act(async () => Promise.resolve()); + + expect(store.getState().feedStatus.isSyncing).toBe(false); + expect(store.getState().feedStatus.error).toBe(`Error: ${errorMessage}`); + }); + + test('should call API periodically', async () => { + mockCheckFeedSync.mockResolvedValue({isSyncing: false}); + + const {renderHook} = rendererWith({store: true, gmp}); + renderHook(() => useFeedSyncStatus()); + + testing.advanceTimersByTime(FIVE_MINUTES); + + expect(mockCheckFeedSync).toHaveBeenCalledTimes(2); + }); + }); + + describe('useFeedSyncDialog Hook', () => { + beforeEach(() => { + testing.clearAllMocks(); + testing.useFakeTimers(); + }); + + test('should open dialog when feedStatus.isSyncing is true', () => { + const {renderHook, store} = rendererWith({store: true, gmp}); + store.dispatch({type: 'SET_SYNC_STATUS', payload: {isSyncing: true}}); + const {result} = renderHook(() => useFeedSyncDialog()); + + expect(result.current[0]).toBe(true); + }); + + test('should open dialog when feedStatus.error is not null', () => { + const {renderHook, store} = rendererWith({store: true, gmp}); + store.dispatch({type: 'SET_SYNC_STATUS', payload: {error: 'Error'}}); + const {result} = renderHook(() => useFeedSyncDialog()); + + expect(result.current[0]).toBe(true); + }); + + test('should close dialog after THIRTY_SECONDS', () => { + const {renderHook, store} = rendererWith({store: true, gmp}); + store.dispatch({type: 'SET_SYNC_STATUS', payload: {isSyncing: true}}); + const {result} = renderHook(() => useFeedSyncDialog()); + + expect(result.current[0]).toBe(true); + + act(() => { + testing.advanceTimersByTime(THIRTY_SECONDS); + }); + + expect(result.current[0]).toBe(false); + }); + }); +}); diff --git a/src/web/components/notification/FeedSyncNotification/helpers.jsx b/src/web/components/notification/FeedSyncNotification/helpers.jsx new file mode 100644 index 0000000000..4e34ccfa91 --- /dev/null +++ b/src/web/components/notification/FeedSyncNotification/helpers.jsx @@ -0,0 +1,67 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {setSyncStatus, setError} from 'web/store/feedStatus/actions'; +import useGmp from 'web/hooks/useGmp'; + +const FIVE_MINUTES = 5 * 60 * 1000; +const THIRTY_SECONDS = 30 * 1000; + +/** + * Custom hook to check the feed sync status updating the store with the response + * at regular intervals defined by `FIVE_MINUTES`. + * @returns {void} + */ + +export const useFeedSyncStatus = () => { + const dispatch = useDispatch(); + const gmp = useGmp(); + + useEffect(() => { + const fetchAndDispatch = async () => { + try { + const response = await gmp.feedstatus.checkFeedSync(); + dispatch(setSyncStatus(response.isSyncing)); + } catch (error) { + dispatch(setError(error.toString())); + } + }; + + fetchAndDispatch(); + const intervalId = setInterval(fetchAndDispatch, FIVE_MINUTES); + + return () => clearInterval(intervalId); + }, [dispatch, gmp]); +}; + +/** + * Hook to manage the feed sync dialog state. + * + * @returns {[boolean, React.Dispatch>, { isSyncing: boolean, error: string }]} + * An array containing the dialog state, a function to set the dialog state, and the feed status. + */ + +export const useFeedSyncDialog = () => { + const feedStatus = useSelector(state => state.feedStatus); + + const [isFeedSyncDialogOpened, setIsFeedSyncDialogOpened] = useState(false); + + useEffect(() => { + if (feedStatus.isSyncing || feedStatus.error) { + setIsFeedSyncDialogOpened(true); + + const timer = setTimeout(() => { + setIsFeedSyncDialogOpened(false); + }, THIRTY_SECONDS); + + return () => clearTimeout(timer); + } + }, [feedStatus.isSyncing, feedStatus.error]); + + return [isFeedSyncDialogOpened, setIsFeedSyncDialogOpened, feedStatus]; +}; diff --git a/src/web/components/panel/infopanel.jsx b/src/web/components/panel/infopanel.jsx index 60a664694d..7898c20514 100644 --- a/src/web/components/panel/infopanel.jsx +++ b/src/web/components/panel/infopanel.jsx @@ -72,7 +72,7 @@ const InfoPanel = ({ {heading} {isDefined(onCloseClick) && ( - )} diff --git a/src/web/pages/extras/__tests__/feedstatuspage.jsx b/src/web/pages/extras/__tests__/feedstatuspage.jsx index ab50f2df89..5da8cbbdf8 100644 --- a/src/web/pages/extras/__tests__/feedstatuspage.jsx +++ b/src/web/pages/extras/__tests__/feedstatuspage.jsx @@ -8,7 +8,7 @@ import {describe, test, expect, testing} from '@gsa/testing'; import {rendererWith, waitFor} from 'web/utils/testing'; import FeedStatus from '../feedstatuspage'; -import {Feed} from 'gmp/commands/feedstatus'; +import {createFeed} from 'gmp/commands/feedstatus'; import Response from 'gmp/http/response'; @@ -20,25 +20,25 @@ const _now = global.Date.now; // set mockDate so the feed ages don't keep changing global.Date.now = testing.fn(() => mockDate); -const nvtFeed = new Feed({ +const nvtFeed = createFeed({ name: 'Greenbone Community Feed', type: 'NVT', version: 202007241005, }); -const scapFeed = new Feed({ +const scapFeed = createFeed({ name: 'Greenbone Community SCAP Feed', type: 'SCAP', version: 202007230130, }); -const certFeed = new Feed({ +const certFeed = createFeed({ name: 'Greenbone Community CERT Feed', type: 'CERT', version: 202005231003, }); -const gvmdDataFeed = new Feed({ +const gvmdDataFeed = createFeed({ name: 'Greenbone Community gvmd Data Feed', type: 'GVMD_DATA', version: 202006221009, diff --git a/src/web/pages/extras/feedstatuspage.jsx b/src/web/pages/extras/feedstatuspage.jsx index 985c28a0cc..0be548f5cd 100644 --- a/src/web/pages/extras/feedstatuspage.jsx +++ b/src/web/pages/extras/feedstatuspage.jsx @@ -75,6 +75,11 @@ const renderFeedStatus = feed => { return _('Update in progress...'); } + if (hasValue(feed.syncNotAvailable)) { + const {error} = feed.syncNotAvailable; + return _('Synchronization issue: {{error}}', {error}); + } + const age = parseInt(feed.age.asDays()); if (age >= 30) { @@ -84,6 +89,7 @@ const renderFeedStatus = feed => { if (age >= 2) { return _('{{age}} days old', {age}); } + return _('Current'); }; @@ -109,10 +115,10 @@ const FeedStatus = ({feeds}) => { {feeds.map(feed => ( - - {feed.feed_type} + + {feed.feedType} - {feed.feed_type === NVT_FEED && ( + {feed.feedType === NVT_FEED && ( @@ -122,7 +128,7 @@ const FeedStatus = ({feeds}) => { )} - {feed.feed_type === SCAP_FEED && ( + {feed.feedType === SCAP_FEED && ( @@ -138,7 +144,7 @@ const FeedStatus = ({feeds}) => { )} - {feed.feed_type === CERT_FEED && ( + {feed.feedType === CERT_FEED && ( @@ -154,7 +160,7 @@ const FeedStatus = ({feeds}) => { )} - {feed.feed_type === GVMD_DATA_FEED && ( + {feed.feedType === GVMD_DATA_FEED && ( diff --git a/src/web/pages/page.jsx b/src/web/pages/page.jsx index 977077de72..6871c7bf9a 100644 --- a/src/web/pages/page.jsx +++ b/src/web/pages/page.jsx @@ -3,8 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - -import React from 'react'; +import PropTypes from 'web/utils/proptypes'; import {useLocation} from 'react-router-dom'; @@ -23,6 +22,7 @@ import useLoadCapabilities from 'web/hooks/useLoadCapabilities'; import Footer from 'web/components/structure/footer'; import Header from 'web/components/structure/header'; import Main from 'web/components/structure/main'; +import FeedSyncNotification from 'web/components/notification/FeedSyncNotification/FeedSyncNotification'; import Menu from 'web/components/menu/menu'; @@ -49,6 +49,7 @@ const Page = ({children}) => {
+ { ); }; +Page.propTypes = { + children: PropTypes.node.isRequired, +}; + export default Page; diff --git a/src/web/pages/tasks/__tests__/actions.jsx b/src/web/pages/tasks/__tests__/actions.jsx index afe4c73184..6cf7775efc 100644 --- a/src/web/pages/tasks/__tests__/actions.jsx +++ b/src/web/pages/tasks/__tests__/actions.jsx @@ -39,7 +39,7 @@ describe('Task Actions tests', () => { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: caps}); + const {render} = rendererWith({capabilities: caps, store: true}); const {element} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: caps}); + const {render} = rendererWith({capabilities: caps, store: true}); const {getAllByTestId} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: wrongCaps}); + const {render} = rendererWith({capabilities: wrongCaps, store: true}); const {getAllByTestId} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: wrongCaps}); + const {render} = rendererWith({capabilities: wrongCaps, store: true}); const {getAllByTestId} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: wrongCaps}); + const {render} = rendererWith({capabilities: wrongCaps, store: true}); const {getAllByTestId} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: caps}); + const {render} = rendererWith({capabilities: caps, store: true}); const {getAllByTestId} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: caps}); + const {render} = rendererWith({capabilities: caps, store: true}); const {getAllByTestId} = render( { const handleTaskStart = testing.fn(); const handleTaskStop = testing.fn(); - const {render} = rendererWith({capabilities: caps}); + const {render} = rendererWith({capabilities: caps, store: true}); const {getAllByTestId} = render( { gmp, capabilities: caps, router: true, + store: true, }); const {element, getAllByTestId} = render( @@ -676,6 +677,7 @@ describe('Task ToolBarIcons tests', () => { gmp, capabilities: caps, router: true, + store: true, }); const {baseElement, getAllByTestId} = render( @@ -771,6 +773,7 @@ describe('Task ToolBarIcons tests', () => { gmp, capabilities: caps, router: true, + store: true, }); const {baseElement, getAllByTestId} = render( @@ -873,6 +876,7 @@ describe('Task ToolBarIcons tests', () => { gmp, capabilities: caps, router: true, + store: true, }); const {baseElement, getAllByTestId} = render( @@ -974,6 +978,7 @@ describe('Task ToolBarIcons tests', () => { gmp, capabilities: caps, router: true, + store: true, }); const {baseElement, getAllByTestId} = render( @@ -1077,6 +1082,7 @@ describe('Task ToolBarIcons tests', () => { gmp, capabilities: caps, router: true, + store: true, }); const {baseElement, getAllByTestId} = render( @@ -1241,6 +1247,7 @@ describe('Task ToolBarIcons tests', () => { gmp, capabilities: caps, router: true, + store: true, }); const {baseElement, getAllByTestId} = render( diff --git a/src/web/pages/tasks/actions.jsx b/src/web/pages/tasks/actions.jsx index 6963f8e774..7850f70426 100644 --- a/src/web/pages/tasks/actions.jsx +++ b/src/web/pages/tasks/actions.jsx @@ -20,10 +20,9 @@ import EditIcon from 'web/entity/icon/editicon'; import TrashIcon from 'web/entity/icon/trashicon'; import ImportReportIcon from 'web/pages/tasks/icons/importreporticon'; -import ResumeIcon from 'web/pages/tasks/icons/resumeicon'; import ScheduleIcon from 'web/pages/tasks/icons/scheduleicon'; -import StartIcon from 'web/pages/tasks/icons/starticon'; import StopIcon from 'web/pages/tasks/icons/stopicon'; +import TaskIconWithSync from 'web/pages/tasks/icons/TaskIconsWithSync'; import PropTypes from 'web/utils/proptypes'; @@ -43,13 +42,13 @@ const Actions = ({ {isDefined(entity.schedule) && ( )} - + - + diff --git a/src/web/pages/tasks/detailspage.jsx b/src/web/pages/tasks/detailspage.jsx index 4cf49ebf50..5206549b06 100644 --- a/src/web/pages/tasks/detailspage.jsx +++ b/src/web/pages/tasks/detailspage.jsx @@ -89,9 +89,8 @@ import withComponentDefaults from 'web/utils/withComponentDefaults'; import ImportReportIcon from './icons/importreporticon'; import NewIconMenu from './icons/newiconmenu'; -import ResumeIcon from './icons/resumeicon'; +import TaskIconWithSync from 'web/pages/tasks/icons/TaskIconsWithSync'; import ScheduleIcon from './icons/scheduleicon'; -import StartIcon from './icons/starticon'; import StopIcon from './icons/stopicon'; import TaskDetails from './details'; @@ -156,14 +155,22 @@ export const ToolBarIcons = ({ links={links} /> )} - + {!entity.isContainer() && ( - + )} @@ -182,18 +189,19 @@ export const ToolBarIcons = ({ )} - {!isDefined(entity.current_report) && isDefined(entity.last_report) && ( - - - - )} + {!isDefined(entity.current_report) && + isDefined(entity.last_report) && ( + + + + )} { + const [_] = useTranslation(); + + const feedSyncingStatus = useSelector(state => state.feedStatus); + + if (feedSyncingStatus.isSyncing) { + const SyncingIcon = type === 'start' ? StartIcon : ResumeIcon; + return ( + + ); + } + + const BaseIcon = type === 'start' ? TaskStartIconBase : TaskResumeIconBase; + return ; +}; + +TaskIconWithSync.propTypes = { + type: PropTypes.string.isRequired, +}; + +export default TaskIconWithSync; diff --git a/src/web/pages/tasks/icons/__tests__/TaskIconsWithSync.jsx b/src/web/pages/tasks/icons/__tests__/TaskIconsWithSync.jsx new file mode 100644 index 0000000000..f93f897cd5 --- /dev/null +++ b/src/web/pages/tasks/icons/__tests__/TaskIconsWithSync.jsx @@ -0,0 +1,82 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import Theme from 'web/utils/theme'; +import Capabilities from 'gmp/capabilities/capabilities'; +import Task, {TASK_STATUS} from 'gmp/models/task'; +import {rendererWith, fireEvent} from 'web/utils/testing'; +import TaskIconWithSync from 'web/pages/tasks/icons/TaskIconsWithSync'; +import {setSyncStatus} from 'web/store/feedStatus/actions'; + +describe('TaskIconWithSync component tests', () => { + const testCases = [ + { + description: + 'should render StartIcon when type is "start" and not syncing', + type: 'start', + taskStatus: TASK_STATUS.new, + expectedTitle: 'Start', + expectedFill: false, + }, + { + description: + 'should render ResumeIcon when type is "resume" and not syncing', + type: 'resume', + taskStatus: TASK_STATUS.stopped, + expectedTitle: 'Resume', + expectedFill: false, + }, + { + description: 'should render syncing message when feed is syncing', + type: 'start', + taskStatus: TASK_STATUS.new, + expectedTitle: 'Feed is currently syncing. Please try again later.', + expectedFill: true, + isSyncing: true, + }, + ]; + + test.each(testCases)( + '$description', + ({type, taskStatus, expectedTitle, expectedFill, isSyncing = false}) => { + const caps = new Capabilities(['everything']); + const task = Task.fromElement({ + status: taskStatus, + target: {_id: '123'}, + permissions: {permission: [{name: 'everything'}]}, + }); + const clickHandler = testing.fn(); + + const {render, store} = rendererWith({capabilities: caps, store: true}); + + store.dispatch(setSyncStatus(isSyncing)); + + const {element} = render( + , + ); + + if (!isSyncing) { + expect(caps.mayOp(`${type}_task`)).toEqual(true); + expect(task.userCapabilities.mayOp(`${type}_task`)).toEqual(true); + + fireEvent.click(element); + + expect(clickHandler).toHaveBeenCalled(); + } + + expect(element).toHaveAttribute('title', expectedTitle); + if (expectedFill) { + expect(element).toHaveStyleRule('fill', Theme.inputBorderGray, { + modifier: `svg path`, + }); + } else { + expect(element).not.toHaveStyleRule('fill', Theme.inputBorderGray, { + modifier: `svg path`, + }); + } + }, + ); +}); diff --git a/src/web/store/feedStatus/__tests__/actions.js b/src/web/store/feedStatus/__tests__/actions.js new file mode 100644 index 0000000000..87be248350 --- /dev/null +++ b/src/web/store/feedStatus/__tests__/actions.js @@ -0,0 +1,33 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; + +import { + SET_SYNC_STATUS, + SET_ERROR, + setSyncStatus, + setError, +} from 'web/store/feedStatus/actions'; + +describe('actions', () => { + test('setSyncStatus should create an action to set sync status', () => { + const isSyncing = true; + const expectedAction = { + type: SET_SYNC_STATUS, + payload: isSyncing, + }; + expect(setSyncStatus(isSyncing)).toEqual(expectedAction); + }); + + test('setError should create an action to set an error', () => { + const error = 'Fetch failed'; + const expectedAction = { + type: SET_ERROR, + payload: error, + }; + expect(setError(error)).toEqual(expectedAction); + }); +}); diff --git a/src/web/store/feedStatus/__tests__/reducers.js b/src/web/store/feedStatus/__tests__/reducers.js new file mode 100644 index 0000000000..1cd17bf716 --- /dev/null +++ b/src/web/store/feedStatus/__tests__/reducers.js @@ -0,0 +1,45 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; + +import feedStatus from 'web/store/feedStatus/reducers'; +import {SET_SYNC_STATUS, SET_ERROR} from 'web/store/feedStatus/actions'; + +describe('feedStatus reducer', () => { + const initialState = { + isSyncing: false, + error: null, + }; + + test('should return the initial state', () => { + expect(feedStatus(undefined, {})).toEqual(initialState); + }); + + test('should handle SET_SYNC_STATUS', () => { + const action = { + type: SET_SYNC_STATUS, + payload: true, + }; + const expectedState = { + isSyncing: true, + error: null, + }; + + expect(feedStatus(initialState, action)).toEqual(expectedState); + }); + + test('should handle SET_ERROR', () => { + const action = { + type: SET_ERROR, + payload: 'Fetch failed', + }; + const expectedState = { + isSyncing: false, + error: 'Fetch failed', + }; + expect(feedStatus(initialState, action)).toEqual(expectedState); + }); +}); diff --git a/src/web/store/feedStatus/actions.js b/src/web/store/feedStatus/actions.js new file mode 100644 index 0000000000..e2a89989e4 --- /dev/null +++ b/src/web/store/feedStatus/actions.js @@ -0,0 +1,17 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const SET_SYNC_STATUS = 'SET_SYNC_STATUS'; +export const SET_ERROR = 'SET_ERROR'; + +export const setSyncStatus = isSyncing => ({ + type: SET_SYNC_STATUS, + payload: isSyncing, +}); + +export const setError = error => ({ + type: SET_ERROR, + payload: error, +}); diff --git a/src/web/store/feedStatus/reducers.js b/src/web/store/feedStatus/reducers.js new file mode 100644 index 0000000000..ebdf802591 --- /dev/null +++ b/src/web/store/feedStatus/reducers.js @@ -0,0 +1,24 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {SET_SYNC_STATUS, SET_ERROR} from 'web/store/feedStatus/actions'; + +const initialState = { + isSyncing: false, + error: null, +}; + +const feedStatus = (state = initialState, action) => { + switch (action.type) { + case SET_SYNC_STATUS: + return {...state, isSyncing: action.payload, error: null}; + case SET_ERROR: + return {...state, error: action.payload}; + default: + return state; + } +}; + +export default feedStatus; diff --git a/src/web/store/reducers.js b/src/web/store/reducers.js index 7158e82c24..65b9fee783 100644 --- a/src/web/store/reducers.js +++ b/src/web/store/reducers.js @@ -12,6 +12,7 @@ import pages from './pages/reducers'; import entities from './entities/reducers'; import {CLEAR_STORE} from 'web/store/actions'; +import feedStatus from 'web/store/feedStatus/reducers'; const rootReducer = combineReducers({ dashboardData, @@ -19,6 +20,7 @@ const rootReducer = combineReducers({ entities, userSettings, pages, + feedStatus, }); const clearStoreReducer = (state = {}, action) => { diff --git a/src/web/utils/testing.jsx b/src/web/utils/testing.jsx index e08caec0c2..9130726598 100644 --- a/src/web/utils/testing.jsx +++ b/src/web/utils/testing.jsx @@ -20,7 +20,7 @@ import { queryAllByAttribute, getElementError, within, - renderHook, + renderHook as rtlRenderHook, } from '@testing-library/react/pure'; import userEvent, {PointerEventsCheckLevel} from '@testing-library/user-event'; @@ -109,7 +109,6 @@ export const render = ui => { queryByName: name => queryByName(baseElement, name), queryAllByName: name => queryAllByName(baseElement, name), within: () => within(baseElement), - renderHook: hook => renderHook(hook, {wrapper: ui}), rerender: component => rerender(
{component}
), ...other, }; @@ -156,22 +155,26 @@ export const rendererWith = ( capabilities = new EverythingCapabilities(); } + const Providers = ({children}) => ( + + + + + {children} + + + + + ); + + const wrapper = ({children}) => {children}; + return { - render: ui => - render( - - - - - {ui} - - - - , - ), + render: ui => render({ui}), gmp, store, history, + renderHook: hook => rtlRenderHook(hook, {wrapper}), }; };