diff --git a/.drone.yml b/.drone.yml index 1f37c6c9..4fc14748 100644 --- a/.drone.yml +++ b/.drone.yml @@ -117,7 +117,7 @@ steps: - name: Build and Push Engine Docker Image Backend to GCR image: plugins/docker settings: - repo: us.gcr.io/kubernetes-dev/oncall-engine + repo: us.gcr.io/kubernetes-dev/oncall dockerfile: engine/Dockerfile context: engine/ config: diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index a3276a5f..aacc6f44 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -100,9 +100,11 @@ export const Root = observer((props: AppRootProps) => { const style = document.createElement('style'); document.head.appendChild(style); const index = style.sheet.insertRule('.page-body {max-width: unset !important}'); + const index2 = style.sheet.insertRule('.page-container {max-width: unset !important}'); return () => { style.sheet.removeRule(index); + style.sheet.removeRule(index2); }; }, []); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index ca00871b..3ce67136 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -5,12 +5,12 @@ import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useMediaQuery } from 'react-responsive'; -import { Tabs, TabsContent } from 'containers/UserSettings/parts'; import { User as UserType } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserSettingsTab } from './UserSettings.types'; +import { Tabs, TabsContent } from './parts'; import styles from './UserSettings.module.css'; @@ -58,7 +58,8 @@ const UserSettings = observer((props: UserFormProps) => { setActiveTab(tab); }, []); - const isModalWide = activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop; + const isModalWide = + (activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop) || activeTab === UserSettingsTab.PhoneVerification; const [showNotificationSettingsTab, showSlackConnectionTab, showTelegramConnectionTab, showMobileAppVerificationTab] = [ diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 19e598ed..7f966bfc 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -7,6 +7,7 @@ import Block from 'components/GBlock/Block'; import MobileAppVerification from 'containers/MobileAppVerification/MobileAppVerification'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab'; +import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings'; import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab'; import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification'; import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo'; @@ -105,6 +106,7 @@ export const TabsContent = (props: TabsContentProps) => { const store = useStore(); const { userStore } = store; + const [isPhoneEnabled, setIsPhoneEnabled] = useState(false); const storeUser = userStore.items[id]; @@ -124,9 +126,12 @@ export const TabsContent = (props: TabsContentProps) => { ))} {activeTab === UserSettingsTab.NotificationSettings && } - {activeTab === UserSettingsTab.PhoneVerification && ( - - )} + {activeTab === UserSettingsTab.PhoneVerification && + (isPhoneEnabled ? ( + + ) : ( + + ))} {activeTab === UserSettingsTab.MobileAppVerification && ( )} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css new file mode 100644 index 00000000..ab86c434 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css @@ -0,0 +1,3 @@ +.test { + color: grey; +} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx new file mode 100644 index 00000000..08f94cd6 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import GTable from 'components/GTable/GTable'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { User as UserType } from 'models/user/user.types'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './CloudPhoneSettings.module.css'; + +const cx = cn.bind(styles); + +interface CloudPhoneSettingsProps extends WithStoreProps {} + +const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { + const [isAccountMatched, setIsAccountMatched] = useState(true); + const [isPhoneVerified, setIsPhoneVerified] = useState(true); + + const signUpGrafanaCloud = () => { + console.log('Sign UP'); + }; + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + return ( + + + OnCall use Grafana Cloud for SMS and phone call notifications + + + {isAccountMatched ? ( + isPhoneVerified ? ( + + + Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} + + + + ) : ( + + + Your account successfully matched with the Grafana Cloud account. Your phone number is verified. + + + + ) + ) : ( + + + {'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '} + + + + )} + + ); +}; + +export default withMobXProviderContext(CloudPhoneSettings); diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts new file mode 100644 index 00000000..f8e09e71 --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -0,0 +1,77 @@ +import { get } from 'lodash-es'; +import { action, computed, observable } from 'mobx'; + +import BaseStore from 'models/base_store'; +import { NotificationPolicyType } from 'models/notification_policy'; +import { makeRequest } from 'network'; +import { Mixpanel } from 'services/mixpanel'; +import { RootStore } from 'state'; +import { move } from 'state/helpers'; + +import { Cloud } from './cloud.types'; + +export class CloudStore extends BaseStore { + @observable.shallow + searchResult: { count?: number; results?: Array } = {}; + + @observable.shallow + items: { [id: string]: Cloud } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + this.path = '/cloud_users/'; + } + + @action + async updateItems(f: any = { searchTerm: '' }, page = 1) { + const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility + const { searchTerm: search } = filters; + const { count, results } = await makeRequest(this.path, { + params: { search, page }, + }); + + this.items = { + ...this.items, + ...results.reduce( + (acc: { [key: number]: Cloud }, item: Cloud) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + + this.searchResult = { + count, + results: results.map((item: Cloud) => item.id), + }; + } + + getSearchResult() { + return { + count: this.searchResult.count, + results: + this.searchResult.results && + this.searchResult.results.map((cloud_user_id: Cloud['id']) => this.items?.[cloud_user_id]), + }; + } + + async syncCloudUsers() { + return await makeRequest(`${this.path}sync_with_cloud`, { method: 'POST' }); + } + + async getCloudConnectionStatus() { + return await makeRequest(`/cloud_connection/`, { method: 'GET' }); + } + + @action + async connectToCloud(token: string) { + return await makeRequest(`/live_settings/`, { method: 'PUT', params: { token } }); + } + + @action + async disconnectToCloud() { + return await makeRequest(`/live_settings/`, { method: 'DELETE' }); + } +} diff --git a/grafana-plugin/src/models/cloud/cloud.types.ts b/grafana-plugin/src/models/cloud/cloud.types.ts new file mode 100644 index 00000000..2aa411a1 --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.types.ts @@ -0,0 +1,6 @@ +export interface Cloud { + id: string; + username: string; + cloud_sync_status?: number; + link?: string; +} diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index cb4e03bf..4dd3f00a 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -50,4 +50,6 @@ export interface User { permissions: UserAction[]; trigger_video_call?: boolean; export_url?: string; + status?: number; + link?: string; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css new file mode 100644 index 00000000..9597b6ab --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -0,0 +1,28 @@ +.info-block { + width: 70%; +} + +.warning-message { + color: var(--warning-text-color); +} + +.success-message { + color: var(--success-text-color); +} + +.error-message { + color: var(--error-text-color); +} + +.user-table { + margin-top: 24px; + width: 100%; +} + +.cloud-page-title { + margin-top: 24px; +} + +.cloud-oncall-name { + color: #f55f3e; +} diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx new file mode 100644 index 00000000..21be94f3 --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -0,0 +1,261 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import GTable from 'components/GTable/GTable'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { HeartGreenIcon, HeartRedIcon } from 'icons'; +import { Cloud } from 'models/cloud/cloud.types'; +import { WithStoreProps } from 'state/types'; +import { useStore } from 'state/useStore'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './CloudPage.module.css'; + +const cx = cn.bind(styles); + +interface CloudPageProps extends WithStoreProps {} + +const CloudPage = (props: CloudPageProps) => { + const store = useStore(); + const [cloudApiKey, setCloudApiKey] = useState(''); + const [cloudIsConnected, setCloudIsConnected] = useState(true); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + + useEffect(() => { + store.cloudStore.updateItems(); + }, []); + + const usersCount = 3; + const data = [ + { id: 'yshanyrova', username: 'y.shanyrova@grafana.com', cloud_sync_status: 2, link: '/test/abc' }, + { id: 'amixradmin', username: 'amixr-admin@grafana.com', cloud_sync_status: 1, link: '/test/qwerty' }, + { id: 'amixr', username: 'amixr@grafana.com', cloud_sync_status: undefined, link: undefined }, + ]; + + // const data = store.cloudStore.getSearchResult(); + const handleChangeCloudApiKey = useCallback((e) => { + setCloudApiKey(e.target.value); + }, []); + + const saveKeyAndConnect = () => { + setShowConfirmationModal(true); + }; + + const disconnectCloudOncall = () => { + console.log('disconnected'); + setCloudIsConnected(false); + store.cloudStore.disconnectToCloud(); + }; + + const connectToCloud = () => { + setCloudIsConnected(true); + setShowConfirmationModal(false); + store.cloudStore.connectToCloud(cloudApiKey); + }; + + const syncUsers = () => { + console.log('Sync Users'); + }; + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + const renderButtons = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return null; + case 1: + return ( + + ); + case 2: + return ( + + ); + default: + return null; + } + }; + + const renderStatus = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return User not found in the Grafana Cloud; + case 1: + return Phone number verified; + + case 2: + return Phone number is not verified in Grafana Cloud; + default: + return User not found in Grafana Cloud; + } + }; + + const renderStatusIcon = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return ; + case 1: + return ; + + case 2: + return ; + default: + return ; + } + }; + + const renderEmail = (user: Cloud) => { + return {user.username}; + }; + + const columns = [ + { + width: '5%', + render: renderStatusIcon, + key: 'statusIcon', + }, + { + width: '30%', + render: renderEmail, + key: 'email', + }, + { + width: '35%', + render: renderStatus, + key: 'status', + }, + { + width: '30%', + render: renderButtons, + key: 'buttons', + align: 'actions', + }, + ]; + + return ( +
+ + + Connect Open Source OnCall and Cloud OnCall + + + {cloudIsConnected ? ( + + + Cloud OnCall API key + + Cloud OnCall is sucessfully connected. + + + + + + ) : ( + + + Cloud OnCall API key + + + + + + + )} + + + {showConfirmationModal && ( + setShowConfirmationModal(false)} + > + + + + + + )} + + + + + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + {cloudIsConnected && ( + + )} + + + + + + + SMS and phone call notifications + + {cloudIsConnected ? ( +
+ + { + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + } + + + ( + + {`${usersCount} users matched between OSS and Cloud OnCall`} + + + )} + rowKey="id" + // @ts-ignore + columns={columns} + data={data} + /> +
+ ) : ( + Users matched between OSS and Cloud OnCall currently unavialable. + )} +
+
+
+
+ ); +}; + +export default withMobXProviderContext(CloudPage); diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index cd2c68a3..e7891eca 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -3,6 +3,7 @@ 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'; @@ -116,6 +117,12 @@ export const pages: PageDefinition[] = [ text: 'Migrate From Amixr.IO', hideFromTabs: true, }, + { + component: CloudPage, + icon: 'cloud', + id: 'cloud', + text: 'Cloud', + }, { component: Test, icon: 'cog', diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index 5900ab1f..331f6ca1 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -9,6 +9,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_filters/alert_receive_channel_filters'; import { AlertGroupStore } from 'models/alertgroup/alertgroup'; import { ApiTokenStore } from 'models/api_token/api_token'; +import { CloudStore } from 'models/cloud/cloud'; import { EscalationChainStore } from 'models/escalation_chain/escalation_chain'; import { EscalationPolicyStore } from 'models/escalation_policy/escalation_policy'; import { GlobalSettingStore } from 'models/global_setting/global_setting'; @@ -81,6 +82,7 @@ export class RootBaseStore { // -------------------------- userStore: UserStore = new UserStore(this); + cloudStore: CloudStore = new CloudStore(this); grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this); alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this); outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this); diff --git a/grafana-plugin/src/vars.css b/grafana-plugin/src/vars.css index a0af933b..0216e04c 100644 --- a/grafana-plugin/src/vars.css +++ b/grafana-plugin/src/vars.css @@ -22,6 +22,8 @@ --secondary-text-color: rgba(36, 41, 46, 0.75); --disabled-text-color: rgba(36, 41, 46, 0.5); --warning-text-color: #8a6c00; + --success-text-color: rgb(10, 118, 78); + --error-text-color: rgb(207, 14, 91); --primary-text-link: #1f62e0; --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); @@ -38,6 +40,8 @@ --secondary-text-color: rgba(204, 204, 220, 0.65); --disabled-text-color: rgba(204, 204, 220, 0.4); --warning-text-color: #f8d06b; + --success-text-color: rgb(108, 207, 142); + --error-text-color: rgb(255, 82, 134); --primary-text-link: #6e9fff; --timeline-icon-background: rgba(70, 76, 84, 1); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 1);