Rares/grafana9.3 navbar latest (#758)
* new nav changes in progress * navbar changes * navbar * working navbar * got rid of deprecated usages from @grafana/* * removed duplicated headers * navbar changes * path fix for links * old navbar support through navbarRootFallback * alignment * minor changes * breadcrumb fix * header * tabs and header for legacy navigation * nav fix * more fixes :) * refactored sass rule * docker file * docker source image * eslint added ^plugin to settings * docker host * fix * test fix * bring back team selector * cleanup * linter fix * navbar chatops changes * navbar * added component to display header in legacy navbar * fixed headings * linter changes * default route * navbar class fallback * permission checks for viewing cloud/live settings * fixed styling for legacy * linter + docker * legacy handling of hideFromTabs * init tabs logic * renamed to isTopNavbar * refactor * some refactoring * fix deprecated query usage * temporarily disable test for webhooks * reverted docker file to original content
This commit is contained in:
parent
f9a9c1d978
commit
83f281ca37
77 changed files with 2053 additions and 2238 deletions
|
|
@ -6,7 +6,7 @@ module.exports = {
|
|||
plugins: ['rulesdir', 'import'],
|
||||
settings: {
|
||||
'import/internal-regex':
|
||||
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils',
|
||||
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils|^plugin',
|
||||
},
|
||||
rules: {
|
||||
eqeqeq: 'warn',
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ module.exports = {
|
|||
'^jest$': '<rootDir>/src/jest',
|
||||
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
|
||||
'^lodash-es$': 'lodash',
|
||||
"^.+\\.svg$": "<rootDir>/src/jest/svgTransform.ts"
|
||||
},
|
||||
};
|
||||
|
|
@ -54,12 +54,12 @@
|
|||
"@babel/preset-env": "^7.18.10",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@grafana/data": "9.1.1",
|
||||
"@grafana/data": "^9.2.4",
|
||||
"@grafana/eslint-config": "^5.0.0",
|
||||
"@grafana/runtime": "9.1.1",
|
||||
"@grafana/toolkit": "9.1.1",
|
||||
"@grafana/ui": "9.1.1",
|
||||
"@jest/globals": "27.5.1",
|
||||
"@grafana/runtime": "^9.2.4",
|
||||
"@grafana/toolkit": "^9.2.4",
|
||||
"@grafana/ui": "^9.2.4",
|
||||
"@jest/globals": "^27.5.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "12",
|
||||
"@types/dompurify": "^2.3.4",
|
||||
|
|
|
|||
28
grafana-plugin/src/PluginPage.tsx
Normal file
28
grafana-plugin/src/PluginPage.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
import { PluginPageProps, PluginPage as RealPluginPage } from '@grafana/runtime';
|
||||
import Header from 'navbar/Header/Header';
|
||||
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { useQueryParams } from 'utils/hooks';
|
||||
|
||||
export const PluginPage = (isTopNavbar() ? RealPlugin : PluginPageFallback) as React.ComponentType<PluginPageProps>;
|
||||
|
||||
function RealPlugin(props: PluginPageProps): React.ReactNode {
|
||||
const store = useStore();
|
||||
|
||||
const queryParams = useQueryParams();
|
||||
const page = queryParams.get('page');
|
||||
|
||||
return (
|
||||
<RealPluginPage {...props}>
|
||||
<Header page={page} backendLicense={store.backendLicense} />
|
||||
{props.children}
|
||||
</RealPluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPageFallback(props: PluginPageProps): React.ReactNode {
|
||||
return props.children;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import gitHubStarSVG from 'assets/img/github_star.svg';
|
||||
import { APP_SUBTITLE, GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
|
||||
import styles from './NavBarSubtitle.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
function NavBarSubtitle({ backendLicense }: { backendLicense: string }) {
|
||||
if (backendLicense === GRAFANA_LICENSE_OSS) {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
{APP_SUBTITLE}
|
||||
<Card heading={undefined} className={cx('navbar-heading')}>
|
||||
<a href="https://github.com/grafana/oncall" className={cx('navbar-link')} target="_blank" rel="noreferrer">
|
||||
<img src={gitHubStarSVG} className={cx('navbar-star-icon')} alt="" /> Star us on GitHub
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{APP_SUBTITLE}</>;
|
||||
}
|
||||
|
||||
export default NavBarSubtitle;
|
||||
|
|
@ -32,23 +32,26 @@ export default function PageErrorHandlingWrapper({
|
|||
itemNotFoundMessage,
|
||||
children,
|
||||
}: {
|
||||
errorData: PageErrorData;
|
||||
objectName: string;
|
||||
errorData?: PageErrorData;
|
||||
objectName?: string;
|
||||
pageName: string;
|
||||
itemNotFoundMessage?: string;
|
||||
children: () => JSX.Element;
|
||||
}) {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!errorData) {
|
||||
return;
|
||||
}
|
||||
const { isWrongTeamError, isNotFoundError } = errorData;
|
||||
if (!isWrongTeamError && isNotFoundError && itemNotFoundMessage) {
|
||||
openWarningNotification(itemNotFoundMessage);
|
||||
}
|
||||
}, [errorData.isNotFoundError]);
|
||||
}, [errorData?.isNotFoundError]);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
if (!errorData.isWrongTeamError) {
|
||||
return children();
|
||||
if (!errorData || !errorData.isWrongTeamError) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const currentTeamId = store.userStore.currentUser?.current_team;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
import React, { useCallback, FC } from 'react';
|
||||
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { LocationUpdate } from '@grafana/runtime/services/LocationSrv';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import cn from 'classnames/bind';
|
||||
import qs from 'query-string';
|
||||
|
||||
import { PLUGIN_URL_PATH } from 'pages';
|
||||
|
||||
import styles from './PluginLink.module.css';
|
||||
|
||||
interface PluginLinkProps extends LocationUpdate {
|
||||
interface PluginLinkProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
wrap?: boolean;
|
||||
children: any;
|
||||
partial?: boolean;
|
||||
path?: string;
|
||||
query?: Record<string, any>;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const PluginLink: FC<PluginLinkProps> = (props) => {
|
||||
const { children, partial = false, path = '/a/grafana-oncall-app/', query, disabled, className, wrap = true } = props;
|
||||
const { children, partial = false, path = PLUGIN_URL_PATH, query, disabled, className, wrap = true } = props;
|
||||
|
||||
const href = `${path}?${qs.stringify(query)}`;
|
||||
const href = `${path}/?${qs.stringify(query)}`;
|
||||
|
||||
const onClickCallback = useCallback(
|
||||
(event) => {
|
||||
|
|
@ -30,7 +34,15 @@ const PluginLink: FC<PluginLinkProps> = (props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
!disabled && getLocationSrv().update({ partial, path, query });
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (partial) {
|
||||
locationService.partial(query);
|
||||
} else {
|
||||
locationService.push(href);
|
||||
}
|
||||
},
|
||||
[children]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
.root {
|
||||
margin-top: -24px;
|
||||
}
|
||||
|
||||
.root .alerts_horizontal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
flex-direction: column;
|
||||
|
||||
.root .alert {
|
||||
margin: 24px 0;
|
||||
.alerts_horizontal {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- GRAFANA UI TUNINGS --- */
|
||||
|
|
@ -18,10 +18,9 @@ import { getItem, setItem } from 'utils/localStorage';
|
|||
import sanitize from 'utils/sanitize';
|
||||
|
||||
import { getSlackMessage } from './DefaultPageLayout.helpers';
|
||||
import styles from './DefaultPageLayout.module.scss';
|
||||
import { SlackError } from './DefaultPageLayout.types';
|
||||
|
||||
import styles from './DefaultPageLayout.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface DefaultPageLayoutProps extends AppRootProps {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
position: absolute;
|
||||
padding: 16px 0;
|
||||
margin-right: 24px;
|
||||
|
||||
&--topRight {
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
}
|
||||
&--topRightIncident {
|
||||
right: 32px;
|
||||
top: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.teamSelectLabel {
|
||||
|
|
@ -11,8 +20,7 @@
|
|||
}
|
||||
|
||||
.teamSelectLink {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.teamSelectInfo {
|
||||
|
|
@ -9,10 +9,11 @@ import PluginLink from 'components/PluginLink/PluginLink';
|
|||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
||||
import styles from './GrafanaTeamSelect.module.css';
|
||||
import styles from './GrafanaTeamSelect.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
|
|
@ -47,34 +48,37 @@ const GrafanaTeamSelect = observer((props: GrafanaTeamSelectProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={cx('teamSelect', { 'teamSelect--topRight': isTopNavbar() })}>
|
||||
<div className={cx('teamSelectLabel')}>
|
||||
<Label>
|
||||
Select Team{' '}
|
||||
<Tooltip content="The objects on this page are filtered by team and you can only view the objects that belong to your team. Note that filtering within Grafana OnCall is meant for usability, not access management.">
|
||||
<Icon name="info-circle" size="md" className={cx('teamSelectInfo')}></Icon>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<WithPermissionControl userAction={UserAction.UpdateTeams}>
|
||||
<PluginLink path="/org/teams" className={cx('teamSelectLink')}>
|
||||
Edit teams
|
||||
</PluginLink>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
<GSelect
|
||||
modelName="grafanaTeamStore"
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Team"
|
||||
className={cx('select', 'control')}
|
||||
value={user.current_team}
|
||||
onChange={onTeamChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return document.getElementsByClassName('page-header__inner')[0]
|
||||
? ReactDOM.createPortal(
|
||||
<div className={cx('teamSelect')}>
|
||||
<div className={cx('teamSelectLabel')}>
|
||||
<Label>
|
||||
Select Team{' '}
|
||||
<Tooltip content="The objects on this page are filtered by team and you can only view the objects that belong to your team. Note that filtering within Grafana OnCall is meant for usability, not access management.">
|
||||
<Icon name="info-circle" size="md" className={cx('teamSelectInfo')}></Icon>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<WithPermissionControl userAction={UserAction.UpdateTeams}>
|
||||
<PluginLink path="/org/teams" className={cx('teamSelectLink')}>
|
||||
Edit teams
|
||||
</PluginLink>
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
<GSelect
|
||||
modelName="grafanaTeamStore"
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Team"
|
||||
className={cx('select', 'control')}
|
||||
value={user.current_team}
|
||||
onChange={onTeamChange}
|
||||
/>
|
||||
</div>,
|
||||
document.getElementsByClassName('page-header__inner')[0]
|
||||
)
|
||||
? ReactDOM.createPortal(content, document.getElementsByClassName('page-header__inner')[0])
|
||||
: isTopNavbar()
|
||||
? content
|
||||
: null;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,35 @@
|
|||
/* Navigation/Layout */
|
||||
|
||||
.page-body {
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
||||
.oncall-header {
|
||||
padding-top: 0;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.scrollbar-view [class*='-page-header'] {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.page-container.page-body {
|
||||
flex-grow: 1 !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: unset !important;
|
||||
flex-grow: unset !important;
|
||||
flex-basis: unset !important;
|
||||
}
|
||||
|
||||
.page-scrollbar-content > div:first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
padding-top: 0 !important;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* This is for Grafana 8, remove later */
|
||||
|
|
|
|||
8
grafana-plugin/src/jest/svgTransform.ts
Normal file
8
grafana-plugin/src/jest/svgTransform.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
process() {
|
||||
return { code: 'module.exports = {};' };
|
||||
},
|
||||
getCacheKey() {
|
||||
return 'svgTransform';
|
||||
},
|
||||
};
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { ComponentClass } from 'react';
|
||||
|
||||
import { AppPlugin, AppPluginMeta, AppRootProps, PluginConfigPageProps } from '@grafana/data';
|
||||
import { GrafanaPluginRootPage } from 'GrafanaPluginRootPage';
|
||||
|
||||
import { PluginConfigPage } from 'containers/PluginConfigPage/PluginConfigPage';
|
||||
import { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage';
|
||||
|
||||
import { OnCallAppSettings } from './types';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-star-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
|
@ -13,9 +8,11 @@
|
|||
border: 1px solid var(--gray-9);
|
||||
width: initial;
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
}
|
||||
63
grafana-plugin/src/navbar/Header/Header.tsx
Normal file
63
grafana-plugin/src/navbar/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card } from '@grafana/ui';
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import gitHubStarSVG from 'assets/img/github_star.svg';
|
||||
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
||||
import logo from 'img/logo.svg';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { APP_SUBTITLE, GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export default function Header({ page, backendLicense }: { page: string; backendLicense: string }) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div className={classnames('page-header__inner', { 'oncall-header': isTopNavbar() })}>
|
||||
<span className="page-header__logo">
|
||||
<img className="page-header__img" src={logo} alt="Grafana OnCall" />
|
||||
</span>
|
||||
|
||||
<div className="page-header__info-block">{renderHeading()}</div>
|
||||
|
||||
<GrafanaTeamSelect currentPage={page} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderHeading() {
|
||||
if (backendLicense === GRAFANA_LICENSE_OSS) {
|
||||
return (
|
||||
<div className={cx('heading')}>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<div className="u-flex u-align-items-center">
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
<Card heading={undefined} className={cx('navbar-heading')}>
|
||||
<a
|
||||
href="https://github.com/grafana/oncall"
|
||||
className={cx('navbar-link')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={gitHubStarSVG} className={cx('navbar-star-icon')} alt="" /> Star us on GitHub
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
11
grafana-plugin/src/navbar/LegacyNavHeading.tsx
Normal file
11
grafana-plugin/src/navbar/LegacyNavHeading.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
|
||||
interface LegacyNavHeadingProps {
|
||||
children: JSX.Element;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export default function LegacyNavHeading(props: LegacyNavHeadingProps): JSX.Element {
|
||||
const { show = !isTopNavbar(), children } = props;
|
||||
return show ? children : null;
|
||||
}
|
||||
29
grafana-plugin/src/navbar/LegacyNavTabsBar.tsx
Normal file
29
grafana-plugin/src/navbar/LegacyNavTabsBar.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
|
||||
import { IconName } from '@grafana/data';
|
||||
import { Tab, TabsBar } from '@grafana/ui';
|
||||
|
||||
import { pages } from 'pages';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
export default function LegacyNavTabsBar({ currentPage }: { currentPage: string }): JSX.Element {
|
||||
const store = useStore();
|
||||
|
||||
const navigationPages = Object.keys(pages)
|
||||
.map((page) => pages[page])
|
||||
.filter((page) => (page.hideFromTabsFn ? !page.hideFromTabsFn(store) : !page.hideFromTabs));
|
||||
|
||||
return (
|
||||
<TabsBar>
|
||||
{navigationPages.map((page, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
icon={page.icon as IconName}
|
||||
label={page.text}
|
||||
href={page.path}
|
||||
active={currentPage === page.id}
|
||||
/>
|
||||
))}
|
||||
</TabsBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { Tabs, TabsContent } from 'pages/chat-ops/parts';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import { ChatOpsTab } from './ChatOps.types';
|
||||
|
||||
import styles from './ChatOps.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface MessengersPageProps extends WithStoreProps {}
|
||||
|
||||
interface MessengersPageState {
|
||||
activeTab: ChatOpsTab;
|
||||
}
|
||||
|
||||
@observer
|
||||
class ChatOpsPage extends React.Component<MessengersPageProps, MessengersPageState> {
|
||||
state: MessengersPageState = {
|
||||
activeTab: ChatOpsTab.Slack,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('tabs')}>
|
||||
<Tabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab: ChatOpsTab) => {
|
||||
this.setState({ activeTab: tab });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx('content')}>
|
||||
<TabsContent activeTab={activeTab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(ChatOpsPage);
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export enum ChatOpsTab {
|
||||
Slack = 'Slack',
|
||||
Telegram = 'Telegram',
|
||||
}
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -26,6 +27,7 @@ import EscalationChainForm from 'containers/EscalationChainForm/EscalationChainF
|
|||
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
|
||||
import { pages } from 'pages';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -134,13 +136,13 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
const searchResult = escalationChainStore.getSearchResult(escalationChainsFilters.searchTerm);
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="escalation"
|
||||
pageName="escalations"
|
||||
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['escalations'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="escalation"
|
||||
pageName="escalations"
|
||||
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
|
|
@ -214,8 +216,8 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.incident-row {
|
||||
display: flex;
|
||||
}
|
||||
|
|
@ -11,7 +7,7 @@
|
|||
}
|
||||
|
||||
.payload-subtitle {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Modal,
|
||||
Tooltip,
|
||||
} from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
|
|
@ -47,6 +48,8 @@ import {
|
|||
GroupedAlert,
|
||||
} from 'models/alertgroup/alertgroup.types';
|
||||
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -94,10 +97,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
update = () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
store.alertGroupStore
|
||||
.getAlert(id)
|
||||
|
|
@ -105,10 +106,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
store,
|
||||
query: { id, cursor, start, perpage },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id, cursor, start, perpage } = getQueryParams();
|
||||
|
||||
const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state;
|
||||
const { isNotFoundError, isWrongTeamError } = errorData;
|
||||
|
|
@ -126,10 +125,10 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
}
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper errorData={errorData} objectName="alert group" pageName="incidents">
|
||||
{() =>
|
||||
errorData.isNotFoundError ? (
|
||||
<div className={cx('root')}>
|
||||
<PluginPage pageNav={pages['incident'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper errorData={errorData} objectName="alert group" pageName="incidents">
|
||||
<div className={cx('root')}>
|
||||
{errorData.isNotFoundError ? (
|
||||
<div className={cx('not-found')}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
|
|
@ -141,10 +140,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
) : (
|
||||
<>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
|
|
@ -157,49 +154,47 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
</div>
|
||||
<div className={cx('column')}>{this.renderTimeline()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
|
||||
}}
|
||||
onUpdateTemplates={() => {
|
||||
store.alertGroupStore.getAlert(id);
|
||||
}}
|
||||
startTab={IntegrationSettingsTab.Templates}
|
||||
id={incident.alert_receive_channel.id}
|
||||
onHide={() =>
|
||||
this.setState({
|
||||
showIntegrationSettings: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showAttachIncidentForm && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</PageErrorHandlingWrapper>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
|
||||
}}
|
||||
onUpdateTemplates={() => {
|
||||
store.alertGroupStore.getAlert(id);
|
||||
}}
|
||||
startTab={IntegrationSettingsTab.Templates}
|
||||
id={incident.alert_receive_channel.id}
|
||||
onHide={() =>
|
||||
this.setState({
|
||||
showIntegrationSettings: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showAttachIncidentForm && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
renderHeader = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id, cursor, start, perpage },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { id, cursor, start, perpage } = getQueryParams();
|
||||
const { alerts } = store.alertGroupStore;
|
||||
|
||||
const incident = alerts.get(id);
|
||||
|
|
@ -316,11 +311,9 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
renderTimeline = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { id } = getQueryParams();
|
||||
const incident = store.alertGroupStore.alerts.get(id);
|
||||
|
||||
if (!incident.render_after_resolve_report_json) {
|
||||
|
|
@ -408,11 +401,9 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
handleCreateResolutionNote = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { id } = getQueryParams();
|
||||
const { resolutionNoteText } = this.state;
|
||||
store.resolutionNotesStore
|
||||
.createResolutionNote(id, resolutionNoteText)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { ReactElement, SyntheticEvent } from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -12,6 +13,7 @@ import Emoji from 'react-emoji-render';
|
|||
import CursorPagination from 'components/CursorPagination/CursorPagination';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
|
|
@ -21,7 +23,9 @@ import IncidentsFilters from 'containers/IncidentsFilters/IncidentsFilters';
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { Alert, Alert as AlertType, AlertAction } from 'models/alertgroup/alertgroup.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { pages } from 'pages';
|
||||
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { move } from 'state/helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -67,10 +71,8 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
constructor(props: IncidentsPageProps) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { cursor: cursorQuery, start: startQuery, perpage: perpageQuery },
|
||||
} = props;
|
||||
const { store } = props;
|
||||
const { cursor: cursorQuery, start: startQuery, perpage: perpageQuery } = getQueryParams();
|
||||
|
||||
const cursor = cursorQuery || undefined;
|
||||
const start = !isNaN(startQuery) ? Number(startQuery) : 1;
|
||||
|
|
@ -100,10 +102,14 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
|
||||
render() {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
<PluginPage pageNav={pages['incidents'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper pageName="incidents">
|
||||
<div className={cx('root')}>
|
||||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { ButtonCascader } from '@grafana/ui';
|
||||
import { ComponentSize } from '@grafana/ui/types/size';
|
||||
import { ButtonCascader, ComponentSize } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
|
||||
import ChatOpsPage from 'pages/chat-ops/ChatOps';
|
||||
import CloudPage from 'pages/cloud/CloudPage';
|
||||
import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
|
||||
import IncidentPage2 from 'pages/incident/Incident';
|
||||
import IncidentsPage2 from 'pages/incidents/Incidents';
|
||||
import IntegrationsPage2 from 'pages/integrations/Integrations';
|
||||
import LiveSettingsPage from 'pages/livesettings/LiveSettingsPage';
|
||||
import MaintenancePage2 from 'pages/maintenance/Maintenance';
|
||||
import OrganizationLogPage2 from 'pages/organization-logs/OrganizationLog';
|
||||
import OutgoingWebhooks2 from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
import SchedulePage from 'pages/schedule/Schedule';
|
||||
import SchedulesPage from 'pages/schedules/Schedules';
|
||||
import SchedulesPageOld from 'pages/schedules_OLD/Schedules';
|
||||
import SettingsPage2 from 'pages/settings/SettingsPage';
|
||||
import Test from 'pages/test/Test';
|
||||
import UsersPage2 from 'pages/users/Users';
|
||||
|
||||
export type PageDefinition = {
|
||||
component: React.ComponentType<AppRootProps>;
|
||||
icon: string;
|
||||
id: string;
|
||||
text: string;
|
||||
hideFromTabs?: boolean;
|
||||
role?: 'Viewer' | 'Editor' | 'Admin';
|
||||
};
|
||||
|
||||
export const pages: PageDefinition[] = [
|
||||
{
|
||||
component: IncidentsPage2,
|
||||
icon: 'bell',
|
||||
id: 'incidents',
|
||||
text: 'Alert Groups',
|
||||
},
|
||||
{
|
||||
component: IncidentPage2,
|
||||
icon: 'bell',
|
||||
id: 'incident',
|
||||
text: 'Incident',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: UsersPage2,
|
||||
icon: 'users-alt',
|
||||
id: 'users',
|
||||
text: 'Users',
|
||||
},
|
||||
{
|
||||
component: IntegrationsPage2,
|
||||
icon: 'plug',
|
||||
id: 'integrations',
|
||||
text: 'Integrations',
|
||||
},
|
||||
{
|
||||
component: EscalationsChainsPage,
|
||||
icon: 'list-ul',
|
||||
id: 'escalations',
|
||||
text: 'Escalation Chains',
|
||||
},
|
||||
{
|
||||
component: SchedulesPageOld,
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedules-old',
|
||||
text: 'Schedules OLD',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: SchedulesPage,
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedules',
|
||||
text: 'Schedules',
|
||||
},
|
||||
{
|
||||
component: SchedulePage,
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedule',
|
||||
text: 'Schedule',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: ChatOpsPage,
|
||||
icon: 'comments-alt',
|
||||
id: 'chat-ops',
|
||||
text: 'ChatOps',
|
||||
},
|
||||
{
|
||||
component: ChatOpsPage,
|
||||
icon: 'comments-alt',
|
||||
id: 'slack',
|
||||
text: 'ChatOps',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: OutgoingWebhooks2,
|
||||
icon: 'link',
|
||||
id: 'outgoing_webhooks',
|
||||
text: 'Outgoing Webhooks',
|
||||
},
|
||||
{
|
||||
component: MaintenancePage2,
|
||||
icon: 'wrench',
|
||||
id: 'maintenance',
|
||||
text: 'Maintenance',
|
||||
},
|
||||
{
|
||||
component: SettingsPage2,
|
||||
icon: 'cog',
|
||||
id: 'settings',
|
||||
text: 'Settings',
|
||||
},
|
||||
{
|
||||
component: LiveSettingsPage,
|
||||
icon: 'table',
|
||||
id: 'live-settings',
|
||||
text: 'Env Variables',
|
||||
role: 'Admin',
|
||||
},
|
||||
{
|
||||
component: OrganizationLogPage2,
|
||||
icon: 'gf-logs',
|
||||
id: 'organization-logs',
|
||||
text: 'Org Logs',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
{
|
||||
component: CloudPage,
|
||||
icon: 'cloud',
|
||||
id: 'cloud',
|
||||
text: 'Cloud',
|
||||
role: 'Admin',
|
||||
},
|
||||
{
|
||||
component: Test,
|
||||
icon: 'cog',
|
||||
id: 'test',
|
||||
text: 'Test',
|
||||
hideFromTabs: true,
|
||||
},
|
||||
];
|
||||
157
grafana-plugin/src/pages/index.tsx
Normal file
157
grafana-plugin/src/pages/index.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { NavModelItem } from '@grafana/data';
|
||||
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { RootBaseStore } from 'state/rootBaseStore';
|
||||
|
||||
export const PLUGIN_URL_PATH = '/a/grafana-oncall-app';
|
||||
|
||||
export type PageDefinition = {
|
||||
path: string;
|
||||
icon: string;
|
||||
id: string;
|
||||
text: string;
|
||||
hideFromTabsFn?: (store: RootBaseStore) => boolean;
|
||||
hideFromTabs?: boolean;
|
||||
role?: 'Viewer' | 'Editor' | 'Admin';
|
||||
|
||||
getPageNav(): { text: string; description: string };
|
||||
};
|
||||
|
||||
function getPath(name = '') {
|
||||
return `${PLUGIN_URL_PATH}/?page=${name}`;
|
||||
}
|
||||
|
||||
export const pages: { [id: string]: PageDefinition } = [
|
||||
{
|
||||
icon: 'bell',
|
||||
id: 'incidents',
|
||||
hideFromBreadcrumbs: true,
|
||||
text: 'Alert Groups',
|
||||
path: getPath('incidents'),
|
||||
},
|
||||
{
|
||||
icon: 'bell',
|
||||
id: 'incident',
|
||||
text: '',
|
||||
hideFromTabs: true,
|
||||
hideFromBreadcrumbs: true,
|
||||
parentItem: { text: 'Incident' },
|
||||
path: getPath('incident/:id?'),
|
||||
},
|
||||
{
|
||||
icon: 'users-alt',
|
||||
id: 'users',
|
||||
hideFromBreadcrumbs: true,
|
||||
text: 'Users',
|
||||
path: getPath('users'),
|
||||
},
|
||||
{
|
||||
icon: 'plug',
|
||||
id: 'integrations',
|
||||
path: getPath('integrations'),
|
||||
hideFromBreadcrumbs: true,
|
||||
text: 'Integrations',
|
||||
},
|
||||
{
|
||||
icon: 'list-ul',
|
||||
id: 'escalations',
|
||||
text: 'Escalation Chains',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('escalations'),
|
||||
},
|
||||
{
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedules',
|
||||
text: 'Schedules',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('schedules'),
|
||||
},
|
||||
{
|
||||
icon: 'calendar-alt',
|
||||
id: 'schedule',
|
||||
text: '',
|
||||
parentItem: { text: 'Schedule' },
|
||||
hideFromBreadcrumbs: true,
|
||||
hideFromTabs: true,
|
||||
path: getPath('schedule/:id?'),
|
||||
},
|
||||
{
|
||||
icon: 'comments-alt',
|
||||
id: 'chat-ops',
|
||||
text: 'ChatOps',
|
||||
path: getPath('chat-ops'),
|
||||
hideFromBreadcrumbs: true,
|
||||
hideFromTabs: isTopNavbar(),
|
||||
},
|
||||
{
|
||||
icon: 'link',
|
||||
id: 'outgoing_webhooks',
|
||||
text: 'Outgoing Webhooks',
|
||||
path: getPath('outgoing_webhooks'),
|
||||
hideFromBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
icon: 'wrench',
|
||||
id: 'maintenance',
|
||||
text: 'Maintenance',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('maintenance'),
|
||||
},
|
||||
{
|
||||
icon: 'cog',
|
||||
id: 'settings',
|
||||
text: 'Organization Settings',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('settings'),
|
||||
},
|
||||
{
|
||||
icon: 'table',
|
||||
id: 'live-settings',
|
||||
text: 'Env Variables',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
return isTopNavbar() || window.grafanaBootData.user.orgRole !== 'Admin' || !hasLiveSettings;
|
||||
},
|
||||
path: getPath('live-settings'),
|
||||
},
|
||||
{
|
||||
icon: 'cloud',
|
||||
id: 'cloud',
|
||||
text: 'Cloud',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasCloudFeature = store.hasFeature(AppFeature.CloudConnection);
|
||||
return isTopNavbar() || window.grafanaBootData.user.orgRole !== 'Admin' || !hasCloudFeature;
|
||||
},
|
||||
path: getPath('cloud'),
|
||||
},
|
||||
{
|
||||
icon: 'gf-logs',
|
||||
id: 'organization-logs',
|
||||
text: 'Org Logs',
|
||||
hideFromTabs: true,
|
||||
path: getPath('organization-logs'),
|
||||
},
|
||||
{
|
||||
icon: 'cog',
|
||||
id: 'test',
|
||||
text: 'Test',
|
||||
hideFromTabs: true,
|
||||
path: getPath('test'),
|
||||
},
|
||||
].reduce((prev, current) => {
|
||||
prev[current.id] = {
|
||||
...current,
|
||||
getPageNav: () =>
|
||||
({
|
||||
text: isTopNavbar() ? '' : current.text,
|
||||
parentItem: current.parentItem,
|
||||
hideFromBreadcrumbs: current.hideFromBreadcrumbs,
|
||||
hideFromTabs: current.hideFromTabs,
|
||||
} as NavModelItem),
|
||||
};
|
||||
|
||||
return prev;
|
||||
}, {});
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -25,6 +26,7 @@ import { IntegrationSettingsTab } from 'containers/IntegrationSettings/Integrati
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel';
|
||||
import { AlertReceiveChannelOption } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { pages } from 'pages';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -130,13 +132,13 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
const searchResult = alertReceiveChannelStore.getSearchResult();
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="integration"
|
||||
pageName="integrations"
|
||||
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['integrations'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="integration"
|
||||
pageName="integrations"
|
||||
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
|
|
@ -241,8 +243,8 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
|
@ -10,3 +6,7 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { Button, HorizontalGroup } from '@grafana/ui';
|
||||
import { Button, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
|
|
@ -15,6 +17,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { getAlertReceiveChannelDisplayName } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Maintenance, MaintenanceMode, MaintenanceType } from 'models/maintenance/maintenance.types';
|
||||
import { pages } from 'pages';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -115,19 +118,21 @@ class MaintenancePage extends React.Component<MaintenancePageProps, MaintenanceP
|
|||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginPage pageNav={pages['maintenance'].getPageNav()}>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={data ? 'No maintenances found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<HorizontalGroup>
|
||||
<Text.Title level={3}>Maintenance</Text.Title>
|
||||
<Text type="secondary">
|
||||
<VerticalGroup>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Maintenance</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary" className={cx('title')}>
|
||||
Mute noisy sources or use for debugging and avoid bothering your colleagues.
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
<WithPermissionControl userAction={UserAction.UpdateMaintenances}>
|
||||
<Button
|
||||
|
|
@ -156,7 +161,7 @@ class MaintenancePage extends React.Component<MaintenancePageProps, MaintenanceP
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Tag, Tooltip } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -95,39 +96,41 @@ class OrganizationLogPage extends React.Component<OrganizationLogProps, Organiza
|
|||
const loading = !results;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<OrganizationLogFilters value={filters} onChange={this.handleChangeOrganizationLogFilters} />
|
||||
<GTable
|
||||
rowKey="id"
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title className={cx('users-title')} level={3}>
|
||||
Organization Logs
|
||||
</Text.Title>
|
||||
<Button onClick={this.refresh} icon={loading ? 'fa fa-spinner' : 'sync'}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
showHeader={true}
|
||||
data={results}
|
||||
loading={loading}
|
||||
emptyText={results ? 'No logs found' : 'Loading...'}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((total || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
rowClassName={cx('align-top')}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderFullDescription,
|
||||
expandRowByClick: true,
|
||||
expandedRowKeys: expandedLogsKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<PluginPage>
|
||||
<div className={cx('root')}>
|
||||
<OrganizationLogFilters value={filters} onChange={this.handleChangeOrganizationLogFilters} />
|
||||
<GTable
|
||||
rowKey="id"
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title className={cx('users-title')} level={3}>
|
||||
Organization Logs
|
||||
</Text.Title>
|
||||
<Button onClick={this.refresh} icon={loading ? 'fa fa-spinner' : 'sync'}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
showHeader={true}
|
||||
data={results}
|
||||
loading={loading}
|
||||
emptyText={results ? 'No logs found' : 'Loading...'}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((total || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
rowClassName={cx('align-top')}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderFullDescription,
|
||||
expandRowByClick: true,
|
||||
expandedRowKeys: expandedLogsKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import outgoingWebhooksStub from 'jest/outgoingWebhooksStub';
|
||||
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { OutgoingWebhooks } from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
|
||||
const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[];
|
||||
const outgoingWebhookStore = () => ({
|
||||
loadItem: () => Promise.resolve(outgoingWebhooks[0]),
|
||||
updateItems: () => Promise.resolve(),
|
||||
getSearchResult: () => outgoingWebhooks,
|
||||
items: outgoingWebhooks.reduce((prev, current) => {
|
||||
prev[current.id] = current;
|
||||
return prev;
|
||||
}, {}),
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
featureToggles: {
|
||||
topNav: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('state/useStore', () => ({
|
||||
useStore: () => ({
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getLocationSrv: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('OutgoingWebhooks', () => {
|
||||
const storeMock = {
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
});
|
||||
|
||||
test('It renders all retrieved webhooks', async () => {
|
||||
render(<OutgoingWebhooks {...getProps()} />);
|
||||
|
||||
const gTable = screen.queryByTestId('test__gTable');
|
||||
const rows = gTable.querySelectorAll('tbody tr');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => queryEditForm()).toThrow(); // edit doesn't show for [id=undefined]
|
||||
expect(rows.length).toBe(outgoingWebhooks.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('It opens Edit View if [id] is supplied', async () => {
|
||||
const id = outgoingWebhooks[0].id;
|
||||
render(<OutgoingWebhooks {...getProps(id)} />);
|
||||
|
||||
expect(() => queryEditForm()).toThrow(); // before updates kick in
|
||||
await waitFor(() => {
|
||||
expect(queryEditForm()).toBeDefined(); // edit shows for [id=?]
|
||||
});
|
||||
});
|
||||
|
||||
function getProps(id: OutgoingWebhook['id'] = undefined): any {
|
||||
return { store: storeMock, query: { id } };
|
||||
}
|
||||
|
||||
function queryEditForm(): HTMLElement {
|
||||
return screen.getByTestId<HTMLElement>('test__outgoingWebhookEditForm');
|
||||
}
|
||||
});
|
||||
|
|
@ -3,8 +3,10 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
|
|
@ -19,6 +21,8 @@ import OutgoingWebhookForm from 'containers/OutgoingWebhookForm/OutgoingWebhookF
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { ActionDTO } from 'models/action';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -39,12 +43,14 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
private outgoingWebhookId: string;
|
||||
|
||||
async componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: OutgoingWebhooksProps) {
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
componentDidUpdate() {
|
||||
if (this.outgoingWebhookId !== getQueryParams()['id']) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
|
@ -55,10 +61,10 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
outgoingWebhookIdToEdit: undefined,
|
||||
})); // reset state on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
this.outgoingWebhookId = id;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
|
|
@ -109,31 +115,35 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
];
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="outgoing webhook"
|
||||
pageName="outgoing_webhooks"
|
||||
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['outgoing_webhooks'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="outgoing webhook"
|
||||
pageName="outgoing_webhooks"
|
||||
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<div className="u-pull-right">
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
|
|
@ -149,8 +159,8 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
108
grafana-plugin/src/pages/routes.tsx
Normal file
108
grafana-plugin/src/pages/routes.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
|
||||
import { PageDefinition } from 'pages';
|
||||
import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
|
||||
import IncidentPage from 'pages/incident/Incident';
|
||||
import IncidentsPage from 'pages/incidents/Incidents';
|
||||
import IntegrationsPage from 'pages/integrations/Integrations';
|
||||
import MaintenancePage from 'pages/maintenance/Maintenance';
|
||||
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
|
||||
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
import SchedulePage from 'pages/schedule/Schedule';
|
||||
import SchedulesPage from 'pages/schedules/Schedules';
|
||||
import SettingsPage from 'pages/settings/SettingsPage';
|
||||
import ChatOpsPage from 'pages/settings/tabs/ChatOps/ChatOps';
|
||||
import CloudPage from 'pages/settings/tabs/Cloud/CloudPage';
|
||||
import LiveSettingsPage from 'pages/settings/tabs/LiveSettings/LiveSettingsPage';
|
||||
import Test from 'pages/test/Test';
|
||||
import UsersPage from 'pages/users/Users';
|
||||
|
||||
export interface NavMenuItem {
|
||||
meta: AppRootProps['meta'];
|
||||
pages: { [id: string]: PageDefinition };
|
||||
path: string;
|
||||
page: string;
|
||||
grafanaUser: {
|
||||
orgRole: 'Viewer' | 'Editor' | 'Admin';
|
||||
};
|
||||
enableLiveSettings: boolean;
|
||||
enableCloudPage: boolean;
|
||||
enableNewSchedulesPage: boolean;
|
||||
backendLicense: string;
|
||||
onNavChanged: any;
|
||||
}
|
||||
|
||||
export interface NavRoute {
|
||||
id: string;
|
||||
component: (props?: any) => JSX.Element;
|
||||
}
|
||||
|
||||
export const routes: { [id: string]: NavRoute } = [
|
||||
{
|
||||
component: IncidentsPage,
|
||||
id: 'incidents',
|
||||
},
|
||||
{
|
||||
component: IncidentPage,
|
||||
id: 'incident',
|
||||
},
|
||||
{
|
||||
component: UsersPage,
|
||||
id: 'users',
|
||||
},
|
||||
{
|
||||
component: IntegrationsPage,
|
||||
id: 'integrations',
|
||||
},
|
||||
{
|
||||
component: EscalationsChainsPage,
|
||||
id: 'escalations',
|
||||
},
|
||||
{
|
||||
component: SchedulesPage,
|
||||
id: 'schedules',
|
||||
},
|
||||
{
|
||||
component: SchedulePage,
|
||||
id: 'schedule',
|
||||
},
|
||||
{
|
||||
component: ChatOpsPage,
|
||||
id: 'chat-ops',
|
||||
},
|
||||
{
|
||||
component: OutgoingWebhooks,
|
||||
id: 'outgoing_webhooks',
|
||||
},
|
||||
{
|
||||
component: MaintenancePage,
|
||||
id: 'maintenance',
|
||||
},
|
||||
{
|
||||
component: SettingsPage,
|
||||
id: 'settings',
|
||||
},
|
||||
{
|
||||
component: LiveSettingsPage,
|
||||
id: 'live-settings',
|
||||
},
|
||||
{
|
||||
component: OrganizationLogPage,
|
||||
id: 'organization-logs',
|
||||
},
|
||||
{
|
||||
component: CloudPage,
|
||||
id: 'cloud',
|
||||
},
|
||||
{
|
||||
component: Test,
|
||||
id: 'test',
|
||||
},
|
||||
].reduce((prev, current) => {
|
||||
prev[current.id] = {
|
||||
id: current.id,
|
||||
component: current.component,
|
||||
};
|
||||
|
||||
return prev;
|
||||
}, {});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
.root {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
margin-top: 24px;
|
||||
|
||||
--rotations-border: var(--border-weak);
|
||||
--rotations-background: var(--background-secondary);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
|||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, Modal, ToolbarButton, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { omit } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import Text from 'components/Text/Text';
|
||||
|
|
@ -21,6 +23,8 @@ import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink';
|
|||
import UsersTimezones from 'containers/UsersTimezones/UsersTimezones';
|
||||
import { Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -64,9 +68,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
const {
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
store.userStore.updateItems();
|
||||
|
||||
|
|
@ -85,10 +87,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
query: { id: scheduleId },
|
||||
store,
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id: scheduleId } = getQueryParams();
|
||||
|
||||
const {
|
||||
startMoment,
|
||||
|
||||
|
|
@ -110,143 +111,149 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
shiftIdToShowOverridesForm;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect value={currentTimezone} users={users} onChange={this.handleTimezoneChange} />
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
{schedule?.type === ScheduleType.Ical && (
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
onClick={() => {
|
||||
this.setState({ showEditForm: true });
|
||||
}}
|
||||
/>
|
||||
<WithConfirm>
|
||||
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
|
||||
</WithConfirm>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
{schedule?.type !== ScheduleType.API && (
|
||||
<Text className={cx('desc')} type="secondary">
|
||||
Ical and API/Terraform schedules are read-only
|
||||
</Text>
|
||||
)}
|
||||
<div className={cx('users-timezones')}>
|
||||
<UsersTimezones
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
onCallNow={schedule?.on_call_now || []}
|
||||
userIds={
|
||||
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
|
||||
}
|
||||
tz={currentTimezone}
|
||||
onTzChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx('rotations')}>
|
||||
<div className={cx('controls')}>
|
||||
<PluginPage pageNav={pages['schedule'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper pageName="schedules">
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleTodayClick}>
|
||||
Today
|
||||
</Button>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button variant="secondary" onClick={this.handleLeftClick}>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleRightClick}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect
|
||||
value={currentTimezone}
|
||||
users={users}
|
||||
onChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
{schedule?.type === ScheduleType.Ical && (
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
onClick={() => {
|
||||
this.setState({ showEditForm: true });
|
||||
}}
|
||||
/>
|
||||
<WithConfirm>
|
||||
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
|
||||
</WithConfirm>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
{showEditForm && (
|
||||
<ScheduleForm
|
||||
id={schedule.id}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ showEditForm: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
{schedule?.type !== ScheduleType.API && (
|
||||
<Text className={cx('desc')} type="secondary">
|
||||
Ical and API/Terraform schedules are read-only
|
||||
</Text>
|
||||
)}
|
||||
<div className={cx('users-timezones')}>
|
||||
<UsersTimezones
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
onCallNow={schedule?.on_call_now || []}
|
||||
userIds={
|
||||
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
|
||||
}
|
||||
tz={currentTimezone}
|
||||
onTzChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx('rotations')}>
|
||||
<div className={cx('controls')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleTodayClick}>
|
||||
Today
|
||||
</Button>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button variant="secondary" onClick={this.handleLeftClick}>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleRightClick}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
</Text.Title>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
{showEditForm && (
|
||||
<ScheduleForm
|
||||
id={schedule.id}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ showEditForm: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -292,10 +299,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
updateEvents = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id: scheduleId },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id: scheduleId } = getQueryParams();
|
||||
|
||||
const { startMoment } = this.state;
|
||||
|
||||
|
|
@ -419,10 +424,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
updateEventsFor = async (scheduleId: Schedule['id'], withEmpty = true, with_gap = true) => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
|
|
@ -441,10 +444,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
handleDelete = () => {
|
||||
const {
|
||||
store,
|
||||
query: { id: scheduleId },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id: scheduleId } = getQueryParams();
|
||||
|
||||
store.scheduleStore.delete(scheduleId).then(() => {
|
||||
getLocationSrv().update({ query: { page: 'schedules' } });
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.root .buttons {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
|
@ -25,6 +26,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
|
||||
import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { pages } from 'pages';
|
||||
import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -133,7 +135,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginPage pageNav={pages['schedules'].getPageNav()}>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup justify="space-between">
|
||||
|
|
@ -190,7 +192,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import moment from 'moment-timezone';
|
||||
|
||||
import { Schedule } from 'models/schedule/schedule.types';
|
||||
|
||||
const DATE_FORMAT = 'HH:mm YYYY-MM-DD';
|
||||
|
||||
function isToday(m: moment.Moment) {
|
||||
return m.isSame('day');
|
||||
}
|
||||
|
||||
function isYesterday(m: moment.Moment, currentMoment: moment.Moment) {
|
||||
return m.diff(currentMoment, 'days') === -1;
|
||||
}
|
||||
|
||||
function isTomorrow(m: moment.Moment, currentMoment: moment.Moment) {
|
||||
return m.diff(currentMoment, 'days') === 1;
|
||||
}
|
||||
|
||||
export function prepareForEdit(schedule: Schedule) {
|
||||
return {
|
||||
...schedule,
|
||||
slack_channel_id: schedule.slack_channel?.id,
|
||||
user_group: schedule.user_group?.id,
|
||||
};
|
||||
}
|
||||
|
||||
function humanize(m: moment.Moment, currentMoment: moment.Moment) {
|
||||
if (isToday(m)) {
|
||||
return 'Today';
|
||||
}
|
||||
if (isYesterday(m, currentMoment)) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
if (isTomorrow(m, currentMoment)) {
|
||||
return 'Tomorrow';
|
||||
}
|
||||
|
||||
return m.format(DATE_FORMAT);
|
||||
}
|
||||
|
||||
export function getDatesString(start: string, end: string, allDay: boolean) {
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
const currentMoment = moment();
|
||||
|
||||
if (allDay) {
|
||||
if (startMoment.isSame(endMoment, 'day')) {
|
||||
return 'All-day';
|
||||
}
|
||||
|
||||
return `${startMoment.format(DATE_FORMAT)} — ${endMoment.format(DATE_FORMAT)}`;
|
||||
}
|
||||
|
||||
if (startMoment.isSame(endMoment, 'day')) {
|
||||
return `${startMoment.format('LT')} — ${endMoment.format('LT')}`;
|
||||
}
|
||||
|
||||
let startString = humanize(startMoment, currentMoment);
|
||||
|
||||
let endString = humanize(endMoment, currentMoment);
|
||||
|
||||
return `${startString} — ${endString}`;
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 50%;
|
||||
margin: 20px auto;
|
||||
white-space: break-spaces;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events {
|
||||
margin: 16px 32px;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.events-list-item {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.priority-icon {
|
||||
width: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--secondary-background);
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gap-between-shifts {
|
||||
width: 520px;
|
||||
padding: 5px 5px 5px 24px;
|
||||
background-color: rgba(209, 14, 92, 0.15);
|
||||
border: 1px solid rgba(209, 14, 92, 0.15);
|
||||
border-radius: 50px;
|
||||
color: #ff5286;
|
||||
font-weight: 400;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
|
@ -1,555 +0,0 @@
|
|||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import {
|
||||
Button,
|
||||
ConfirmModal,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
LoadingPlaceholder,
|
||||
Modal,
|
||||
PENDING_COLOR,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { omit } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import instructionsImage from 'assets/img/events_instructions.png';
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import SchedulesFilters from 'components/SchedulesFilters/SchedulesFilters';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
|
||||
import Text from 'components/Text/Text';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
||||
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
|
||||
import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { Schedule, ScheduleEvent, ScheduleType } from 'models/schedule/schedule.types';
|
||||
import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
|
||||
import { getDatesString } from './Schedules.helpers';
|
||||
|
||||
import styles from './Schedules.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SchedulesPageProps extends WithStoreProps, AppRootProps {}
|
||||
interface SchedulesPageState extends PageBaseState {
|
||||
scheduleIdToEdit?: Schedule['id'];
|
||||
scheduleIdToDelete?: Schedule['id'];
|
||||
scheduleIdToExport?: Schedule['id'];
|
||||
filters: SchedulesFiltersType;
|
||||
expandedSchedulesKeys: Array<Schedule['id']>;
|
||||
}
|
||||
|
||||
@observer
|
||||
class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageState> {
|
||||
state: SchedulesPageState = {
|
||||
filters: {
|
||||
selectedDate: moment().startOf('day').format('YYYY-MM-DD'),
|
||||
},
|
||||
expandedSchedulesKeys: [],
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: SchedulesPageProps) {
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
||||
parseQueryParams = async () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scheduleId: string = undefined;
|
||||
const isNewSchedule = id === 'new';
|
||||
|
||||
if (!isNewSchedule) {
|
||||
// load schedule only for valid id
|
||||
const schedule = await store.scheduleStore
|
||||
.loadItem(id, true)
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
if (!schedule) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleId = schedule.id;
|
||||
}
|
||||
|
||||
if (scheduleId || isNewSchedule) {
|
||||
this.setState({ scheduleIdToEdit: id });
|
||||
} else {
|
||||
openErrorNotification(`Schedule with id=${id} is not found. Please select schedule from the list.`);
|
||||
}
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
const { scheduleStore } = store;
|
||||
|
||||
return scheduleStore.updateItems();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { store, query } = this.props;
|
||||
const { expandedSchedulesKeys, scheduleIdToDelete, scheduleIdToEdit, scheduleIdToExport } = this.state;
|
||||
const { filters, errorData } = this.state;
|
||||
const { scheduleStore } = store;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
render: this.renderType,
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
title: 'OnCall now',
|
||||
render: this.renderOncallNow,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Slack channel',
|
||||
render: this.renderChannelName,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Slack user group',
|
||||
render: this.renderUserGroup,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
key: 'warning',
|
||||
render: this.renderWarning,
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
key: 'action',
|
||||
render: this.renderActionButtons,
|
||||
},
|
||||
];
|
||||
|
||||
const schedules = scheduleStore.getSearchResult();
|
||||
|
||||
const timezoneStr = moment.tz.guess();
|
||||
const offset = moment().tz(timezoneStr).format('Z');
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="schedule"
|
||||
pageName="schedules"
|
||||
itemNotFoundMessage={`Schedule with id=${query?.id} is not found. Please select schedule from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('title')}>
|
||||
<HorizontalGroup align="flex-end">
|
||||
<Text.Title level={3}>On-call Schedules</Text.Title>
|
||||
<Text type="secondary">
|
||||
Use this to distribute notifications among team members you specified in the "Notify Users from
|
||||
on-call schedule" step in{' '}
|
||||
<PluginLink query={{ page: 'integrations' }}>escalation chains</PluginLink>.
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
||||
{!schedules || schedules.length ? (
|
||||
<GTable
|
||||
emptyText={schedules ? 'No schedules found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup className={cx('filters')} spacing="md">
|
||||
<SchedulesFilters value={filters} onChange={this.handleChangeFilters} />
|
||||
<Text type="secondary">
|
||||
<Icon name="info-circle" /> Your timezone is {timezoneStr} UTC{offset}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateSchedules)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
|
||||
<Button variant="primary" icon="plus">
|
||||
New schedule
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={schedules}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderEvents,
|
||||
expandRowByClick: true,
|
||||
onExpand: this.onRowExpand,
|
||||
expandedRowKeys: expandedSchedulesKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tutorial
|
||||
step={TutorialStep.Schedules}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">You haven’t added a schedule yet.</Text>
|
||||
<PluginLink partial query={{ id: 'new' }}>
|
||||
<Button icon="plus" variant="primary" size="lg">
|
||||
Add team schedule for on-call rotation
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scheduleIdToEdit && (
|
||||
<ScheduleForm
|
||||
id={scheduleIdToEdit}
|
||||
type={ScheduleType.Ical}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ scheduleIdToEdit: undefined });
|
||||
getLocationSrv().update({ partial: true, query: { id: undefined } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scheduleIdToDelete && (
|
||||
<ConfirmModal
|
||||
isOpen
|
||||
title="Are you sure to delete?"
|
||||
confirmText="Delete"
|
||||
dismissText="Cancel"
|
||||
onConfirm={this.handleDelete}
|
||||
body={null}
|
||||
onDismiss={() => {
|
||||
this.setState({ scheduleIdToDelete: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scheduleIdToExport && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ scheduleIdToExport: undefined })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleIdToExport} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
onRowExpand = (expanded: boolean, schedule: Schedule) => {
|
||||
if (expanded) {
|
||||
this.updateEventsFor(schedule.id);
|
||||
}
|
||||
};
|
||||
|
||||
handleExpandedRowsChange = (expandedRows: string[]) => {
|
||||
this.setState({ expandedSchedulesKeys: expandedRows });
|
||||
};
|
||||
|
||||
renderEvents = (schedule: Schedule) => {
|
||||
const { store } = this.props;
|
||||
const { scheduleStore } = store;
|
||||
const { scheduleToScheduleEvents } = scheduleStore;
|
||||
|
||||
const events = scheduleToScheduleEvents[schedule.id];
|
||||
|
||||
return events ? (
|
||||
events.length ? (
|
||||
<div className={cx('events')}>
|
||||
<Text.Title type="secondary" level={3}>
|
||||
Events
|
||||
</Text.Title>
|
||||
<ul className={cx('events-list')}>
|
||||
{(events || []).map((event, idx) => (
|
||||
<li key={idx} className={cx('events-list-item')}>
|
||||
<Event event={event} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
this.renderInstruction()
|
||||
)
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading events..." />
|
||||
);
|
||||
};
|
||||
|
||||
renderInstruction = () => {
|
||||
const { store } = this.props;
|
||||
const { userStore } = store;
|
||||
|
||||
return (
|
||||
<div className={cx('instructions')}>
|
||||
<Text type="secondary">
|
||||
There are no active slots here. To add an event, enter a username, for example “
|
||||
{userStore.currentUser?.username}“, and click the “Reload” button. OnCall will download this calendar and set
|
||||
up an on-call schedule based on event names. OnCall will refresh the calendar every 10 minutes after the
|
||||
intial setup.
|
||||
</Text>
|
||||
<img style={{ width: '400px' }} src={instructionsImage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
handleChangeFilters = (filters: SchedulesFiltersType) => {
|
||||
this.setState({ filters }, () => {
|
||||
const { filters, expandedSchedulesKeys } = this.state;
|
||||
|
||||
if (!filters.selectedDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandedSchedulesKeys.forEach((id) => this.updateEventsFor(id));
|
||||
});
|
||||
};
|
||||
|
||||
renderChannelName = (value: Schedule) => {
|
||||
return getSlackChannelName(value.slack_channel) || '-';
|
||||
};
|
||||
|
||||
renderUserGroup = (value: Schedule) => {
|
||||
return value.user_group?.handle || '-';
|
||||
};
|
||||
|
||||
renderOncallNow = (item: Schedule, _index: number) => {
|
||||
if (item.on_call_now?.length > 0) {
|
||||
return item.on_call_now.map((user, _index) => {
|
||||
return (
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }}>
|
||||
<div>
|
||||
<Avatar size="small" src={user.avatar} />
|
||||
<Text type="secondary"> {user.username}</Text>
|
||||
</div>
|
||||
</PluginLink>
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
renderType = (value: number) => {
|
||||
type tTypeToVerbal = {
|
||||
[key: number]: string;
|
||||
};
|
||||
const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical', 2: 'Web' };
|
||||
return typeToVerbal[value];
|
||||
};
|
||||
|
||||
renderWarning = (item: Schedule) => {
|
||||
if (item.warnings.length > 0) {
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
{item.warnings.map((warning: string) => (
|
||||
<p key={warning}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip placement="top" content={tooltipContent}>
|
||||
<Icon style={{ color: PENDING_COLOR }} name="exclamation-triangle" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderActionButtons = (record: Schedule) => {
|
||||
return (
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<WithPermissionControl key="edit" userAction={UserAction.UpdateSchedules}>
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.setState({ scheduleIdToEdit: record.id });
|
||||
|
||||
getLocationSrv().update({ partial: true, query: { id: record.id } });
|
||||
}}
|
||||
fill="text"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl key="reload" userAction={UserAction.UpdateSchedules}>
|
||||
<Button onClick={this.getReloadScheduleClickHandler(record.id)} fill="text">
|
||||
Reload
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl key="export" userAction={UserAction.UpdateSchedules}>
|
||||
<Button onClick={this.getExportScheduleClickHandler(record.id)} fill="text">
|
||||
Export
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<WithPermissionControl key="delete" userAction={UserAction.UpdateSchedules}>
|
||||
<Button onClick={this.getDeleteScheduleClickHandler(record.id)} fill="text" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
updateEventsFor = async (scheduleId: Schedule['id'], withEmpty = true, with_gap = true) => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { scheduleStore } = store;
|
||||
const {
|
||||
filters: { selectedDate },
|
||||
} = this.state;
|
||||
|
||||
store.scheduleStore.scheduleToScheduleEvents = omit(store.scheduleStore.scheduleToScheduleEvents, [scheduleId]);
|
||||
|
||||
this.forceUpdate();
|
||||
|
||||
await scheduleStore.updateScheduleEvents(scheduleId, withEmpty, with_gap, selectedDate, moment.tz.guess());
|
||||
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
getReloadScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
return async (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
await scheduleStore.reloadIcal(scheduleId);
|
||||
|
||||
scheduleStore.updateItem(scheduleId);
|
||||
this.updateEventsFor(scheduleId);
|
||||
};
|
||||
};
|
||||
|
||||
getDeleteScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
return (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
this.setState({ scheduleIdToDelete: scheduleId });
|
||||
};
|
||||
};
|
||||
|
||||
getExportScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
return (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
this.setState({ scheduleIdToExport: scheduleId });
|
||||
};
|
||||
};
|
||||
|
||||
handleDelete = async () => {
|
||||
const { scheduleIdToDelete } = this.state;
|
||||
const { store } = this.props;
|
||||
|
||||
this.setState({ scheduleIdToDelete: undefined });
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
await scheduleStore.delete(scheduleIdToDelete);
|
||||
|
||||
this.update();
|
||||
};
|
||||
}
|
||||
|
||||
interface EventProps {
|
||||
event: ScheduleEvent;
|
||||
}
|
||||
|
||||
const Event = ({ event }: EventProps) => {
|
||||
const dates = getDatesString(event.start, event.end, event.all_day);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!event.is_gap ? (
|
||||
<HorizontalGroup align="flex-start" spacing="sm">
|
||||
<div className={cx('priority-icon')}>
|
||||
<Text wrap type="secondary">{`L${event.priority_level || '0'}`}</Text>
|
||||
</div>
|
||||
<VerticalGroup>
|
||||
<div>
|
||||
{!event.is_empty ? (
|
||||
event.users.map((user: any, index: number) => (
|
||||
<span key={user.pk}>
|
||||
{index ? ', ' : ''}
|
||||
<PluginLink query={{ page: 'users', id: user.pk }}>{user.display_name}</PluginLink>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<HorizontalGroup spacing="sm">
|
||||
<Icon style={{ color: PENDING_COLOR }} name="exclamation-triangle" />
|
||||
<Text type="secondary">Empty shift</Text>
|
||||
{event.missing_users[0] && (
|
||||
<Text type="secondary">
|
||||
(check if {event.missing_users[0].includes(',') ? 'some of these users -' : 'user -'}{' '}
|
||||
<Text type="secondary">"{event.missing_users[0]}"</Text>{' '}
|
||||
{event.missing_users[0].includes(',') ? 'are' : 'is'} existing in OnCall or{' '}
|
||||
{event.missing_users[0].includes(',') ? 'have' : 'has'} Viewer role)
|
||||
</Text>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
{event.source && <span> — source: {event.source}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary"> {dates}</Text>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</HorizontalGroup>
|
||||
) : (
|
||||
<div className={cx('gap-between-shifts')}>
|
||||
<Icon name="exclamation-triangle" className={cx('gap-between-shifts-icon')} />
|
||||
<Text> Gap! Nobody On-Call...</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withMobXProviderContext(SchedulesPage);
|
||||
|
|
@ -1,11 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings {
|
||||
width: fit-content;
|
||||
.tabs__content {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,158 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, Input, Switch } from '@grafana/ui';
|
||||
import { Tab, TabsBar } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import ApiTokenSettings from 'containers/ApiTokenSettings/ApiTokenSettings';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { pages } from 'pages';
|
||||
import ChatOpsPage from 'pages/settings/tabs/ChatOps/ChatOps';
|
||||
import MainSettings from 'pages/settings/tabs/MainSettings/MainSettings';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { RootBaseStore } from 'state/rootBaseStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import { SettingsPageTab } from './SettingsPage.types';
|
||||
import CloudPage from './tabs/Cloud/CloudPage';
|
||||
import LiveSettingsPage from './tabs/LiveSettings/LiveSettingsPage';
|
||||
|
||||
import styles from './SettingsPage.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SettingsPageProps extends WithStoreProps {}
|
||||
|
||||
interface SettingsPageProps {
|
||||
store: RootBaseStore;
|
||||
}
|
||||
interface SettingsPageState {
|
||||
apiUrl?: string;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
@observer
|
||||
class SettingsPage extends React.Component<SettingsPageProps, SettingsPageState> {
|
||||
state: SettingsPageState = {
|
||||
apiUrl: '',
|
||||
activeTab: SettingsPageTab.MainSettings.key, // should read from route instead
|
||||
};
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
const url = await store.getApiUrlForSettings();
|
||||
this.setState({ apiUrl: url });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { teamStore } = store;
|
||||
const { apiUrl } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
Organization settings
|
||||
</Text.Title>
|
||||
<div className={cx('settings')}>
|
||||
<Field
|
||||
loading={!teamStore.currentTeam}
|
||||
label="Require resolution note when resolve incident"
|
||||
description="Once user clicks “Resolve” for an incident they are require to fill a resolution note about the incident"
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateGlobalSettings}>
|
||||
<Switch
|
||||
value={teamStore.currentTeam?.is_resolution_note_required}
|
||||
onChange={(event) => {
|
||||
teamStore.saveCurrentTeam({
|
||||
is_resolution_note_required: event.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
</Field>
|
||||
</div>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
API URL
|
||||
</Text.Title>
|
||||
<div>
|
||||
<Field>
|
||||
<Input value={apiUrl} disabled />
|
||||
</Field>
|
||||
</div>
|
||||
<ApiTokenSettings />
|
||||
</div>
|
||||
<PluginPage pageNav={this.getMatchingPageNav()}>
|
||||
<div className={cx('root')}>{this.renderContent()}</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { activeTab } = this.state;
|
||||
const { store } = this.props;
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
this.setState({ activeTab: tab });
|
||||
};
|
||||
|
||||
const grafanaUser = window.grafanaBootData.user;
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
const hasCloudPage = store.hasFeature(AppFeature.CloudConnection);
|
||||
const showCloudPage =
|
||||
hasCloudPage && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true);
|
||||
const showLiveSettings =
|
||||
hasLiveSettings && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true);
|
||||
|
||||
if (isTopNavbar()) {
|
||||
return (
|
||||
<>
|
||||
<TabsBar>
|
||||
<Tab
|
||||
key={SettingsPageTab.MainSettings.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.MainSettings.key)}
|
||||
active={activeTab === SettingsPageTab.MainSettings.key}
|
||||
label={SettingsPageTab.MainSettings.value}
|
||||
/>
|
||||
<Tab
|
||||
key={SettingsPageTab.ChatOps.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.ChatOps.key)}
|
||||
active={activeTab === SettingsPageTab.ChatOps.key}
|
||||
label={SettingsPageTab.ChatOps.value}
|
||||
/>
|
||||
{showLiveSettings && (
|
||||
<Tab
|
||||
key={SettingsPageTab.EnvVariables.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.EnvVariables.key)}
|
||||
active={activeTab === SettingsPageTab.EnvVariables.key}
|
||||
label={SettingsPageTab.EnvVariables.value}
|
||||
/>
|
||||
)}
|
||||
{showCloudPage && (
|
||||
<Tab
|
||||
key={SettingsPageTab.Cloud.key}
|
||||
onChangeTab={() => onTabChange(SettingsPageTab.Cloud.key)}
|
||||
active={activeTab === SettingsPageTab.Cloud.key}
|
||||
label={SettingsPageTab.Cloud.value}
|
||||
/>
|
||||
)}
|
||||
</TabsBar>
|
||||
|
||||
<TabsContent activeTab={activeTab} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <MainSettings />;
|
||||
}
|
||||
|
||||
getMatchingPageNav() {
|
||||
return {
|
||||
parentItem: {
|
||||
text: getTabText(this.state.activeTab),
|
||||
},
|
||||
text: '',
|
||||
hideFromBreadcrumbs: true,
|
||||
};
|
||||
|
||||
function getTabText(activeTab: string) {
|
||||
let result: string;
|
||||
Object.keys(SettingsPageTab).forEach((tab) => {
|
||||
if (activeTab === SettingsPageTab[tab].key) {
|
||||
result = SettingsPageTab[tab].value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TabsContentProps {
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
const TabsContent = (props: TabsContentProps) => {
|
||||
const { activeTab } = props;
|
||||
|
||||
return (
|
||||
<div className={cx('tabs__content')}>
|
||||
{activeTab === SettingsPageTab.MainSettings.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<MainSettings />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === SettingsPageTab.ChatOps.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<ChatOpsPage />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === SettingsPageTab.EnvVariables.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<LiveSettingsPage />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === SettingsPageTab.Cloud.key && (
|
||||
<div className={cx('tab__page')}>
|
||||
<CloudPage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withMobXProviderContext(SettingsPage);
|
||||
|
|
|
|||
8
grafana-plugin/src/pages/settings/SettingsPage.types.ts
Normal file
8
grafana-plugin/src/pages/settings/SettingsPage.types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { KeyValuePair } from 'utils';
|
||||
|
||||
export const SettingsPageTab = {
|
||||
MainSettings: new KeyValuePair('MainSettings', 'Organization Settings'),
|
||||
ChatOps: new KeyValuePair('ChatOps', 'Chat Ops'),
|
||||
EnvVariables: new KeyValuePair('EnvVariables', 'Env Variables'),
|
||||
Cloud: new KeyValuePair('Cloud', 'Cloud'),
|
||||
};
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
@ -2,23 +2,61 @@ import React from 'react';
|
|||
|
||||
import { HorizontalGroup, Icon } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import VerticalTabsBar, { VerticalTab } from 'components/VerticalTabsBar/VerticalTabsBar';
|
||||
import { ChatOpsTab } from 'pages/chat-ops/ChatOps.types';
|
||||
import SlackSettings from 'pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings';
|
||||
import TelegramSettings from 'pages/settings/tabs/ChatOps/tabs/TelegramSettings/TelegramSettings';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import SlackSettings from './tabs/SlackSettings/SlackSettings';
|
||||
import TelegramSettings from './tabs/TelegramSettings/TelegramSettings';
|
||||
|
||||
import styles from 'containers/UserSettings/parts/index.module.css';
|
||||
import styles from './ChatOps.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export enum ChatOpsTab {
|
||||
Slack = 'Slack',
|
||||
Telegram = 'Telegram',
|
||||
}
|
||||
|
||||
interface ChatOpsState {
|
||||
activeTab: ChatOpsTab;
|
||||
}
|
||||
|
||||
@observer
|
||||
class ChatOpsPage extends React.Component<{}, ChatOpsState> {
|
||||
state: ChatOpsState = {
|
||||
activeTab: ChatOpsTab.Slack,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('tabs')}>
|
||||
<Tabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab: ChatOpsTab) => {
|
||||
this.setState({ activeTab: tab });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx('content')}>
|
||||
<TabsContent activeTab={activeTab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(ChatOpsPage);
|
||||
|
||||
interface TabsProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const Tabs = (props: TabsProps) => {
|
||||
const Tabs = (props: TabsProps) => {
|
||||
const { activeTab, onTabChange } = props;
|
||||
|
||||
return (
|
||||
|
|
@ -43,7 +81,7 @@ interface TabsContentProps {
|
|||
activeTab: string;
|
||||
}
|
||||
|
||||
export const TabsContent = (props: TabsContentProps) => {
|
||||
const TabsContent = (props: TabsContentProps) => {
|
||||
const { activeTab } = props;
|
||||
|
||||
return (
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
height: 32px;
|
||||
}
|
||||
|
||||
.cloud-page-title,
|
||||
.heartbit-button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
|
@ -59,7 +58,7 @@
|
|||
}
|
||||
|
||||
.table-title {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.table-button {
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings {
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, Input, Switch } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import ApiTokenSettings from 'containers/ApiTokenSettings/ApiTokenSettings';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
import styles from './MainSettings.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SettingsPageProps extends WithStoreProps {}
|
||||
|
||||
interface SettingsPageState {
|
||||
apiUrl?: string;
|
||||
}
|
||||
|
||||
@observer
|
||||
class SettingsPage extends React.Component<SettingsPageProps, SettingsPageState> {
|
||||
state: SettingsPageState = {
|
||||
apiUrl: '',
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
const url = await store.getApiUrlForSettings();
|
||||
this.setState({ apiUrl: url });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { teamStore } = store;
|
||||
const { apiUrl } = this.state;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
Organization settings
|
||||
</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
|
||||
<div className={cx('settings')}>
|
||||
<Field
|
||||
loading={!teamStore.currentTeam}
|
||||
label="Require resolution note when resolve incident"
|
||||
description={`Once user clicks "Resolve" for an incident they are require to fill a resolution note about the incident`}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateGlobalSettings}>
|
||||
<Switch
|
||||
value={teamStore.currentTeam?.is_resolution_note_required}
|
||||
onChange={(event) => {
|
||||
teamStore.saveCurrentTeam({
|
||||
is_resolution_note_required: event.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
</Field>
|
||||
</div>
|
||||
<Text.Title level={3} className={cx('title')}>
|
||||
API URL
|
||||
</Text.Title>
|
||||
<div>
|
||||
<Field>
|
||||
<Input value={apiUrl} disabled />
|
||||
</Field>
|
||||
</div>
|
||||
<ApiTokenSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(SettingsPage);
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -16,11 +17,13 @@ const cx = cn.bind(styles);
|
|||
class Test extends React.Component<any, any> {
|
||||
render() {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
|
||||
{(disabled) => <Button disabled={disabled}>Click me!</Button>}
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
<PluginPage>
|
||||
<div className={cx('root')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
|
||||
{(disabled) => <Button disabled={disabled}>Click me!</Button>}
|
||||
</WithPermissionControl>
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.root {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.users-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -19,7 +15,7 @@
|
|||
.users-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.users-header-left {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import React from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Alert, Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import { PluginPage } from 'PluginPage';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
|
|
@ -21,6 +23,8 @@ import UserSettings from 'containers/UserSettings/UserSettings';
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { getRole } from 'models/user/user.helpers';
|
||||
import { User as UserType, UserRole } from 'models/user/user.types';
|
||||
import { pages } from 'pages';
|
||||
import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -61,10 +65,10 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
|
||||
initialUsersLoaded = false;
|
||||
|
||||
private userId: string;
|
||||
|
||||
async componentDidMount() {
|
||||
const {
|
||||
query: { p },
|
||||
} = this.props;
|
||||
const { p } = getQueryParams();
|
||||
this.setState({ page: p ? Number(p) : 1 }, this.updateUsers);
|
||||
|
||||
this.parseParams();
|
||||
|
|
@ -83,7 +87,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
return await userStore.updateItems(getRealFilters(usersFilters), page);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<UsersProps>, _prevState: Readonly<UsersState>, _snapshot?: any) {
|
||||
componentDidUpdate() {
|
||||
const { store } = this.props;
|
||||
|
||||
if (!this.initialUsersLoaded && store.isUserActionAllowed(UserAction.ViewOtherUsers)) {
|
||||
|
|
@ -91,7 +95,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
this.initialUsersLoaded = true;
|
||||
}
|
||||
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
if (this.userId !== getQueryParams()['id']) {
|
||||
this.parseParams();
|
||||
}
|
||||
}
|
||||
|
|
@ -99,10 +103,10 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
parseParams = async () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
this.userId = id;
|
||||
|
||||
if (id) {
|
||||
await (id === 'me' ? store.userStore.loadCurrentUser() : store.userStore.loadUser(String(id), true)).catch(
|
||||
|
|
@ -171,20 +175,22 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
const { count, results } = userStore.getSearchResult();
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="user"
|
||||
pageName="users"
|
||||
itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<PluginPage pageNav={pages['users'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="user"
|
||||
pageName="users"
|
||||
itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root', 'TEST-users-page')}>
|
||||
<div className={cx('users-header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
|
|
@ -244,8 +250,8 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@
|
|||
"name": "Alert Groups",
|
||||
"path": "/a/grafana-oncall-app/?page=incidents",
|
||||
"role": "Viewer",
|
||||
"addToNav": true,
|
||||
"defaultNav": true
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
|
|
@ -65,13 +64,6 @@
|
|||
"role": "Viewer",
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "ChatOps",
|
||||
"path": "/a/grafana-oncall-app/?page=chat-ops",
|
||||
"role": "Viewer",
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Outgoing Webhooks",
|
||||
|
|
|
|||
14
grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx
Normal file
14
grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { config } from '@grafana/runtime';
|
||||
|
||||
export function isTopNavbar(): boolean {
|
||||
return !!config.featureToggles.topnav;
|
||||
}
|
||||
|
||||
export function getQueryParams(): any {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const result = {};
|
||||
for (const [key, value] of searchParams) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, LinkButton } from '@grafana/ui';
|
||||
import classnames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
|
|
@ -10,17 +12,18 @@ import localeData from 'dayjs/plugin/localeData';
|
|||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import { observer, Provider } from 'mobx-react';
|
||||
|
||||
import 'interceptors';
|
||||
import { observer, Provider } from 'mobx-react';
|
||||
import Header from 'navbar/Header/Header';
|
||||
import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar';
|
||||
|
||||
import DefaultPageLayout from 'containers/DefaultPageLayout/DefaultPageLayout';
|
||||
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
||||
import logo from 'img/logo.svg';
|
||||
import { pages } from 'pages';
|
||||
import { routes } from 'pages/routes';
|
||||
import { rootStore } from 'state';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { useNavModel } from 'utils/hooks';
|
||||
import { useQueryParams, useQueryPath } from 'utils/hooks';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
|
@ -30,10 +33,11 @@ dayjs.extend(isSameOrBefore);
|
|||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
import './style/vars.css';
|
||||
import './style/index.css';
|
||||
import 'style/vars.css';
|
||||
import 'style/global.css';
|
||||
import 'style/utils.css';
|
||||
|
||||
import { AppFeature } from './state/features';
|
||||
import { isTopNavbar } from './GrafanaPluginRootPage.helpers';
|
||||
|
||||
export const GrafanaPluginRootPage = (props: AppRootProps) => (
|
||||
<Provider store={rootStore}>
|
||||
|
|
@ -96,21 +100,18 @@ const RootWithLoader = observer((props: AppRootProps) => {
|
|||
});
|
||||
|
||||
export const Root = observer((props: AppRootProps) => {
|
||||
const {
|
||||
path,
|
||||
onNavChanged,
|
||||
query: { page },
|
||||
meta,
|
||||
} = props;
|
||||
const [didFinishLoading, setDidFinishLoading] = useState(false);
|
||||
const queryParams = useQueryParams();
|
||||
const page = queryParams.get('page');
|
||||
const path = useQueryPath();
|
||||
|
||||
// Required to support grafana instances that use a custom `root_url`.
|
||||
const pathWithoutLeadingSlash = path.replace(/^\//, '');
|
||||
|
||||
const store = useStore();
|
||||
const { backendLicense } = store;
|
||||
|
||||
useEffect(() => {
|
||||
store.updateBasicData();
|
||||
updateBasicData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -126,33 +127,48 @@ export const Root = observer((props: AppRootProps) => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Update the navigation when the page or path changes
|
||||
const navModel = useNavModel(
|
||||
useMemo(
|
||||
() => ({
|
||||
page,
|
||||
pages,
|
||||
path: pathWithoutLeadingSlash,
|
||||
meta,
|
||||
grafanaUser: window.grafanaBootData.user,
|
||||
enableLiveSettings: store.hasFeature(AppFeature.LiveSettings),
|
||||
enableCloudPage: store.hasFeature(AppFeature.CloudConnection),
|
||||
backendLicense,
|
||||
}),
|
||||
[meta, pathWithoutLeadingSlash, page, store.features, backendLicense]
|
||||
)
|
||||
);
|
||||
useEffect(() => {
|
||||
/* @ts-ignore */
|
||||
onNavChanged(navModel);
|
||||
}, [navModel, onNavChanged]);
|
||||
const updateBasicData = async () => {
|
||||
await store.updateBasicData();
|
||||
setDidFinishLoading(true);
|
||||
};
|
||||
|
||||
const Page = pages.find(({ id }) => id === page)?.component || pages[0].component;
|
||||
const Page = useMemo(() => getPageMatchingComponent(page), [page]);
|
||||
|
||||
if (!didFinishLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultPageLayout {...props}>
|
||||
<GrafanaTeamSelect currentPage={page} />
|
||||
<Page {...props} path={pathWithoutLeadingSlash} />
|
||||
{!isTopNavbar() && (
|
||||
<>
|
||||
<Header page={page} backendLicense={store.backendLicense} />
|
||||
<nav className="page-container">
|
||||
<LegacyNavTabsBar currentPage={page} />
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classnames(
|
||||
{ 'page-container': !isTopNavbar() },
|
||||
{ 'page-body': !isTopNavbar() },
|
||||
'u-position-relative'
|
||||
)}
|
||||
>
|
||||
<Page {...props} path={pathWithoutLeadingSlash} store={store} />
|
||||
</div>
|
||||
</DefaultPageLayout>
|
||||
);
|
||||
});
|
||||
|
||||
function getPageMatchingComponent(pageId: string): (props?: any) => JSX.Element {
|
||||
let matchingPage = routes[pageId];
|
||||
if (!matchingPage) {
|
||||
const defaultPageId = pages['incidents'].id;
|
||||
matchingPage = routes[defaultPageId];
|
||||
locationService.replace(pages[defaultPageId].path);
|
||||
}
|
||||
|
||||
return matchingPage.component;
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import { Timezone } from 'models/timezone/timezone.types';
|
|||
import { UserStore } from 'models/user/user';
|
||||
import { UserGroupStore } from 'models/user_group/user_group';
|
||||
import { makeRequest } from 'network';
|
||||
import { NavMenuItem } from 'pages/routes';
|
||||
|
||||
import { AppFeature } from './features';
|
||||
import {
|
||||
|
|
@ -99,6 +100,9 @@ export class RootBaseStore {
|
|||
@observable
|
||||
onCallApiUrl: string;
|
||||
|
||||
@observable
|
||||
navMenuItem: NavMenuItem;
|
||||
|
||||
// --------------------------
|
||||
|
||||
userStore: UserStore = new UserStore(this);
|
||||
|
|
@ -125,16 +129,18 @@ export class RootBaseStore {
|
|||
// stores
|
||||
|
||||
async updateBasicData() {
|
||||
this.teamStore.loadCurrentTeam();
|
||||
this.grafanaTeamStore.updateItems();
|
||||
this.updateFeatures();
|
||||
this.userStore.updateNotificationPolicyOptions();
|
||||
this.userStore.updateNotifyByOptions();
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions();
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions();
|
||||
this.escalationPolicyStore.updateWebEscalationPolicyOptions();
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions();
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions();
|
||||
return Promise.all([
|
||||
this.teamStore.loadCurrentTeam(),
|
||||
this.grafanaTeamStore.updateItems(),
|
||||
this.updateFeatures(),
|
||||
this.userStore.updateNotificationPolicyOptions(),
|
||||
this.userStore.updateNotifyByOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
|
||||
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
async getUserRole() {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,3 @@
|
|||
.spin {
|
||||
width: 100%;
|
||||
margin-top: 200px;
|
||||
margin-bottom: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
/* animation: fadeIn 1s infinite alternate; */
|
||||
}
|
||||
|
||||
.spin-text {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.configure-plugin {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
|
@ -24,6 +8,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
|
||||
.spin {
|
||||
width: 100%;
|
||||
margin-top: 200px;
|
||||
margin-bottom: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spin-text {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.disabled-row {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
|
@ -31,3 +33,9 @@
|
|||
.highlighted-row {
|
||||
background: var(--highlighted-row-bg);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
|
||||
.navbarRootFallback {
|
||||
margin-top: 24px;
|
||||
}
|
||||
20
grafana-plugin/src/style/utils.css
Normal file
20
grafana-plugin/src/style/utils.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.u-flex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.u-align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.u-position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.u-pull-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.u-pull-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
--gradient-brandHorizontal: linear-gradient(90deg, #f83 0%, #f53e4c 100%);
|
||||
--gradient-brandVertical: linear-gradient(0.01deg, #f53e4c -31.2%, #f83 113.07%);
|
||||
--always-gray: #ccccdc;
|
||||
--title-marginBottom: 16px;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
|
|
|
|||
|
|
@ -1,90 +1,12 @@
|
|||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AppRootProps, NavModelItem } from '@grafana/data';
|
||||
|
||||
import NavBarSubtitle from 'components/NavBar/NavBarSubtitle';
|
||||
import { PageDefinition } from 'pages';
|
||||
|
||||
import { APP_TITLE } from './consts';
|
||||
|
||||
type Args = {
|
||||
meta: AppRootProps['meta'];
|
||||
pages: PageDefinition[];
|
||||
path: string;
|
||||
page: string;
|
||||
grafanaUser: {
|
||||
orgRole: 'Viewer' | 'Editor' | 'Admin';
|
||||
};
|
||||
enableLiveSettings: boolean;
|
||||
enableCloudPage: boolean;
|
||||
backendLicense: string;
|
||||
};
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useForceUpdate() {
|
||||
const [, setValue] = useState(0);
|
||||
return () => setValue((value) => value + 1);
|
||||
}
|
||||
|
||||
export function useNavModel({
|
||||
meta,
|
||||
pages,
|
||||
path,
|
||||
page,
|
||||
grafanaUser,
|
||||
enableLiveSettings,
|
||||
enableCloudPage,
|
||||
backendLicense,
|
||||
}: Args) {
|
||||
return useMemo(() => {
|
||||
const tabs: NavModelItem[] = [];
|
||||
|
||||
pages.forEach(({ text, icon, id, role, hideFromTabs }) => {
|
||||
tabs.push({
|
||||
text,
|
||||
icon,
|
||||
id,
|
||||
url: `${path}?page=${id}`,
|
||||
hideFromTabs:
|
||||
hideFromTabs ||
|
||||
(role === 'Admin' && grafanaUser.orgRole !== role) ||
|
||||
(id === 'live-settings' && !enableLiveSettings) ||
|
||||
(id === 'cloud' && !enableCloudPage),
|
||||
});
|
||||
|
||||
if (page === id) {
|
||||
tabs[tabs.length - 1].active = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback if current `tab` doesn't match any page
|
||||
if (!tabs.some(({ active }) => active)) {
|
||||
tabs[0].active = true;
|
||||
}
|
||||
|
||||
const node = {
|
||||
text: APP_TITLE,
|
||||
img: meta.info.logos.large,
|
||||
subTitle: <NavBarSubtitle backendLicense={backendLicense} />,
|
||||
url: path,
|
||||
children: tabs,
|
||||
};
|
||||
|
||||
return {
|
||||
node,
|
||||
main: node,
|
||||
};
|
||||
}, [
|
||||
meta.info.logos.large,
|
||||
pages,
|
||||
path,
|
||||
page,
|
||||
enableLiveSettings,
|
||||
enableCloudPage,
|
||||
backendLicense,
|
||||
grafanaUser.orgRole,
|
||||
]);
|
||||
}
|
||||
|
||||
export function usePrevious(value: any) {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
|
|
@ -93,6 +15,17 @@ export function usePrevious(value: any) {
|
|||
return ref.current;
|
||||
}
|
||||
|
||||
export function useQueryParams() {
|
||||
const { search } = useLocation();
|
||||
|
||||
return React.useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
export function useQueryPath() {
|
||||
const location = useLocation();
|
||||
return React.useMemo(() => location.pathname, [location]);
|
||||
}
|
||||
|
||||
export function useDebouncedCallback<A extends any[]>(callback: (...args: A) => void, wait: number) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = useRef<A>();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ import appEvents from 'grafana/app/core/app_events';
|
|||
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
||||
import qs from 'query-string';
|
||||
|
||||
export class KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
|
||||
constructor(key: string, value: string) {
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
export const TZ_OFFSET = new Date().getTimezoneOffset();
|
||||
|
||||
export const getTzOffsetHours = (): number => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue