mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
import {Fragment, useMemo, useState} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import moment from 'moment';
|
|
|
|
import type {APIRequestMethod} from 'sentry/api';
|
|
import {Button} from 'sentry/components/button';
|
|
import {CompactSelect} from 'sentry/components/compactSelect';
|
|
import Input from 'sentry/components/input';
|
|
import {IconAdd, IconClose, IconDelete, IconEdit} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Project} from 'sentry/types';
|
|
import {getExactDuration, parseLargestSuffix} from 'sentry/utils/formatters';
|
|
import {capitalize} from 'sentry/utils/string/capitalize';
|
|
import useApi from 'sentry/utils/useApi';
|
|
import useOrganization from 'sentry/utils/useOrganization';
|
|
|
|
import {
|
|
CRASH_FREE_SESSION_RATE_STR,
|
|
CRASH_FREE_USER_RATE_STR as _CRASH_FREE_USER_RATE_STR,
|
|
FAILURE_RATE_STR as _FAILURE_RATE_STR,
|
|
NEW_ISSUE_COUNT_STR,
|
|
NEW_THRESHOLD_PREFIX,
|
|
REGRESSED_ISSUE_COUNT_STR as _REGRESSED_ISSUE_COUNT_STR,
|
|
TOTAL_ERROR_COUNT_STR,
|
|
UNHANDLED_ISSUE_COUNT_STR as _UNHANDLED_ISSUE_COUNT_STR,
|
|
} from '../utils/constants';
|
|
import type {EditingThreshold, Threshold} from '../utils/types';
|
|
|
|
type Props = {
|
|
allEnvironmentNames: string[];
|
|
isLastRow: boolean;
|
|
project: Project;
|
|
refetch: () => void;
|
|
setTempError: (msg: string) => void;
|
|
newGroup?: boolean;
|
|
onFormClose?: (id: string) => void;
|
|
threshold?: Threshold;
|
|
};
|
|
|
|
export function ThresholdGroupRows({
|
|
allEnvironmentNames,
|
|
isLastRow,
|
|
newGroup = false,
|
|
onFormClose,
|
|
project,
|
|
refetch,
|
|
setTempError,
|
|
threshold: initialThreshold,
|
|
}: Props) {
|
|
const [editingThresholds, setEditingThresholds] = useState<{
|
|
[key: string]: EditingThreshold;
|
|
}>(() => {
|
|
const editingThreshold = {};
|
|
if (newGroup) {
|
|
const [windowValue, windowSuffix] = parseLargestSuffix(0);
|
|
const id = `${NEW_THRESHOLD_PREFIX}`;
|
|
const newGroupEdit = {
|
|
id,
|
|
project,
|
|
windowValue,
|
|
windowSuffix,
|
|
threshold_type: 'total_error_count',
|
|
trigger_type: 'over',
|
|
value: 0,
|
|
hasError: false,
|
|
};
|
|
editingThreshold[id] = newGroupEdit;
|
|
}
|
|
return editingThreshold;
|
|
});
|
|
const [newThresholdIterator, setNewThresholdIterator] = useState<number>(0); // used simply to initialize new threshold
|
|
const api = useApi();
|
|
const organization = useOrganization();
|
|
|
|
const thresholdIdSet = useMemo(() => {
|
|
const initial = new Set<string>([]);
|
|
if (initialThreshold) {
|
|
initial.add(initialThreshold.id);
|
|
}
|
|
return new Set([...initial, ...Object.keys(editingThresholds)]);
|
|
}, [initialThreshold, editingThresholds]);
|
|
|
|
const thresholdTypeList = useMemo(() => {
|
|
const isInternal = organization.features?.includes('releases-v2-internal');
|
|
const list = [
|
|
{
|
|
value: TOTAL_ERROR_COUNT_STR,
|
|
textValue: 'Errors',
|
|
label: 'Error Count',
|
|
},
|
|
];
|
|
if (isInternal) {
|
|
list.push(
|
|
{
|
|
value: CRASH_FREE_SESSION_RATE_STR,
|
|
textValue: 'Crash Free Sessions',
|
|
label: 'Crash Free Sessions',
|
|
},
|
|
{
|
|
value: NEW_ISSUE_COUNT_STR,
|
|
textValue: 'New Issue Count',
|
|
label: 'New Issue Count',
|
|
}
|
|
);
|
|
}
|
|
return list;
|
|
}, [organization]);
|
|
|
|
const windowOptions = thresholdType => {
|
|
let options = [
|
|
{
|
|
value: 'hours',
|
|
textValue: 'hours',
|
|
label: 'hrs',
|
|
},
|
|
{
|
|
value: 'days',
|
|
textValue: 'days',
|
|
label: 'days',
|
|
},
|
|
];
|
|
if (thresholdType !== CRASH_FREE_SESSION_RATE_STR) {
|
|
options = [
|
|
{
|
|
value: 'seconds',
|
|
textValue: 'seconds',
|
|
label: 's',
|
|
},
|
|
{
|
|
value: 'minutes',
|
|
textValue: 'minutes',
|
|
label: 'min',
|
|
},
|
|
...options,
|
|
];
|
|
}
|
|
return options;
|
|
};
|
|
|
|
const initializeNewThreshold = (
|
|
environmentName: string | undefined = undefined,
|
|
defaultWindow: number = 0
|
|
) => {
|
|
if (!project) {
|
|
setTempError('No project provided');
|
|
return;
|
|
}
|
|
const thresholdId = `${NEW_THRESHOLD_PREFIX}-${newThresholdIterator}`;
|
|
const [windowValue, windowSuffix] = parseLargestSuffix(defaultWindow);
|
|
const newThreshold: EditingThreshold = {
|
|
id: thresholdId,
|
|
project,
|
|
environmentName,
|
|
windowValue,
|
|
windowSuffix,
|
|
threshold_type: 'total_error_count',
|
|
trigger_type: 'over',
|
|
value: 0,
|
|
hasError: false,
|
|
};
|
|
const updatedEditingThresholds = {...editingThresholds};
|
|
updatedEditingThresholds[thresholdId] = newThreshold;
|
|
setEditingThresholds(updatedEditingThresholds);
|
|
setNewThresholdIterator(newThresholdIterator + 1);
|
|
};
|
|
|
|
const enableEditThreshold = (threshold: Threshold) => {
|
|
const updatedEditingThresholds = {...editingThresholds};
|
|
const [windowValue, windowSuffix] = parseLargestSuffix(threshold.window_in_seconds);
|
|
updatedEditingThresholds[threshold.id] = {
|
|
...JSON.parse(JSON.stringify(threshold)), // Deep copy the original threshold object
|
|
environmentName: threshold.environment ? threshold.environment.name : '', // convert environment to string for editing
|
|
windowValue,
|
|
windowSuffix,
|
|
hasError: false,
|
|
};
|
|
setEditingThresholds(updatedEditingThresholds);
|
|
};
|
|
|
|
const saveThreshold = (saveIds: string[]) => {
|
|
saveIds.forEach(id => {
|
|
const thresholdData = editingThresholds[id];
|
|
const seconds = moment
|
|
.duration(thresholdData.windowValue, thresholdData.windowSuffix)
|
|
.as('seconds');
|
|
if (!thresholdData.project) {
|
|
setTempError('Project required');
|
|
return;
|
|
}
|
|
const submitData = {
|
|
...thresholdData,
|
|
environment: thresholdData.environmentName,
|
|
window_in_seconds: seconds,
|
|
};
|
|
let path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/${id}/`;
|
|
let method: APIRequestMethod = 'PUT';
|
|
if (id.includes(NEW_THRESHOLD_PREFIX)) {
|
|
path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/`;
|
|
method = 'POST';
|
|
}
|
|
const request = api.requestPromise(path, {
|
|
method,
|
|
data: submitData,
|
|
});
|
|
request
|
|
.then(() => {
|
|
refetch();
|
|
closeEditForm(id);
|
|
if (onFormClose) {
|
|
onFormClose(id);
|
|
}
|
|
})
|
|
.catch(_err => {
|
|
setTempError('Issue saving threshold');
|
|
setEditingThresholds(prevState => {
|
|
const errorThreshold = {
|
|
...submitData,
|
|
hasError: true,
|
|
};
|
|
const updatedEditingThresholds = {...prevState};
|
|
updatedEditingThresholds[id] = errorThreshold;
|
|
return updatedEditingThresholds;
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
const deleteThreshold = thresholdId => {
|
|
const updatedEditingThresholds = {...editingThresholds};
|
|
const thresholdData = editingThresholds[thresholdId];
|
|
const path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/${thresholdId}/`;
|
|
const method = 'DELETE';
|
|
if (!thresholdId.includes(NEW_THRESHOLD_PREFIX)) {
|
|
const request = api.requestPromise(path, {
|
|
method,
|
|
});
|
|
request
|
|
.then(() => {
|
|
refetch();
|
|
})
|
|
.catch(_err => {
|
|
setTempError('Issue deleting threshold');
|
|
const errorThreshold = {
|
|
...thresholdData,
|
|
hasError: true,
|
|
};
|
|
updatedEditingThresholds[thresholdId] = errorThreshold as EditingThreshold;
|
|
setEditingThresholds(updatedEditingThresholds);
|
|
});
|
|
}
|
|
delete updatedEditingThresholds[thresholdId];
|
|
setEditingThresholds(updatedEditingThresholds);
|
|
};
|
|
|
|
const closeEditForm = thresholdId => {
|
|
const updatedEditingThresholds = {...editingThresholds};
|
|
delete updatedEditingThresholds[thresholdId];
|
|
setEditingThresholds(updatedEditingThresholds);
|
|
if (onFormClose) {
|
|
onFormClose(thresholdId);
|
|
}
|
|
};
|
|
|
|
const editThresholdState = (thresholdId, key, value) => {
|
|
if (editingThresholds[thresholdId]) {
|
|
const updateEditing = JSON.parse(JSON.stringify(editingThresholds));
|
|
const currentThresholdValues = updateEditing[thresholdId];
|
|
updateEditing[thresholdId][key] = value;
|
|
if (key === 'threshold_type' && value === CRASH_FREE_SESSION_RATE_STR) {
|
|
if (['seconds', 'minutes'].indexOf(currentThresholdValues.windowSuffix) > -1) {
|
|
updateEditing[thresholdId].windowSuffix = 'hours';
|
|
}
|
|
}
|
|
setEditingThresholds(updateEditing);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<StyledThresholdGroup>
|
|
{Array.from(thresholdIdSet).map((tId: string, idx: number) => {
|
|
const isEditing = tId in editingThresholds;
|
|
// NOTE: we're casting the threshold type because we can't dynamically derive type below
|
|
const threshold = isEditing
|
|
? (editingThresholds[tId] as EditingThreshold)
|
|
: (initialThreshold as Threshold);
|
|
|
|
return (
|
|
<StyledRow
|
|
key={threshold.id}
|
|
lastRow={isLastRow && idx === thresholdIdSet.size - 1}
|
|
hasError={isEditing && (threshold as EditingThreshold).hasError}
|
|
>
|
|
{/* ENV ONLY EDITABLE IF NEW */}
|
|
{!initialThreshold || threshold.id !== initialThreshold.id ? (
|
|
<CompactSelect
|
|
style={{width: '100%'}}
|
|
value={(threshold as EditingThreshold).environmentName || ''}
|
|
onChange={selectedOption =>
|
|
editThresholdState(
|
|
threshold.id,
|
|
'environmentName',
|
|
selectedOption.value
|
|
)
|
|
}
|
|
options={[
|
|
{
|
|
value: '',
|
|
textValue: '',
|
|
label: '',
|
|
},
|
|
...allEnvironmentNames.map(env => ({
|
|
value: env,
|
|
textValue: env,
|
|
label: env,
|
|
})),
|
|
]}
|
|
/>
|
|
) : (
|
|
<FlexCenter>
|
|
{/* '' means it _has_ an environment, but the env has no name */}
|
|
{(threshold as Threshold).environment
|
|
? (threshold as Threshold).environment.name || ''
|
|
: '{No environment}'}
|
|
</FlexCenter>
|
|
)}
|
|
{/* FOLLOWING COLUMNS ARE EDITABLE */}
|
|
{isEditing ? (
|
|
<Fragment>
|
|
<FlexCenter>
|
|
<Input
|
|
style={{width: '50%'}}
|
|
value={(threshold as EditingThreshold).windowValue}
|
|
type="number"
|
|
min={0}
|
|
onChange={e =>
|
|
editThresholdState(threshold.id, 'windowValue', e.target.value)
|
|
}
|
|
/>
|
|
<CompactSelect
|
|
style={{width: '50%'}}
|
|
value={(threshold as EditingThreshold).windowSuffix}
|
|
onChange={selectedOption =>
|
|
editThresholdState(
|
|
threshold.id,
|
|
'windowSuffix',
|
|
selectedOption.value
|
|
)
|
|
}
|
|
options={windowOptions(threshold.threshold_type)}
|
|
/>
|
|
</FlexCenter>
|
|
<FlexCenter>
|
|
<CompactSelect
|
|
value={threshold.threshold_type}
|
|
onChange={selectedOption =>
|
|
editThresholdState(
|
|
threshold.id,
|
|
'threshold_type',
|
|
selectedOption.value
|
|
)
|
|
}
|
|
options={thresholdTypeList}
|
|
/>
|
|
{threshold.trigger_type === 'over' ? (
|
|
<Button
|
|
onClick={() =>
|
|
editThresholdState(threshold.id, 'trigger_type', 'under')
|
|
}
|
|
>
|
|
>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={() =>
|
|
editThresholdState(threshold.id, 'trigger_type', 'over')
|
|
}
|
|
>
|
|
<
|
|
</Button>
|
|
)}
|
|
<Input
|
|
value={threshold.value}
|
|
type="number"
|
|
min={0}
|
|
onChange={e =>
|
|
editThresholdState(threshold.id, 'value', e.target.value)
|
|
}
|
|
/>
|
|
</FlexCenter>
|
|
</Fragment>
|
|
) : (
|
|
<Fragment>
|
|
<FlexCenter>
|
|
{getExactDuration(
|
|
(threshold as Threshold).window_in_seconds || 0,
|
|
false,
|
|
'seconds'
|
|
)}
|
|
</FlexCenter>
|
|
<FlexCenter>
|
|
<div>
|
|
{threshold.threshold_type
|
|
.split('_')
|
|
.map(word => capitalize(word))
|
|
.join(' ')}
|
|
</div>
|
|
<div> {threshold.trigger_type === 'over' ? '>' : '<'} </div>
|
|
<div>{threshold.value}</div>
|
|
</FlexCenter>
|
|
</Fragment>
|
|
)}
|
|
{/* END OF EDITABLE COLUMNS */}
|
|
<ActionsColumn>
|
|
{isEditing ? (
|
|
<Fragment>
|
|
<Button size="xs" onClick={() => saveThreshold([threshold.id])}>
|
|
Save
|
|
</Button>
|
|
{!threshold.id.includes(NEW_THRESHOLD_PREFIX) && (
|
|
<Button
|
|
aria-label={t('Delete threshold')}
|
|
borderless
|
|
icon={<IconDelete color="danger" />}
|
|
onClick={() => deleteThreshold(threshold.id)}
|
|
size="xs"
|
|
/>
|
|
)}
|
|
<Button
|
|
aria-label={t('Close')}
|
|
borderless
|
|
icon={<IconClose />}
|
|
onClick={() => closeEditForm(threshold.id)}
|
|
size="xs"
|
|
/>
|
|
</Fragment>
|
|
) : (
|
|
<Fragment>
|
|
<Button
|
|
aria-label={t('Edit threshold')}
|
|
icon={<IconEdit />}
|
|
onClick={() => enableEditThreshold(threshold as Threshold)}
|
|
size="xs"
|
|
/>
|
|
<Button
|
|
aria-label={t('New Threshold')}
|
|
icon={<IconAdd color="activeText" isCircled />}
|
|
onClick={() =>
|
|
initializeNewThreshold(
|
|
initialThreshold?.environment
|
|
? initialThreshold.environment.name
|
|
: undefined,
|
|
initialThreshold ? initialThreshold.window_in_seconds : 0
|
|
)
|
|
}
|
|
size="xs"
|
|
/>
|
|
</Fragment>
|
|
)}
|
|
</ActionsColumn>
|
|
</StyledRow>
|
|
);
|
|
})}
|
|
</StyledThresholdGroup>
|
|
);
|
|
}
|
|
|
|
const StyledThresholdGroup = styled('div')`
|
|
display: contents;
|
|
`;
|
|
|
|
type StyledThresholdRowProps = {
|
|
lastRow: boolean;
|
|
hasError?: boolean;
|
|
};
|
|
const StyledRow = styled('div')<StyledThresholdRowProps>`
|
|
display: contents;
|
|
> * {
|
|
padding: ${space(2)};
|
|
background-color: ${p =>
|
|
p.hasError ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0)'};
|
|
border-bottom: ${p => (p.lastRow ? 0 : '1px solid ' + p.theme.border)};
|
|
}
|
|
`;
|
|
|
|
const FlexCenter = styled('div')`
|
|
display: flex;
|
|
align-items: center;
|
|
> * {
|
|
margin: 0 ${space(1)};
|
|
}
|
|
`;
|
|
|
|
const ActionsColumn = styled('div')`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-around;
|
|
`;
|