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 0b22adf67c83..cd4b2fd301b9 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 { logger } from '../../helpers/logger';
import { isPmmAdmin } from '../../helpers/permissions';
@@ -82,6 +83,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 dde3e7dc0bc6..abc42f357c2e 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 9b1e745f3971..ca40764796fd 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';