chore: back merge irm (#5111)
# What this PR does
Back merge irm
## Checklist
- [ ] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
show up in the autogenerated release notes.
This commit is contained in:
parent
e9d94ebc1e
commit
612c0e5a2e
14 changed files with 130 additions and 8 deletions
|
|
@ -1,5 +1,17 @@
|
|||
# Changelog
|
||||
|
||||
## [1.9.28](https://github.com/grafana/irm/compare/grafana-oncall-app-v1.9.27...grafana-oncall-app-v1.9.28) (2024-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disallow oncall schedule rotation layer/overrides CUD form submissions more than once ([#193](https://github.com/grafana/irm/issues/193)) ([73ae1c7](https://github.com/grafana/irm/commit/73ae1c7d78474b42b9eb4305416828afeb04fa3a))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* implement merged IRM module.tsx ([#182](https://github.com/grafana/irm/issues/182)) ([995b573](https://github.com/grafana/irm/commit/995b5732493aabc226cd62b9ca52a1e582ef5878))
|
||||
|
||||
## [1.9.27](https://github.com/grafana/irm/compare/grafana-oncall-app-v1.9.26...grafana-oncall-app-v1.9.27) (2024-09-26)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "grafana-oncall-app",
|
||||
"version": "1.9.27",
|
||||
"version": "1.9.28",
|
||||
"description": "Grafana OnCall Plugin",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.jsx,.ts,.tsx --max-warnings=20 ./src ./e2e-tests",
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
return this.renderNumAlertsInWindow();
|
||||
case 'num_minutes_in_window':
|
||||
return this.renderNumMinutesInWindowOptions();
|
||||
case 'severity':
|
||||
return this.renderSeverities();
|
||||
default:
|
||||
console.warn('Unknown escalation step placeholder');
|
||||
return '';
|
||||
|
|
@ -248,6 +250,34 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
);
|
||||
}
|
||||
|
||||
renderSeverities() {
|
||||
const {
|
||||
data,
|
||||
isDisabled,
|
||||
theme,
|
||||
store: { escalationPolicyStore },
|
||||
} = this.props;
|
||||
const styles = getEscalationPolicyStyles(theme);
|
||||
const { severity } = data;
|
||||
|
||||
return (
|
||||
<WithPermissionControlTooltip key="" userAction={UserActions.EscalationChainsWrite}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
disabled={isDisabled}
|
||||
placeholder="Severity"
|
||||
className={cx(styles.select, styles.control)}
|
||||
value={severity}
|
||||
onChange={this.getOnSelectChangeHandler('severity')}
|
||||
options={escalationPolicyStore.severityChoices.map((severity_choice) => ({
|
||||
value: severity_choice.value,
|
||||
label: severity_choice.display_name,
|
||||
}))}
|
||||
/>
|
||||
</WithPermissionControlTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
renderTimeRange() {
|
||||
const { data, isDisabled, theme } = this.props;
|
||||
const styles = getEscalationPolicyStyles(theme);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps)
|
|||
|
||||
useEffect(() => {
|
||||
escalationPolicyStore.updateWebEscalationPolicyOptions();
|
||||
escalationPolicyStore.updateSeverityOptions();
|
||||
}, []);
|
||||
|
||||
const handleSortEnd = useCallback(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { GRAFANA_HEADER_HEIGHT, StackSize } from 'helpers/consts';
|
||||
import { useDebouncedCallback, useResize } from 'helpers/hooks';
|
||||
import { useDebouncedCallback, useIsLoading, useResize } from 'helpers/hooks';
|
||||
import { observer } from 'mobx-react';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
|
||||
|
|
@ -51,6 +51,7 @@ import { DeletionModal } from 'containers/RotationForm/parts/DeletionModal';
|
|||
import { TimeUnitSelector } from 'containers/RotationForm/parts/TimeUnitSelector';
|
||||
import { UserItem } from 'containers/RotationForm/parts/UserItem';
|
||||
import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers';
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
import { getShiftName } from 'models/schedule/schedule.helpers';
|
||||
import { Schedule, Shift } from 'models/schedule/schedule.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
|
|
@ -114,6 +115,10 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
|
||||
const [startRotationFromUserIndex, setStartRotationFromUserIndex] = useState(0);
|
||||
|
||||
const isCreating = useIsLoading(ActionKey.CREATE_ONCALL_SHIFT);
|
||||
const isUpdating = useIsLoading(ActionKey.UPDATE_ONCALL_SHIFT);
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||
const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>(
|
||||
undefined
|
||||
|
|
@ -526,7 +531,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
const hasUpdatedShift = shift && shift.updated_shift;
|
||||
const ended = shift && shift.until && getDateTime(shift.until).isBefore(dayjs());
|
||||
|
||||
const disabled = hasUpdatedShift || ended;
|
||||
const disabled = hasUpdatedShift || ended || isSubmitting;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cx } from '@emotion/css';
|
|||
import { IconButton, Stack, Field, Button, useTheme2, useStyles2 } from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { StackSize } from 'helpers/consts';
|
||||
import { useDebouncedCallback, useResize } from 'helpers/hooks';
|
||||
import { useDebouncedCallback, useIsLoading, useResize } from 'helpers/hooks';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
|
||||
import { Modal } from 'components/Modal/Modal';
|
||||
|
|
@ -13,6 +13,7 @@ import { Text } from 'components/Text/Text';
|
|||
import { UserGroups } from 'components/UserGroups/UserGroups';
|
||||
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
|
||||
import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers';
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
import { getShiftName } from 'models/schedule/schedule.helpers';
|
||||
import { Schedule, Shift } from 'models/schedule/schedule.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
|
|
@ -64,6 +65,11 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
|
||||
const [offsetTop, setOffsetTop] = useState<number>(0);
|
||||
|
||||
const isCreating = useIsLoading(ActionKey.CREATE_ONCALL_SHIFT);
|
||||
const isUpdating = useIsLoading(ActionKey.UPDATE_ONCALL_SHIFT);
|
||||
const isDeleting = useIsLoading(ActionKey.DELETE_ONCALL_SHIFT);
|
||||
const isSubmitting = isCreating || isUpdating || isDeleting;
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||
|
|
@ -197,7 +203,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]);
|
||||
|
||||
const ended = shift && shift.until && getDateTime(shift.until).isBefore(dayjs());
|
||||
const disabled = ended;
|
||||
const disabled = ended || isSubmitting;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -230,7 +236,13 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
<Stack>
|
||||
{shiftId !== 'new' && (
|
||||
<WithConfirm title="Are you sure you want to delete override?">
|
||||
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
tooltip="Delete"
|
||||
name="trash-alt"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</WithConfirm>
|
||||
)}
|
||||
<IconButton aria-label="Drag" variant="secondary" className="drag-handler" name="draggabledots" />
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import React, { ChangeEvent, useCallback, useState } from 'react';
|
|||
|
||||
import { Stack, Modal as GrafanaModal, Button, InlineSwitch, useStyles2 } from '@grafana/ui';
|
||||
import { StackSize } from 'helpers/consts';
|
||||
import { useIsLoading } from 'helpers/hooks';
|
||||
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { getRotationFormStyles } from 'containers/RotationForm/RotationForm.styles';
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
|
||||
interface DeletionModalProps {
|
||||
onHide: () => void;
|
||||
|
|
@ -13,6 +15,7 @@ interface DeletionModalProps {
|
|||
|
||||
export const DeletionModal = ({ onHide, onConfirm }: DeletionModalProps) => {
|
||||
const [isForceDelete, setIsForceDelete] = useState<boolean>(false);
|
||||
const isDeleting = useIsLoading(ActionKey.DELETE_ONCALL_SHIFT);
|
||||
|
||||
const styles = useStyles2(getRotationFormStyles);
|
||||
|
||||
|
|
@ -46,7 +49,7 @@ export const DeletionModal = ({ onHide, onConfirm }: DeletionModalProps) => {
|
|||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmClick}>
|
||||
<Button variant="destructive" onClick={handleConfirmClick} disabled={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export enum TimeLineRealm {
|
|||
export interface TimeLineItem {
|
||||
action: string;
|
||||
author: ApiSchemas['User'] | null;
|
||||
escalation_chain: TimelineLink | null;
|
||||
incident: DeclaredIncident | null;
|
||||
schedule: TimelineLink | null;
|
||||
webhook: TimelineLink | null;
|
||||
created_at: string;
|
||||
realm: TimeLineRealm;
|
||||
time: string;
|
||||
|
|
@ -55,3 +59,13 @@ interface RenderForWeb {
|
|||
image_url: string;
|
||||
source_link: string;
|
||||
}
|
||||
|
||||
interface DeclaredIncident {
|
||||
incident_link: string;
|
||||
incident_title: string;
|
||||
}
|
||||
|
||||
interface TimelineLink {
|
||||
pk: string;
|
||||
title: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { EscalationPolicy } from 'models/escalation_policy/escalation_policy.typ
|
|||
import { makeRequest } from 'network/network';
|
||||
import { move } from 'state/helpers';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
import { SelectOption } from 'state/types';
|
||||
|
||||
export class EscalationPolicyStore extends BaseStore {
|
||||
@observable.shallow
|
||||
|
|
@ -19,6 +20,9 @@ export class EscalationPolicyStore extends BaseStore {
|
|||
@observable
|
||||
escalationChoices: any = [];
|
||||
|
||||
@observable
|
||||
severityChoices: SelectOption[] = [];
|
||||
|
||||
@observable
|
||||
webEscalationChoices: any = [];
|
||||
|
||||
|
|
@ -30,6 +34,15 @@ export class EscalationPolicyStore extends BaseStore {
|
|||
this.path = '/escalation_policies/';
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async updateSeverityOptions() {
|
||||
const response = await makeRequest<SelectOption[]>('/escalation_policies/severity_options/', {});
|
||||
|
||||
runInAction(() => {
|
||||
this.severityChoices = response;
|
||||
});
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async updateWebEscalationPolicyOptions() {
|
||||
const response = await makeRequest('/escalation_policies/escalation_options/', {});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface EscalationPolicy {
|
|||
important: boolean | null;
|
||||
num_alerts_in_window: number;
|
||||
num_minutes_in_window: number;
|
||||
severity: string | null;
|
||||
}
|
||||
|
||||
export interface EscalationPolicyOption {
|
||||
|
|
|
|||
|
|
@ -21,4 +21,7 @@ export enum ActionKey {
|
|||
FETCH_INTEGRATIONS_AVAILABLE_FOR_CONNECTION = 'FETCH_INTEGRATIONS_AVAILABLE_FOR_CONNECTION',
|
||||
FETCH_WEBHOOKS = 'FETCH_WEBHOOKS',
|
||||
TRIGGER_MANUAL_WEBHOOK = 'TRIGGER_MANUAL_WEBHOOK',
|
||||
CREATE_ONCALL_SHIFT = 'CREATE_ONCALL_SHIFT',
|
||||
UPDATE_ONCALL_SHIFT = 'UPDATE_ONCALL_SHIFT',
|
||||
DELETE_ONCALL_SHIFT = 'DELETE_ONCALL_SHIFT',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ export class ScheduleStore extends BaseStore {
|
|||
// ------- NEW SCHEDULES API ENDPOINTS ---------
|
||||
|
||||
@action.bound
|
||||
@AutoLoadingState(ActionKey.CREATE_ONCALL_SHIFT)
|
||||
async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params: Partial<Shift>) {
|
||||
const type = isOverride ? 3 : 2;
|
||||
|
||||
|
|
@ -364,6 +365,7 @@ export class ScheduleStore extends BaseStore {
|
|||
}
|
||||
|
||||
@action.bound
|
||||
@AutoLoadingState(ActionKey.UPDATE_ONCALL_SHIFT)
|
||||
async updateRotation(shiftId: Shift['id'], params: Partial<Shift>) {
|
||||
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {
|
||||
params: { force: true },
|
||||
|
|
@ -382,6 +384,7 @@ export class ScheduleStore extends BaseStore {
|
|||
}
|
||||
|
||||
@action.bound
|
||||
@AutoLoadingState(ActionKey.UPDATE_ONCALL_SHIFT)
|
||||
async updateRotationAsNew(shiftId: Shift['id'], params: Partial<Shift>) {
|
||||
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {
|
||||
data: { ...params },
|
||||
|
|
@ -489,6 +492,7 @@ export class ScheduleStore extends BaseStore {
|
|||
return response;
|
||||
}
|
||||
|
||||
@AutoLoadingState(ActionKey.DELETE_ONCALL_SHIFT)
|
||||
async deleteOncallShift(shiftId: Shift['id'], force?: boolean) {
|
||||
try {
|
||||
return await makeRequest(`/oncall_shifts/${shiftId}`, {
|
||||
|
|
|
|||
|
|
@ -661,6 +661,30 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
<Text underline>{entity.author?.username}</Text>
|
||||
</a>
|
||||
);
|
||||
case 'escalation_chain':
|
||||
return (
|
||||
<a href={`${PLUGIN_ROOT}/escalations/${entity.escalation_chain?.pk}`} target="_blank" rel="noopener noreferrer">
|
||||
<Text underline>{entity.escalation_chain?.title}</Text>
|
||||
</a>
|
||||
);
|
||||
case 'related_incident':
|
||||
return (
|
||||
<a href={entity.incident?.incident_link} target="_blank" rel="noopener noreferrer">
|
||||
<Text underline>{entity.incident?.incident_title}</Text>
|
||||
</a>
|
||||
);
|
||||
case 'schedule':
|
||||
return (
|
||||
<a href={`${PLUGIN_ROOT}/schedules/${entity.schedule?.pk}`} target="_blank" rel="noopener noreferrer">
|
||||
<Text underline>{entity.schedule?.title}</Text>
|
||||
</a>
|
||||
);
|
||||
case 'webhook':
|
||||
return (
|
||||
<a href={`${PLUGIN_ROOT}/outgoing_webhooks/${entity.webhook?.pk}`} target="_blank" rel="noopener noreferrer">
|
||||
<Text underline>{entity.webhook?.title}</Text>
|
||||
</a>
|
||||
);
|
||||
default:
|
||||
return '{{' + match + '}}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
export const GIT_COMMIT = 'dev';
|
||||
|
||||
// Declare a constant that will be updated by release-please action
|
||||
export const CURRENT_VERSION = '1.9.27' as string; // x-release-please-version
|
||||
export const CURRENT_VERSION = '1.9.28' as string; // x-release-please-version
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue