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:
Rares Mardare 2022-11-16 11:56:54 +02:00 committed by GitHub
parent f9a9c1d978
commit 83f281ca37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 2053 additions and 2238 deletions

View file

@ -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',

View file

@ -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"
},
};

View file

@ -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",

View 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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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]
);

View file

@ -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 --- */

View file

@ -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 {

View file

@ -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 {

View file

@ -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;
});

View file

@ -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 */

View file

@ -0,0 +1,8 @@
module.exports = {
process() {
return { code: 'module.exports = {};' };
},
getCacheKey() {
return 'svgTransform';
},
};

View file

@ -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';

View file

@ -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;
}

View 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>
</>
);
}
}

View 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;
}

View 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>
);
}

View file

@ -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);

View file

@ -1,4 +0,0 @@
export enum ChatOpsTab {
Slack = 'Slack',
Telegram = 'Telegram',
}

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.filters {
margin-bottom: 20px;
}

View file

@ -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>
);
}

View file

@ -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 {

View file

@ -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)

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.select {
width: 400px;
}

View file

@ -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>
);
}

View file

@ -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';

View file

@ -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,
},
];

View 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;
}, {});

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.filters {
margin-bottom: 20px;
}

View file

@ -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>
);
}

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.header {
display: flex;
justify-content: space-between;

View file

@ -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>
);
}

View file

@ -1,8 +1,5 @@
.root {
margin-top: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}

View file

@ -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');
}
});

View file

@ -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>
);
}

View 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;
}, {});

View file

@ -1,7 +1,6 @@
.root {
max-width: 1600px;
margin: 0 auto;
margin-top: 24px;
--rotations-border: var(--border-weak);
--rotations-background: var(--background-secondary);

View file

@ -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' } });

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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}`;
}

View file

@ -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;
}

View file

@ -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 havent 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);

View file

@ -1,11 +1,3 @@
.root {
margin-top: 24px;
}
.title {
margin-bottom: 20px;
}
.settings {
width: fit-content;
.tabs__content {
padding-top: 24px;
}

View file

@ -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);

View 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'),
};

View file

@ -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 (

View file

@ -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 {

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.align-top {
vertical-align: top;
}

View file

@ -0,0 +1,7 @@
.title {
margin-bottom: 20px;
}
.settings {
width: fit-content;
}

View file

@ -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);

View file

@ -1,7 +1,3 @@
.root {
margin-top: 24px;
}
.select {
width: 400px;
}

View file

@ -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>
);
}
}

View file

@ -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 {

View file

@ -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>
);
}

View file

@ -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",

View 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;
}

View file

@ -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;
}

View file

@ -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() {

View file

@ -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;
}

View 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;
}

View file

@ -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 {

View file

@ -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>();

View file

@ -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