diff --git a/.betterer.results.json b/.betterer.results.json index 566910833556..1a33c81ed72b 100644 --- a/.betterer.results.json +++ b/.betterer.results.json @@ -5366,6 +5366,12 @@ "count": 1 } ], + "/public/app/percona/integrated-alerting/components/TemplateForm/EvaluateEvery/EvaluateEvery.styles.ts": [ + { + "message": "Styles should be written using objects.", + "count": 1 + } + ], "/public/app/plugins/datasource/alertmanager/DataSource.ts": [ { "message": "Unexpected any. Specify a different type.", diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 92344e6ae5ae..ee440387c75d 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -44,6 +44,7 @@ export interface NavModelItem extends NavLinkDTO { isDivider?: boolean; isHeading?: boolean; showChildren?: boolean; + showDot?: boolean; } /** diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx index f4a58771cd74..d9bfc7f91dc7 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx @@ -6,6 +6,7 @@ import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data'; import { useStyles2, Text, IconButton, Icon } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; +import { Dot } from 'app/percona/shared/components/Elements/Dot'; import { Indent } from '../../Indent/Indent'; @@ -100,14 +101,23 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { {/* @PERCONA - show icons for inner items */} {level <= 1 && link.icon && ( - + <> + + {/* @PERCONA */} + {!!link.showDot && } + )} - {link.text} + {/* @PERCONA */} +
+ {link.text} + {/* @PERCONA */} + {!!link.showDot && !link.icon && } +
@@ -208,6 +218,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ fontStyle: 'italic', padding: theme.spacing(1, 1.5, 1, 7), }), + // @PERCONA + relativeText: css({ + position: 'relative', + }), }); function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { diff --git a/public/app/percona/shared/components/Elements/Dot/Dot.styles.ts b/public/app/percona/shared/components/Elements/Dot/Dot.styles.ts new file mode 100644 index 000000000000..820ce7f1ba73 --- /dev/null +++ b/public/app/percona/shared/components/Elements/Dot/Dot.styles.ts @@ -0,0 +1,17 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme2, top?: number, bottom?: number, right?: number, left?: number) => ({ + dot: css({ + position: 'absolute', + width: 6, + height: 6, + top, + bottom, + right, + left, + borderRadius: theme.shape.radius.circle, + backgroundColor: theme.colors.error.main, + }), +}); diff --git a/public/app/percona/shared/components/Elements/Dot/Dot.tsx b/public/app/percona/shared/components/Elements/Dot/Dot.tsx new file mode 100644 index 000000000000..ba6e3fdebc29 --- /dev/null +++ b/public/app/percona/shared/components/Elements/Dot/Dot.tsx @@ -0,0 +1,13 @@ +import classNames from 'classnames'; +import React, { FC } from 'react'; + +import { useStyles2 } from '@grafana/ui'; + +import { getStyles } from './Dot.styles'; +import { DotProps } from './Dot.types'; + +export const Dot: FC = ({ top, bottom, right, left }) => { + const styles = useStyles2(getStyles, top, bottom, right, left); + + return
; +}; diff --git a/public/app/percona/shared/components/Elements/Dot/Dot.types.ts b/public/app/percona/shared/components/Elements/Dot/Dot.types.ts new file mode 100644 index 000000000000..8bf1ac7554b0 --- /dev/null +++ b/public/app/percona/shared/components/Elements/Dot/Dot.types.ts @@ -0,0 +1,6 @@ +export interface DotProps { + top?: number; + bottom?: number; + right?: number; + left?: number; +} diff --git a/public/app/percona/shared/components/Elements/Dot/index.ts b/public/app/percona/shared/components/Elements/Dot/index.ts new file mode 100644 index 000000000000..3ebede5c033a --- /dev/null +++ b/public/app/percona/shared/components/Elements/Dot/index.ts @@ -0,0 +1 @@ +export { Dot } from './Dot'; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx index be1a330158e9..52ed538491c4 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx @@ -18,6 +18,7 @@ import { useAppDispatch } from 'app/store/store'; import { Telemetry } from '../../../ui-events/components/Telemetry'; import usePerconaTour from '../../core/hooks/tour'; +import { checkUpdatesAction } from '../../core/reducers/updates'; import { isPmmAdmin } from '../../helpers/permissions'; import { Messages } from './PerconaBootstrapper.messages'; @@ -79,6 +80,7 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { await getSettings(); await dispatch(fetchUserStatusAction()); await dispatch(fetchAdvisors({ disableNotifications: true })); + await dispatch(checkUpdatesAction()); } await getUserDetails(); diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts index 1b9752e46561..c9b706fec7df 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts @@ -85,6 +85,15 @@ export const PMM_INVENTORY_PAGE: NavModelItem = { children: [PMM_SERVICES_PAGE, PMM_NODES_PAGE], }; +export const PMM_UPDATES_LINK: NavModelItem = { + id: 'pmm-updates', + text: 'Updates', + url: '/pmm-ui/updates', + hideFromTabs: true, + target: '_self', + showDot: false, +}; + export const PMM_HEADING_LINK: NavModelItem = { id: 'settings-pmm', text: 'PMM', diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx index bb5f1e17dc3b..3852da9bac37 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx @@ -10,7 +10,13 @@ import { fetchActiveServiceTypesAction } from 'app/percona/shared/core/reducers/ import { useAppDispatch } from 'app/store/store'; import { FolderDTO, useSelector } from 'app/types'; -import { getCategorizedAdvisors, getPerconaSettings, getPerconaUser, getServices } from '../../../core/selectors'; +import { + getCategorizedAdvisors, + getPerconaSettings, + getPerconaUser, + getServices, + getUpdatesInfo, +} from '../../../core/selectors'; import { ACTIVE_SERVICE_TYPES_CHECK_INTERVAL_MS, @@ -50,6 +56,7 @@ const PerconaNavigation: FC = () => { const dispatch = useAppDispatch(); const { activeTypes } = useSelector(getServices); const advisorsPage = buildAdvisorsNavItem(categorizedAdvisors); + const { updateAvailable } = useSelector(getUpdatesInfo); dispatch(updateNavIndex(getPmmSettingsPage(alertingEnabled))); dispatch(updateNavIndex(PMM_DUMP_PAGE)); @@ -113,7 +120,7 @@ const PerconaNavigation: FC = () => { } } - buildInventoryAndSettings(updatedNavTree, result); + buildInventoryAndSettings(updatedNavTree, result, updateAvailable); const iaMenuItem = alertingEnabled ? buildIntegratedAlertingMenuItem(updatedNavTree) @@ -140,7 +147,7 @@ const PerconaNavigation: FC = () => { dispatch(updateNavTree(filterByServices(updatedNavTree, activeTypes))); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [result, folders, activeTypes, isAuthorized, isPlatformUser, advisorsPage]); + }, [result, folders, activeTypes, isAuthorized, isPlatformUser, advisorsPage, updateAvailable]); return null; }; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.utils.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.utils.ts index 5fffbd4070fc..ecd9b13196cd 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.utils.ts +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.utils.ts @@ -22,6 +22,7 @@ import { PMM_ADD_INSTANCE_CREATE_PAGE, getPmmSettingsPage, PMM_INVENTORY_PAGE, + PMM_UPDATES_LINK, } from './PerconaNavigation.constants'; export const buildIntegratedAlertingMenuItem = (mainLinks: NavModelItem[]): NavModelItem | undefined => { @@ -53,7 +54,11 @@ export const removeAlertingMenuItem = (mainLinks: NavModelItem[]) => { return alertingItem; }; -export const buildInventoryAndSettings = (mainLinks: NavModelItem[], settings?: Settings): NavModelItem[] => { +export const buildInventoryAndSettings = ( + mainLinks: NavModelItem[], + settings?: Settings, + updateAvailable?: boolean +): NavModelItem[] => { const inventoryLink: NavModelItem = PMM_INVENTORY_PAGE; const orgLink: NavModelItem = { id: 'main-organization', @@ -64,6 +69,8 @@ export const buildInventoryAndSettings = (mainLinks: NavModelItem[], settings?: const configNode = mainLinks.find((link) => link.id === 'cfg'); const pmmConfigNode = mainLinks.find((link) => link.id === 'pmmcfg'); + PMM_UPDATES_LINK.showDot = updateAvailable; + if (!pmmConfigNode) { const pmmcfgNode: NavModelItem = { id: 'pmmcfg', @@ -71,8 +78,9 @@ export const buildInventoryAndSettings = (mainLinks: NavModelItem[], settings?: icon: 'percona-nav-logo', url: `${config.appSubUrl}/inventory`, subTitle: 'Configuration', - children: [PMM_ADD_INSTANCE_PAGE, PMM_ADD_INSTANCE_CREATE_PAGE, inventoryLink, settingsLink], + children: [PMM_ADD_INSTANCE_PAGE, PMM_ADD_INSTANCE_CREATE_PAGE, inventoryLink, settingsLink, PMM_UPDATES_LINK], sortWeight: -800, + showDot: updateAvailable, }; mainLinks.push(pmmcfgNode); } diff --git a/public/app/percona/shared/core/reducers/index.ts b/public/app/percona/shared/core/reducers/index.ts index 96e7f1aebf63..7e7b02b16815 100644 --- a/public/app/percona/shared/core/reducers/index.ts +++ b/public/app/percona/shared/core/reducers/index.ts @@ -21,6 +21,7 @@ import pmmDumpsReducers from './pmmDump/pmmDump'; import rolesReducers from './roles/roles'; import servicesReducer from './services'; import tourReducer from './tour/tour'; +import updatesReducers from './updates'; import perconaUserReducers from './user/user'; import usersReducers from './users/users'; @@ -216,5 +217,6 @@ export default { users: usersReducers, advisors: advisorsReducers, pmmDumps: pmmDumpsReducers, + updates: updatesReducers, }), }; diff --git a/public/app/percona/shared/core/reducers/updates/index.ts b/public/app/percona/shared/core/reducers/updates/index.ts new file mode 100644 index 000000000000..f711d54a9dd0 --- /dev/null +++ b/public/app/percona/shared/core/reducers/updates/index.ts @@ -0,0 +1,6 @@ +import updatesReducer from './updates'; + +export * from './updates'; +export * from './updates.types'; + +export default updatesReducer; diff --git a/public/app/percona/shared/core/reducers/updates/updates.ts b/public/app/percona/shared/core/reducers/updates/updates.ts new file mode 100644 index 000000000000..966377b8714f --- /dev/null +++ b/public/app/percona/shared/core/reducers/updates/updates.ts @@ -0,0 +1,45 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import { UpdatesService } from 'app/percona/shared/services/updates'; + +import { CheckUpdatesPayload, UpdatesState } from './updates.types'; +import { responseToPayload } from './updates.utils'; + +const initialState: UpdatesState = { + isLoading: false, +}; + +export const updatesSlice = createSlice({ + name: 'updates', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(checkUpdatesAction.pending, () => ({ + ...initialState, + isLoading: true, + })); + + builder.addCase(checkUpdatesAction.fulfilled, (state, { payload }) => ({ + ...state, + ...payload, + isLoading: false, + })); + + builder.addCase(checkUpdatesAction.rejected, () => ({ + ...initialState, + isLoading: false, + })); + }, +}); + +export const checkUpdatesAction = createAsyncThunk('percona/checkUpdates', async (): Promise => { + try { + const res = await UpdatesService.getCurrentVersion({ force: true }); + return responseToPayload(res); + } catch (error) { + const res = await UpdatesService.getCurrentVersion({ force: true, only_installed_version: true }); + return responseToPayload(res); + } +}); + +export default updatesSlice.reducer; diff --git a/public/app/percona/shared/core/reducers/updates/updates.types.ts b/public/app/percona/shared/core/reducers/updates/updates.types.ts new file mode 100644 index 000000000000..5c6fb31a09cc --- /dev/null +++ b/public/app/percona/shared/core/reducers/updates/updates.types.ts @@ -0,0 +1,28 @@ +export interface CurrentInformation { + version?: string; + fullVersion?: string; + timestamp?: string; +} + +export interface LatestInformation { + version?: string; + tag?: string; + timestamp?: string; +} + +export interface UpdatesState { + isLoading: boolean; + updateAvailable?: boolean; + installed?: CurrentInformation; + latest?: LatestInformation; + latestNewsUrl?: string; + lastChecked?: string; +} + +export interface CheckUpdatesPayload { + installed?: CurrentInformation; + latest?: LatestInformation; + latestNewsUrl?: string; + lastChecked?: string; + updateAvailable: boolean; +} diff --git a/public/app/percona/shared/core/reducers/updates/updates.utils.ts b/public/app/percona/shared/core/reducers/updates/updates.utils.ts new file mode 100644 index 000000000000..10dd6c6756a7 --- /dev/null +++ b/public/app/percona/shared/core/reducers/updates/updates.utils.ts @@ -0,0 +1,23 @@ +import { CheckUpdatesResponse } from 'app/percona/shared/services/updates/Updates.types'; + +import { CheckUpdatesPayload } from './updates.types'; + +export const responseToPayload = (response: CheckUpdatesResponse): CheckUpdatesPayload => ({ + installed: response.installed + ? { + version: response.installed.version, + fullVersion: response.installed.full_version, + timestamp: response.installed.timestamp, + } + : undefined, + latest: response.latest + ? { + version: response.latest.version, + tag: response.latest.tag, + timestamp: response.latest.timestamp, + } + : undefined, + lastChecked: response.last_check, + latestNewsUrl: response.latest_news_url, + updateAvailable: !!response.update_available, +}); diff --git a/public/app/percona/shared/core/selectors.ts b/public/app/percona/shared/core/selectors.ts index e93f904270bc..e74d1bd502b5 100644 --- a/public/app/percona/shared/core/selectors.ts +++ b/public/app/percona/shared/core/selectors.ts @@ -25,3 +25,4 @@ export const getCategorizedAdvisors = createSelector([getAdvisors], (advisors) = groupAdvisorsIntoCategories(advisors.result || []) ); export const getDumps = (state: StoreState) => state.percona.pmmDumps; +export const getUpdatesInfo = (state: StoreState) => state.percona.updates; diff --git a/public/app/percona/shared/services/updates/Updates.service.ts b/public/app/percona/shared/services/updates/Updates.service.ts new file mode 100644 index 000000000000..6fedae059277 --- /dev/null +++ b/public/app/percona/shared/services/updates/Updates.service.ts @@ -0,0 +1,8 @@ +import { api } from '../../helpers/api'; + +import { CheckUpdatesBody, CheckUpdatesResponse } from './Updates.types'; + +export const UpdatesService = { + getCurrentVersion: (body: CheckUpdatesBody = { force: false }) => + api.post('/v1/Updates/Check', body, true), +}; diff --git a/public/app/percona/shared/services/updates/Updates.types.ts b/public/app/percona/shared/services/updates/Updates.types.ts new file mode 100644 index 000000000000..3fcbdf80a786 --- /dev/null +++ b/public/app/percona/shared/services/updates/Updates.types.ts @@ -0,0 +1,24 @@ +export interface CheckUpdatesBody { + force: boolean; + only_installed_version?: boolean; +} + +export interface CurrentInfo { + version?: string; + full_version?: string; + timestamp?: string; +} + +export interface LatestInfo { + version?: string; + tag?: string; + timestamp?: string; +} + +export interface CheckUpdatesResponse { + installed?: CurrentInfo; + latest?: LatestInfo; + update_available?: boolean; + latest_news_url?: string; + last_check?: string; +} diff --git a/public/app/percona/shared/services/updates/index.ts b/public/app/percona/shared/services/updates/index.ts new file mode 100644 index 000000000000..938abb123ca7 --- /dev/null +++ b/public/app/percona/shared/services/updates/index.ts @@ -0,0 +1 @@ +export { UpdatesService } from './Updates.service';