diff --git a/public/i18n/en/common.json b/public/i18n/en/common.json index 3e7c7de2d..506b5e15a 100644 --- a/public/i18n/en/common.json +++ b/public/i18n/en/common.json @@ -7,7 +7,7 @@ "Cluster": "Cluster", "Cluster_plural": "Clusters", "Clusters": "Clusters", - "DELETE_RESOURCE": "Delete {{resourceName}} {{resourceType}}", + "DELETE_RESOURCE": "Delete {{resourceName}} {{resourceType}}?", "DELETE_RESOURCE_CONFIRMATION": "Type the name of the {{resourceType}} (\"{{resourceName}}\") to confirm.", "DRONE_MESSAGE": "Drone <1>build {{id}} {{status}} for commit <6>{{sha}} at {{datetime}}.", "Dashboard": "Dashboard", diff --git a/public/logos/email_logo.svg b/public/logos/email_logo.svg new file mode 100644 index 000000000..45eef72ee --- /dev/null +++ b/public/logos/email_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/logos/opsGenie_logo.svg b/public/logos/opsGenie_logo.svg new file mode 100644 index 000000000..85db6cf6d --- /dev/null +++ b/public/logos/opsGenie_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/logos/slack_logo.svg b/public/logos/slack_logo.svg new file mode 100644 index 000000000..427a8e5f2 --- /dev/null +++ b/public/logos/slack_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/logos/teams_logo.svg b/public/logos/teams_logo.svg new file mode 100644 index 000000000..5ae5907d0 --- /dev/null +++ b/public/logos/teams_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index 84c3709bb..2e172bdb6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,8 +22,8 @@ import Service from 'pages/Service' import Services from 'pages/Services' import Setting from 'pages/Setting' import SettingsOverview from 'pages/SettingsOverview' -import Team from 'pages/Team' -import Teams from 'pages/Teams' +import Team from 'pages/teams/create-edit' +import Teams from 'pages/teams/overview' import Policies from 'pages/Policies' import SessionProvider from 'providers/Session' import ThemeProvider from 'theme' diff --git a/src/components/AdvancedSettings.tsx b/src/components/AdvancedSettings.tsx new file mode 100644 index 000000000..f2b31b282 --- /dev/null +++ b/src/components/AdvancedSettings.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import { styled } from '@mui/material/styles' +import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material' +import { KeyboardArrowRight } from '@mui/icons-material' +import { Typography } from './Typography' + +const StyledTitle = styled(Typography)(({ theme }) => ({ + marginTop: 0, + color: theme.palette.cl.text.title, +})) + +const StyledDescription = styled(Typography)(({ theme }) => ({ + color: theme.palette.cl.text.subTitle, +})) + +const StyledAccordion = styled(Accordion)(({ theme }) => ({ + backgroundColor: 'transparent', + boxShadow: 'none !important', + margin: '0px !important', + '&:before': { + display: 'none', // Remove the default border above the accordion + }, +})) + +const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ + backgroundColor: 'transparent', + boxShadow: 'none', + marginTop: '0px', + padding: 0, + '&:before': { + display: 'none', // Remove the default border above the accordion + }, +})) + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + padding: '0', + '.MuiAccordionSummary-content': { + margin: '0', // Remove margin between text and icon + }, + marginTop: '0px !important', + display: 'inline-flex', +})) + +interface Props { + description?: string + title?: string + children?: React.ReactNode + noPaddingTop?: boolean +} + +export default function AdvancedSettings(props: Props) { + const { title = 'Advanced Settings', description, children, noPaddingTop } = props + const [expanded, setExpanded] = useState(true) + + const handleAccordionChange = () => { + setExpanded((prev) => !prev) + } + + return ( + + } + sx={{ + '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { + transform: 'rotate(90deg)', + }, + }} + > +
+ {title && {title}} + {description && {description}} +
+
+ {children} +
+ ) +} diff --git a/src/components/ControlledBox.tsx b/src/components/ControlledBox.tsx new file mode 100644 index 000000000..a9550d0aa --- /dev/null +++ b/src/components/ControlledBox.tsx @@ -0,0 +1,16 @@ +import Box, { BoxProps } from '@mui/material/Box' +import { styled } from '@mui/material/styles' + +interface ControlledBoxProps extends BoxProps { + disabled?: boolean +} + +const ControlledBox = styled(Box, { + shouldForwardProp: (prop) => prop !== 'disabled', +})(({ disabled }) => ({ + opacity: disabled ? 0.5 : 1, + pointerEvents: disabled ? 'none' : 'auto', + cursor: disabled ? 'default' : 'text', +})) + +export default ControlledBox diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 0725df0aa..7fef2f36c 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,12 +1,12 @@ import { Box, Card, Grid, Typography, useTheme } from '@mui/material' import { useSession } from 'providers/Session' import * as React from 'react' -import { GetTeamApiResponse } from 'redux/otomiApi' import { makeStyles } from 'tss-react/mui' import { getDomain } from 'layouts/Shell' import useSettings from 'hooks/useSettings' import Link from '@mui/material/Link' import { Link as RouterLink } from 'react-router-dom' +import { GetTeamApiResponse } from 'redux/otomiApi' import UpgradeVersion from './UpgradeVersion' // styles ----------------------------------------------------------- diff --git a/src/components/ImgButtonGroup.tsx b/src/components/ImgButtonGroup.tsx index 6c5628d1d..bb98cd8b6 100644 --- a/src/components/ImgButtonGroup.tsx +++ b/src/components/ImgButtonGroup.tsx @@ -30,6 +30,7 @@ const StyledTypography = styled(Typography)<{ selected: boolean }>(({ theme, sel interface ImgButtonGroupProps { title?: string + description?: string name: string control: any value: string @@ -38,7 +39,16 @@ interface ImgButtonGroupProps { disabled?: boolean } -function ImgButtonGroup({ title, name, control, value, options, onChange, disabled = false }: ImgButtonGroupProps) { +function ImgButtonGroup({ + title, + description, + name, + control, + value, + options, + onChange, + disabled = false, +}: ImgButtonGroupProps) { return ( {title} + {description && {description}} )} diff --git a/src/components/InformationBanner.tsx b/src/components/InformationBanner.tsx index 1467bbbbf..ad01db56d 100644 --- a/src/components/InformationBanner.tsx +++ b/src/components/InformationBanner.tsx @@ -1,25 +1,28 @@ -import { Box, Typography, styled, useTheme } from '@mui/material' +import { Box, SxProps, Typography, styled, useTheme } from '@mui/material' import React from 'react' import Iconify from './Iconify' -const StyledInfoBanner = styled(Box)({ +const StyledInfoBanner = styled(Box)<{ small?: boolean }>(({ theme, small }) => ({ backgroundColor: '#f2f2894d', - padding: '10px', + padding: small ? '5px' : '10px', border: '1px solid #d4d402', borderRadius: '8px', display: 'flex', alignItems: 'center', -}) + maxWidth: small ? '800px' : 'inherit', +})) interface Props { - message: string + message: string | React.ReactNode + small?: boolean children?: React.ReactNode + sx?: SxProps } -export default function InformationBanner({ message, children }: Props) { +export default function InformationBanner({ message, children, small, sx }: Props) { const theme = useTheme() return ( - + {message} {children} diff --git a/src/components/KeyValue.tsx b/src/components/KeyValue.tsx new file mode 100644 index 000000000..f7023d531 --- /dev/null +++ b/src/components/KeyValue.tsx @@ -0,0 +1,257 @@ +import React from 'react' +import { Box, Button, IconButton, StandardTextFieldProps } from '@mui/material' +import { TextField } from 'components/forms/TextField' +import { makeStyles } from 'tss-react/mui' +import { Theme } from '@mui/material/styles' +import { Add, Clear } from '@mui/icons-material' +import { Typography } from 'components/Typography' +import { InputLabel } from 'components/InputLabel' +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form' +import FormRow from 'components/forms/FormRow' +import { FormHelperText } from 'components/FormHelperText' +import { InputAdornment } from './InputAdornment' + +const useStyles = makeStyles()((theme: Theme) => ({ + container: { + padding: '16px', + backgroundColor: '#424242', + borderRadius: '8px', + }, + itemRow: { + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + }, + addItemButton: { + marginLeft: '-10px', + display: 'flex', + alignItems: 'center', + textTransform: 'none', + }, + errorText: { + alignItems: 'center', + color: '#d63c42', + display: 'flex', + left: 5, + top: 42, + width: '100%', + }, + helperTextTop: { + color: theme.palette.cl.text.subTitle, + marginTop: 0, + }, + inputLabel: { + color: theme.palette.cl.text.title, + }, + label: { + fontFamily: 'sans-serif', + }, + decorator: { + borderLeft: '1px solid #777777', + height: 'auto', + padding: '7px', + width: '65px', + textAlign: 'right', + backgroundColor: theme.palette.cm.disabledBackground, + display: 'flex', + justifyContent: 'flex-end', + }, + decoratortext: { + fontWeight: 'bold', + fontSize: '10px', + color: theme.palette.cl.text.title, + }, +})) + +interface TextFieldPropsOverrides extends StandardTextFieldProps { + label: string +} + +export interface KeyValueItem { + name: string + value: string +} + +interface KeyValueProps { + title: string + subTitle?: string + keyLabel: string + keyValue?: string + keyDisabled?: boolean + helperText?: string + helperTextPosition?: 'bottom' | 'top' + showLabel?: boolean + valueLabel: string + valueDisabled?: boolean + addLabel?: string + label?: string + error?: boolean + name: string + // determines the margin-top between key/value pairs + compressed?: boolean + // disable all fields and remove buttons + disabled?: boolean + // used when section is disabled by checkbox, prevent user input but leaves styling untouched + frozen?: boolean + keySize?: 'small' | 'medium' | 'large' + valueSize?: 'small' | 'medium' | 'large' + onlyValue?: boolean + errorText?: string + // optional filter function. It receives a field and its original index. + filterFn?: (item: KeyValueItem & { id: string }, index: number) => boolean + // hide filtered fields when filterFn is provided and empty + hideWhenEmpty?: boolean + decoratorMapping?: Record +} + +// This local subcomponent watches the key field (using its path) and checks the provided +// decoratorMapping. If a matching decorator exists, it is rendered as an InputAdornment. +function DecoratorAdornment({ + name, + index, + keyLabel, + decoratorMapping, + classes, +}: { + name: string + index: number + keyLabel: string + decoratorMapping: Record + classes: Record +}) { + const { control } = useFormContext() + const keyFieldPath = `${name}.${index}.${keyLabel.toLowerCase()}` + const keyValue = useWatch({ control, name: keyFieldPath }) as string + const decorator = decoratorMapping[keyValue] + if (!decorator) return null + return ( + + {decorator} + + ) +} + +export default function KeyValue(props: KeyValueProps) { + const { classes, cx } = useStyles() + const { control, register } = useFormContext() + + const { + title, + subTitle, + keyLabel, + valueLabel, + addLabel, + compressed = false, + disabled = false, + frozen = false, + name, + label, + helperText, + helperTextPosition, + onlyValue, + keyValue, + keySize = 'medium', + valueSize = 'medium', + error, + errorText, + keyDisabled = false, + showLabel = true, + valueDisabled = false, + hideWhenEmpty = false, + filterFn, + decoratorMapping, + } = props + + const { fields, append, remove } = useFieldArray({ control, name }) + + // Map fields with their original index. + const mappedFields = fields.map((field, index) => ({ field, index })) + // Apply filtering if filterFn is provided. + const filteredFields = filterFn + ? mappedFields.filter(({ field, index }) => filterFn(field as KeyValueItem & { id: string }, index)) + : mappedFields + + if (filterFn && hideWhenEmpty && filteredFields.length === 0) return null + + const handleAddItem = () => { + append(onlyValue ? '' : { [keyLabel.toLowerCase()]: '', [valueLabel.toLowerCase()]: '' }) + } + + const errorScrollClassName = 'error-for-scroll' + return ( + + + {title} + + {subTitle && {subTitle}} + + {filteredFields.map(({ field, index }, localIndex) => ( + + + + + ) : null, + }} + /> + + {addLabel && !disabled && ( + remove(index)}> + + + )} + + ))} + {addLabel && !disabled && ( + + )} + {errorText && ( + + {errorText} + + )} + {helperText && (helperTextPosition === 'bottom' || !helperTextPosition) && ( + {helperText} + )} + + ) +} diff --git a/src/components/NavConfig.tsx b/src/components/NavConfig.tsx index 157e705cd..e44b513b7 100644 --- a/src/components/NavConfig.tsx +++ b/src/components/NavConfig.tsx @@ -90,20 +90,20 @@ export default function NavConfig() { title: 'Shell', path: `/cloudtty`, icon: getIcon('shell_icon.svg'), - disabled: process.env.NODE_ENV !== 'production' || !canDo(user, oboTeamId, 'shell'), + disabled: process.env.NODE_ENV !== 'production' || !canDo(user, oboTeamId, 'useCloudShell'), }, { title: 'Download KUBECFG', path: `/api/v1/kubecfg/${oboTeamId}`, icon: getIcon('download_icon.svg'), - disabled: oboTeamId === 'admin' || !canDo(user, oboTeamId, 'downloadKubeConfig'), + disabled: oboTeamId === 'admin' || !canDo(user, oboTeamId, 'downloadKubeconfig'), isDownload: true, }, { title: 'Download DOCKERCFG', path: `/api/v1/dockerconfig/${oboTeamId}`, icon: getIcon('download_icon.svg'), - disabled: !appsEnabled?.harbor || !canDo(user, oboTeamId, 'downloadDockerConfig'), + disabled: !appsEnabled?.harbor || !canDo(user, oboTeamId, 'downloadDockerLogin'), isDownload: true, }, { diff --git a/src/components/PermissionTable.tsx b/src/components/PermissionTable.tsx new file mode 100644 index 000000000..33b58ddbe --- /dev/null +++ b/src/components/PermissionTable.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { makeStyles } from 'tss-react/mui' +import { Theme } from '@mui/material/styles' +import { Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material' +import { Checkbox } from './cmCheckbox/Checkbox' + +interface PermissionDefinition { + id: string + label: string +} + +interface PermissionsTableProps { + name: string + disabled?: boolean +} + +const permissionDefinitions: PermissionDefinition[] = [ + { id: 'createServices', label: 'Create Services' }, + { id: 'editSecurityPolicies', label: 'Edit Security Policies' }, + { id: 'useCloudShell', label: 'Use Cloud Shell' }, + { id: 'downloadKubeconfig', label: 'Download kubeconfig file' }, + { id: 'downloadDockerLogin', label: 'Download docker login credentials' }, + // { id: 'enableMonitoring', label: 'Enable Monitoring' }, + // { id: 'configureAlerts', label: 'Configure Alert Engine' }, +] + +const useStyles = makeStyles()((theme: Theme) => ({ + tableHead: { + backgroundColor: 'transparent', + padding: '0px 10px 15px 10px', + marginBottom: '10px', + }, + tableHeadText: { + fontWeight: 'bold', + fontSize: '1rem', + color: theme.palette.cm.headline, + }, + tableBody: { + '& tr:nth-of-type(even)': { + backgroundColor: theme.palette.cm.rowAlter, + }, + }, + tableCell: { + padding: '2px 10px', + }, + alignCenter: { + textAlign: 'center', + }, +})) + +export function PermissionsTable({ name, disabled }: PermissionsTableProps) { + const { register, control } = useFormContext() + const { classes, cx } = useStyles() + + return ( + + + + + Action + + + Team Members + + {/* + Team Admins + */} + + + + {permissionDefinitions.map((permission) => ( + + {permission.label} + + ( + field.onChange(e.target.checked)} + disabled={disabled} + /> + )} + /> + + {/* + + */} + + ))} + +
+ ) +} diff --git a/src/components/Section.tsx b/src/components/Section.tsx index b42a9e4f8..d9af7f8cc 100644 --- a/src/components/Section.tsx +++ b/src/components/Section.tsx @@ -1,4 +1,7 @@ +import React, { useState } from 'react' import { styled } from '@mui/material/styles' +import { Accordion, AccordionDetails, AccordionSummary, Box } from '@mui/material' +import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight' import { Paper } from './Paper' import { Typography } from './Typography' @@ -9,23 +12,86 @@ const StyledTitle = styled(Typography)(({ theme }) => ({ const StyledDescription = styled(Typography)(({ theme }) => ({ color: theme.palette.cl.text.subTitle, + maxWidth: '85%', + fontSize: '0.9rem', + marginTop: '5px', +})) + +const StyledAccordion = styled(Accordion)(({ theme }) => ({ + backgroundColor: 'transparent', + boxShadow: 'none !important', + margin: '0px !important', + '&:before': { + display: 'none', + }, +})) + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + // Remove the default expand icon container by not using the expandIcon prop + padding: 0, + flexDirection: 'column', + alignItems: 'flex-start', + '& .MuiAccordionSummary-content': { + margin: 0, + }, + minHeight: '0!important', +})) + +interface StyledAccordionDetailsProps { + noMarginTop?: boolean +} + +const StyledAccordionDetails = styled(AccordionDetails, { + shouldForwardProp: (prop) => prop !== 'noMarginTop', +})(({ noMarginTop }) => ({ + padding: 0, + marginTop: noMarginTop ? '0px' : '20px', })) interface Props { collapsable?: boolean description?: string title?: string - children?: any + children?: React.ReactNode noPaddingTop?: boolean + noMarginTop?: boolean } export default function Section(props: Props) { - const { title, description, collapsable, children, noPaddingTop } = props + const { title, description, collapsable, children, noPaddingTop, noMarginTop } = props + const [expanded, setExpanded] = useState(true) + + const handleAccordionChange = () => { + setExpanded((prev) => !prev) + } + + if (collapsable) { + return ( + + + + +
+ {title && {title}} + +
+ {description && {description}} +
+
+ {children} +
+
+ ) + } return ( - {title} - {description} + {title && {title}} + {description && {description}} {children} ) diff --git a/src/components/TextfieldList.tsx b/src/components/TextfieldList.tsx new file mode 100644 index 000000000..ef88e31b2 --- /dev/null +++ b/src/components/TextfieldList.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import { Box, Button, IconButton } from '@mui/material' +import { TextField } from 'components/forms/TextField' +import { makeStyles } from 'tss-react/mui' +import { Theme } from '@mui/material/styles' +import { Add, Clear } from '@mui/icons-material' +import { Typography } from 'components/Typography' +import { InputLabel } from 'components/InputLabel' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { FormHelperText } from 'components/FormHelperText' + +const useStyles = makeStyles()((theme: Theme) => ({ + container: { + padding: '16px', + backgroundColor: '#424242', + borderRadius: '8px', + }, + itemRow: { + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + }, + addItemButton: { + marginLeft: '-10px', + display: 'flex', + alignItems: 'center', + textTransform: 'none', + }, + errorText: { + alignItems: 'center', + color: '#d63c42', + display: 'flex', + left: 5, + top: 42, + width: '100%', + }, + helperTextTop: { + color: theme.palette.cl.text.subTitle, + marginTop: 0, + }, + label: { + fontFamily: 'sans-serif', + }, +})) + +interface ValueListProps { + title: string + subTitle?: string + /** The label shown on the input (only on the first item if showLabel is true) */ + valueLabel: string + helperText?: string + helperTextPosition?: 'bottom' | 'top' + /** Whether to display the label above the first text field */ + showLabel?: boolean + /** The label for the add/remove button */ + addLabel?: string + error?: boolean + errorText?: string + /** Name for the react-hook-form field array */ + name: string + /** Determines the width of the text field: small, medium, or large */ + valueSize?: 'small' | 'medium' | 'large' + valueDisabled?: boolean +} + +export default function TextfieldList(props: ValueListProps) { + const { classes, cx } = useStyles() + const { control, register } = useFormContext() + + const { + title, + subTitle, + valueLabel, + addLabel, + name, + helperText, + helperTextPosition, + showLabel = true, + valueSize = 'medium', + error, + errorText, + valueDisabled = false, + } = props + + const { fields, append, remove } = useFieldArray({ + control, + name, + }) + + const handleAddItem = () => { + append('') + } + + const errorScrollClassName = 'error-for-scroll' + return ( + + {title} + {subTitle && {subTitle}} + + {fields.map((item, index) => ( + + + {addLabel && ( + remove(index)}> + + + )} + + ))} + {addLabel && ( + + )} + {errorText && ( + + {errorText} + + )} + {helperText && (helperTextPosition === 'bottom' || !helperTextPosition) && ( + {helperText} + )} + + ) +} diff --git a/src/components/forms/ControlledCheckbox.tsx b/src/components/forms/ControlledCheckbox.tsx index e2b7c0a7b..43ba4b668 100644 --- a/src/components/forms/ControlledCheckbox.tsx +++ b/src/components/forms/ControlledCheckbox.tsx @@ -26,8 +26,10 @@ const StyledTypography = styled(Typography, { label: 'StyledTypography' })(({ th [theme.breakpoints.up('md')]: { paddingLeft: `calc(${theme.spacing(4)} + 14px)`, // 46 }, - lineHeight: '0.875rem', + lineHeight: 'normal', color: theme.palette.cl.text.subTitle, + maxWidth: '75%', + fontSize: 'medium', })) interface ControlledCheckboxProps { @@ -42,7 +44,7 @@ interface ControlledCheckboxProps { export default function ControlledCheckbox(props: ControlledCheckboxProps) { const { name, disabled, control, label, explainertext } = props return ( - + ({ fontSize: '25px', marginRight: '5px', }, + '& input::placeholder': { + color: '#838383', + }, }, })) @@ -222,6 +225,10 @@ interface BaseProps { trimmed?: boolean value?: Value width?: TextboxWidth + /** + * If true, places the label to the left of the text field. + */ + isHorizontalLabel?: boolean } type Value = null | number | string | undefined @@ -250,6 +257,7 @@ export type TextFieldProps = BaseProps & TextFieldPropsOverrides & LabelToolTipP export const TextField = React.forwardRef(function TextField(props: TextFieldProps, ref) { const { classes, cx } = useStyles() + const theme = useTheme() const { InputLabelProps, @@ -290,11 +298,11 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro onIncrement, onDecrement, suffixSymbol, + isHorizontalLabel = false, ...textFieldProps } = props const [_value, setValue] = React.useState(value) - const theme = useTheme() const widthMap: Record = { small: '100px', @@ -318,41 +326,15 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro const handleChange = (e: React.ChangeEvent) => { const numberTypes = ['tel', 'number'] - - // Because !!0 is falsy :( const minAndMaxExist = typeof min === 'number' && typeof max === 'number' - - /** - * If we've provided a min and max value, make sure the user - * input doesn't go outside of those bounds ONLY if the input - * type matches a number type. - */ const cleanedValue = minAndMaxExist && numberTypes.some((eachType) => eachType === type) && e.target.value !== '' ? clamp(min, max, +e.target.value) : e.target.value - /** - * If the cleanedValue is undefined, set the value to an empty - * string but this shouldn't happen. - */ setValue(cleanedValue || '') - // Invoke the onChange prop if one is provided with the cleaned value. if (onChange) { - /** - * Create clone of event node only if our cleanedValue - * is different from the e.target.value - * - * This solves for a specific scenario where the e.target on - * the MUI TextField select variants were actually a plain object - * rather than a DOM node. - * - * So e.target on a text field === - * while e.target on the select variant === { value: 10, name: undefined } - * - * See GitHub issue: https://github.com/mui-org/material-ui/issues/16470 - */ if (e.target.value !== cleanedValue) { const clonedEvent = { ...e, @@ -446,32 +428,29 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro }, containerProps?.className, )} + display='flex' + flexDirection={isHorizontalLabel ? 'row' : 'column'} + alignItems={isHorizontalLabel ? 'center' : 'flex-start'} + sx={{ + ...(!noMarginTop && { marginTop: theme.spacing(2) }), + gap: isHorizontalLabel ? theme.spacing(2) : 0, + }} > - + {!hideLabel && ( {label} {/* eslint-disable-next-line no-nested-ternary */} @@ -481,90 +460,100 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro (optional) ) : null} - - - {helperText && helperTextPosition === 'top' && ( - - {helperText} - )} -
- - {children} - -
- {errorText && ( - + {helperText && helperTextPosition === 'top' && ( + + {helperText} + + )} +
- {errorText} - - )} - {helperText && (helperTextPosition === 'bottom' || !helperTextPosition) && ( - - {helperText} - - )} + + {children} + +
+ {errorText && ( + + {errorText} + + )} + {helperText && (helperTextPosition === 'bottom' || !helperTextPosition) && ( + + {helperText} + + )} +
) }) diff --git a/src/pages/Policy.tsx b/src/pages/Policy.tsx index c6bf50685..d39a91937 100644 --- a/src/pages/Policy.tsx +++ b/src/pages/Policy.tsx @@ -42,7 +42,7 @@ export default function ({ const { t } = useTranslation() // END HOOKS const team = !isLoadingTeams && find(teams, { id: teamId }) - const editPolicies = team?.selfService?.policies?.includes('edit policies') || teamId === 'admin' || isPlatformAdmin + const editPolicies = team?.selfService?.teamMembers?.editSecurityPolicies || teamId === 'admin' || isPlatformAdmin const loading = isLoading || isLoadingTeams const mutating = isLoadingUpdate if (!mutating && isSuccessUpdate) return diff --git a/src/pages/teams/create-edit/ResourceQuotaKeyValue.tsx b/src/pages/teams/create-edit/ResourceQuotaKeyValue.tsx new file mode 100644 index 000000000..baf8038d6 --- /dev/null +++ b/src/pages/teams/create-edit/ResourceQuotaKeyValue.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { Box } from '@mui/material' +import { makeStyles } from 'tss-react/mui' +import { Theme } from '@mui/material/styles' +import KeyValue from 'components/KeyValue' + +const useStyles = makeStyles()((theme: Theme) => ({ + decorator: { + borderLeft: '1px solid #777777', + height: 'auto', + padding: '7px', + width: '65px', + textAlign: 'right', + backgroundColor: theme.palette.cm.disabledBackground, + display: 'flex', + justifyContent: 'flex-end', + }, + decoratortext: { + fontWeight: 'bold', + fontSize: '10px', + color: theme.palette.cl.text.title, + }, + keyValueWrapper: { + marginTop: '32px', + }, +})) + +// Define which keys belong to each group. +const countQuotaKeys = new Set(['services.loadbalancers', 'services.nodeports']) +const computeQuotaKeys = new Set(['limits.cpu', 'requests.cpu', 'limits.memory', 'requests.memory']) +const computeDecorators: Record = { + 'limits.cpu': 'mCPUs', + 'requests.cpu': 'mCPUs', + 'limits.memory': 'Mi', + 'requests.memory': 'Mi', +} + +interface ResourceQuotaKeyValueProps { + name: string + disabled?: boolean +} + +export default function ResourceQuotaKeyValue({ name, disabled }: ResourceQuotaKeyValueProps) { + const { classes } = useStyles() + + return ( + + {/* Count Quota Section */} + countQuotaKeys.has(item.name)} + hideWhenEmpty + compressed + keyDisabled + valueDisabled + valueSize='medium' + keySize='medium' + showLabel={false} + disabled={disabled} + /> + + {/* Compute Resource Quota Section */} + + computeQuotaKeys.has(item.name)} + hideWhenEmpty + keyDisabled + compressed + valueSize='medium' + keySize='medium' + showLabel={false} + decoratorMapping={computeDecorators} + disabled={disabled} + /> + + + {/* Custom Resource Quota Section */} + + !countQuotaKeys.has(item.name) && !computeQuotaKeys.has(item.name)} + addLabel='Add Custom Quota' + disabled={disabled} + /> + + + ) +} diff --git a/src/pages/teams/create-edit/create-edit-teams.styles.ts b/src/pages/teams/create-edit/create-edit-teams.styles.ts new file mode 100644 index 000000000..cde5b6061 --- /dev/null +++ b/src/pages/teams/create-edit/create-edit-teams.styles.ts @@ -0,0 +1,29 @@ +import { makeStyles } from 'tss-react/mui' +import type { Theme } from '@mui/material/styles' + +export const useStyles = makeStyles()((theme: Theme) => ({ + root: { + '& .mlMain': { + flexBasis: '100%', + maxWidth: '100%', + [theme.breakpoints.up('lg')]: { + flexBasis: '78.8%', + maxWidth: '78.8%', + }, + }, + '& .mlSidebar': { + flexBasis: '100%', + maxWidth: '100%', + position: 'static', + [theme.breakpoints.up('lg')]: { + flexBasis: '21.2%', + maxWidth: '21.2%', + position: 'sticky', + }, + width: '100%', + }, + }, + keyValueWrapper: { + marginTop: '32px', + }, +})) diff --git a/src/pages/teams/create-edit/create-edit-teams.validator.ts b/src/pages/teams/create-edit/create-edit-teams.validator.ts new file mode 100644 index 000000000..c725904f6 --- /dev/null +++ b/src/pages/teams/create-edit/create-edit-teams.validator.ts @@ -0,0 +1,138 @@ +import * as yup from 'yup' + +// Define the alerts schema +const alertsSchema = yup.object({ + repeatInterval: yup.string().optional().default(undefined), + groupInterval: yup.string().optional().default(undefined), + receivers: yup + .array() + .of(yup.string().oneOf(['slack', 'msteams', 'opsgenie', 'email', 'none'])) + .optional(), + slack: yup + .object({ + channel: yup.string().optional().default(undefined), + channelCrit: yup.string().optional().default(undefined), + url: yup.string().optional().default(undefined), + }) + .optional(), + msteams: yup + .object({ + highPrio: yup.string().optional().default(undefined), + lowPrio: yup.string().optional().default(undefined), + }) + .optional(), +}) + +// Define the selfService schema +const selfServiceSchema = yup.object({ + teamMembers: yup + .object({ + createServices: yup.boolean().required('Create services permission is required').default(false), + editSecurityPolicies: yup.boolean().required('Edit security policies permission is required').default(false), + useCloudShell: yup.boolean().required('Cloud shell usage permission is required').default(false), + downloadKubeconfig: yup.boolean().required('Download kubeconfig permission is required').default(false), + downloadDockerLogin: yup.boolean().required('Download docker login permission is required').default(false), + }) + .required('Team members permissions are required'), +}) + +// Define a schema for a single ResourceQuota item. +const resourceQuotaItemSchema = yup.object({ + key: yup.string().required('Resource quota key is required'), + value: yup.number().required('Resource quota value is required'), + mutable: yup.boolean().optional(), + decorator: yup.string().optional(), +}) + +// Define the resourceQuota object schema containing countQuota, computeResourceQuota, and customQuota. +const resourceQuotaObjectSchema = yup.object({ + enabled: yup.boolean().default(false), + countQuota: yup + .array() + .of(resourceQuotaItemSchema) + .default([ + { key: 'loadbalancers', value: 0, mutable: false, decorator: 'lbs' }, + { key: 'nodeports', value: 0, mutable: false, decorator: 'nprts' }, + { key: 'count', value: 5, mutable: true, decorator: 'pods' }, + ]), + computeResourceQuota: yup + .array() + .of(resourceQuotaItemSchema) + .default([ + { key: 'limits.cpu', value: 500, decorator: 'mCPUs' }, + { key: 'requests.cpu', value: 250, decorator: 'mCPUs' }, + { key: 'limits.memory', value: 500, decorator: 'Mi' }, + { key: 'requests.memory', value: 500, decorator: 'Mi' }, + ]), + customQuota: yup + .array() + .of(resourceQuotaItemSchema) + .default([]) + .test( + 'customQuota-not-default', + 'custom resource quota may not be the same as defined above', + function (customQuota) { + const { path, createError } = this + const countQuotaDefaults = [ + { key: 'loadbalancers', value: 0, mutable: false, decorator: 'lbs' }, + { key: 'nodeports', value: 0, mutable: false, decorator: 'nprts' }, + { key: 'count', value: 5, mutable: true, decorator: 'pods' }, + ] + const computeQuotaDefaults = [ + { key: 'limits.cpu', value: 500, decorator: 'mCPUs' }, + { key: 'requests.cpu', value: 250, decorator: 'mCPUs' }, + { key: 'limits.memory', value: 500, decorator: 'Mi' }, + { key: 'requests.memory', value: 500, decorator: 'Mi' }, + ] + const defaultQuotaKeys = new Set([...countQuotaDefaults, ...computeQuotaDefaults].map((quota) => quota.key)) + if (!customQuota) return true + const invalidEntry = customQuota.find((quota) => defaultQuotaKeys.has(quota.key)) + return invalidEntry + ? createError({ path, message: 'custom resource quota may not contain duplicated quota' }) + : true + }, + ), +}) + +// Main CreateTeamApiResponse schema +export const createTeamApiResponseSchema = yup.object({ + id: yup.string().optional().default(undefined), + name: yup.string().required('Team label is required'), + oidc: yup + .object({ + groupMapping: yup.string().optional().default(undefined), + }) + .optional(), + password: yup.string().optional().default(undefined), + managedMonitoring: yup + .object({ + grafana: yup.boolean().optional().default(false), + alertmanager: yup.boolean().optional().default(false), + }) + .optional(), + alerts: alertsSchema.optional(), + resourceQuota: yup + .array() + .of( + yup.object({ + name: yup.string().required('Resource quota name is required'), + value: yup.string().required('Resource quota value is required'), + }), + ) + .default([ + { name: 'services.loadbalancers', value: '0' }, + { name: 'services.nodeports', value: '0' }, + { name: 'limits.cpu', value: '500m' }, + { name: 'requests.cpu', value: '250m' }, + { name: 'limits.memory', value: '500mi' }, + { name: 'requests.memory', value: '500mi' }, + { name: 'count/pods', value: '5' }, + ]), + networkPolicy: yup + .object({ + ingressPrivate: yup.boolean().optional().default(true), + egressPublic: yup.boolean().optional().default(true), + }) + .optional(), + selfService: selfServiceSchema.optional(), +}) diff --git a/src/pages/teams/create-edit/index.tsx b/src/pages/teams/create-edit/index.tsx new file mode 100644 index 000000000..b67194eaf --- /dev/null +++ b/src/pages/teams/create-edit/index.tsx @@ -0,0 +1,294 @@ +import { Grid } from '@mui/material' +import PaperLayout from 'layouts/Paper' +import { LandingHeader } from 'components/LandingHeader' +import { FormProvider, Resolver, get, useForm } from 'react-hook-form' +import { Redirect, RouteComponentProps } from 'react-router-dom' +import Section from 'components/Section' +import ControlledCheckbox from 'components/forms/ControlledCheckbox' +import { TextField } from 'components/forms/TextField' +import AdvancedSettings from 'components/AdvancedSettings' +import ImgButtonGroup from 'components/ImgButtonGroup' +import { useEffect, useState } from 'react' +import { + CreateTeamApiResponse, + useCreateTeamMutation, + useDeleteTeamMutation, + useEditTeamMutation, + useGetTeamQuery, +} from 'redux/otomiApi' +import { yupResolver } from '@hookform/resolvers/yup' +import { PermissionsTable } from 'components/PermissionTable' +import ControlledBox from 'components/ControlledBox' +import { useSession } from 'providers/Session' +import InformationBanner from 'components/InformationBanner' +import { Link } from 'components/LinkUrl/LinkUrl' +import LoadingButton from '@mui/lab/LoadingButton' +import DeleteButton from 'components/DeleteButton' +import { useStyles } from './create-edit-teams.styles' +import { createTeamApiResponseSchema } from './create-edit-teams.validator' +import ResourceQuotaKeyValue from './ResourceQuotaKeyValue' + +type NotificationReceiver = 'slack' | 'teams' | 'opsgenie' | 'email' + +interface Params { + teamId?: string +} + +export default function CreateEditTeams({ + match: { + params: { teamId }, + }, +}: RouteComponentProps) { + const { classes } = useStyles() + const { appsEnabled, user } = useSession() + const { isPlatformAdmin } = user + const [create, { isLoading: isLoadingCreate, isSuccess: isSuccessCreate }] = useCreateTeamMutation() + const [update, { isLoading: isLoadingUpdate, isSuccess: isSuccessUpdate }] = useEditTeamMutation() + const [del, { isLoading: isLoadingDelete, isSuccess: isSuccessDelete }] = useDeleteTeamMutation() + const { data, isLoading, isFetching, isError, refetch } = useGetTeamQuery({ teamId }, { skip: !teamId }) + const [activeNotificationReceiver, setActiveNotificationReceiver] = useState('slack') + const notificationReceiverOptions = [ + { + value: 'slack', + label: 'Slack', + imgSrc: '/logos/slack_logo.svg', + }, + { + value: 'teams', + label: 'Teams', + imgSrc: '/logos/teams_logo.svg', + }, + // { + // value: 'email', + // label: 'Email', + // imgSrc: '/logos/email_logo.svg', + // }, + ] + + const mergedDefaultValues = createTeamApiResponseSchema.cast(data) + + const methods = useForm({ + disabled: !isPlatformAdmin, + resolver: yupResolver(createTeamApiResponseSchema) as Resolver, + defaultValues: mergedDefaultValues, + }) + + const { + control, + register, + reset, + resetField, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + setValue, + trigger, + } = methods + + // checkbox logic + // const controlledResourceQuotaInput = watch('resourceQuota.enabled') + const controlledAlertmanagerInput = watch('managedMonitoring.alertmanager') + + useEffect(() => { + if (data) reset(data) + + const teamsWebhookLow = get(data, 'alerts.msteams.lowPrio') + const teamsWebhookHigh = get(data, 'alerts.msteams.highPrio') + + if (teamsWebhookLow || teamsWebhookHigh) setActiveNotificationReceiver('teams') + else setActiveNotificationReceiver('slack') + }, [data]) + + const onSubmit = (submitData) => { + if (teamId) update({ teamId, body: submitData }) + else create({ body: submitData }) + } + + const mutating = isLoadingCreate || isLoadingUpdate || isLoadingDelete + if (!mutating && (isSuccessCreate || isSuccessUpdate || isSuccessDelete)) return + + return ( + + + + {!isPlatformAdmin && } + + +
+
+ { + const value = e.target.value + setValue('name', value) + }} + error={!!errors.name} + helperText={errors.name?.message?.toString()} + disabled={!!teamId} + /> +
+ +
+ {!appsEnabled.grafana && isPlatformAdmin && ( + + Dashboards require Grafana to be enabled. Click here to enable it. + + } + /> + )} + +
+
+ {!appsEnabled.alertmanager && isPlatformAdmin && ( + + Alerts require Prometheus and AlertManager to be enabled. Click{' '} + here to enable them. + + } + /> + )} + + + { + setActiveNotificationReceiver(value as NotificationReceiver) + }} + /> + {activeNotificationReceiver === 'slack' && ( + <> + +
+ + + + )} + {activeNotificationReceiver === 'teams' && ( + <> + + + + )} + {/* NOTE: keep this code in case email notification receiver will be re-enabled + + {activeNotificationReceiver === 'email' && ( + + + + + )} */} +
+
+
+ +
+
+ +
+
+ {teamId && ( + del({ teamId })} + resourceName={watch('name')} + resourceType='team' + data-cy='button-delete-team' + disabled={isLoadingDelete || isLoadingCreate || isLoadingUpdate || !isPlatformAdmin} + loading={isLoadingDelete} + /> + )} + + {teamId ? 'Edit Team' : 'Create Team'} + + +
+
+
+ ) +} diff --git a/src/pages/teams/overview/index.tsx b/src/pages/teams/overview/index.tsx new file mode 100644 index 000000000..c82f25e01 --- /dev/null +++ b/src/pages/teams/overview/index.tsx @@ -0,0 +1,41 @@ +import PaperLayout from 'layouts/Paper' +import { useSession } from 'providers/Session' +import React, { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useAppSelector } from 'redux/hooks' +import { useGetTeamsQuery } from 'redux/otomiApi' +import { HeadCell } from '../../../components/EnhancedTable' +import RLink from '../../../components/Link' +import ListTable from '../../../components/ListTable' + +export default function TeamOverview(): React.ReactElement { + const { data, isLoading: isLoadingTeams, isFetching, refetch } = useGetTeamsQuery() + const isDirty = useAppSelector(({ global: { isDirty } }) => isDirty) + const { + user: { isPlatformAdmin }, + } = useSession() + + useEffect(() => { + if (isDirty !== false && !isFetching) refetch() + }, [isDirty]) + + const { t } = useTranslation() + + const headCells: HeadCell[] = [ + { + id: 'name', + label: t('Name'), + renderer: ({ name }: any) => + isPlatformAdmin ? ( + + {name} + + ) : ( + name + ), + }, + ] + // END HOOKS + const comp = + return +} diff --git a/src/redux/otomiApi.ts b/src/redux/otomiApi.ts index a4dc92949..575a95a32 100644 --- a/src/redux/otomiApi.ts +++ b/src/redux/otomiApi.ts @@ -559,7 +559,7 @@ export type GetTeamsApiResponse = /** status 200 Successfully obtained teams col alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -569,27 +569,6 @@ export type GetTeamsApiResponse = /** status 200 Successfully obtained teams col highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } resourceQuota?: { name: string @@ -600,11 +579,13 @@ export type GetTeamsApiResponse = /** status 200 Successfully obtained teams col egressPublic?: boolean } selfService?: { - service?: 'ingress'[] - policies?: 'edit policies'[] - team?: ('oidc' | 'managedMonitoring' | 'alerts' | 'resourceQuota' | 'networkPolicy')[] - apps?: ('argocd' | 'gitea')[] - access?: ('shell' | 'downloadKubeConfig' | 'downloadDockerConfig' | 'downloadCertificateAuthority')[] + teamMembers?: { + createServices: boolean + editSecurityPolicies: boolean + useCloudShell: boolean + downloadKubeconfig: boolean + downloadDockerLogin: boolean + } } }[] export type GetTeamsApiArg = void @@ -622,7 +603,7 @@ export type CreateTeamApiResponse = /** status 200 Successfully obtained teams c alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -632,27 +613,6 @@ export type CreateTeamApiResponse = /** status 200 Successfully obtained teams c highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } resourceQuota?: { name: string @@ -663,11 +623,13 @@ export type CreateTeamApiResponse = /** status 200 Successfully obtained teams c egressPublic?: boolean } selfService?: { - service?: 'ingress'[] - policies?: 'edit policies'[] - team?: ('oidc' | 'managedMonitoring' | 'alerts' | 'resourceQuota' | 'networkPolicy')[] - apps?: ('argocd' | 'gitea')[] - access?: ('shell' | 'downloadKubeConfig' | 'downloadDockerConfig' | 'downloadCertificateAuthority')[] + teamMembers?: { + createServices: boolean + editSecurityPolicies: boolean + useCloudShell: boolean + downloadKubeconfig: boolean + downloadDockerLogin: boolean + } } } export type CreateTeamApiArg = { @@ -686,7 +648,7 @@ export type CreateTeamApiArg = { alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -696,27 +658,6 @@ export type CreateTeamApiArg = { highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } resourceQuota?: { name: string @@ -727,11 +668,13 @@ export type CreateTeamApiArg = { egressPublic?: boolean } selfService?: { - service?: 'ingress'[] - policies?: 'edit policies'[] - team?: ('oidc' | 'managedMonitoring' | 'alerts' | 'resourceQuota' | 'networkPolicy')[] - apps?: ('argocd' | 'gitea')[] - access?: ('shell' | 'downloadKubeConfig' | 'downloadDockerConfig' | 'downloadCertificateAuthority')[] + teamMembers?: { + createServices: boolean + editSecurityPolicies: boolean + useCloudShell: boolean + downloadKubeconfig: boolean + downloadDockerLogin: boolean + } } } } @@ -749,7 +692,7 @@ export type GetTeamApiResponse = /** status 200 Successfully obtained team */ { alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -759,27 +702,6 @@ export type GetTeamApiResponse = /** status 200 Successfully obtained team */ { highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } resourceQuota?: { name: string @@ -790,11 +712,13 @@ export type GetTeamApiResponse = /** status 200 Successfully obtained team */ { egressPublic?: boolean } selfService?: { - service?: 'ingress'[] - policies?: 'edit policies'[] - team?: ('oidc' | 'managedMonitoring' | 'alerts' | 'resourceQuota' | 'networkPolicy')[] - apps?: ('argocd' | 'gitea')[] - access?: ('shell' | 'downloadKubeConfig' | 'downloadDockerConfig' | 'downloadCertificateAuthority')[] + teamMembers?: { + createServices: boolean + editSecurityPolicies: boolean + useCloudShell: boolean + downloadKubeconfig: boolean + downloadDockerLogin: boolean + } } } export type GetTeamApiArg = { @@ -815,7 +739,7 @@ export type EditTeamApiResponse = /** status 200 Successfully edited team */ { alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -825,27 +749,6 @@ export type EditTeamApiResponse = /** status 200 Successfully edited team */ { highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } resourceQuota?: { name: string @@ -856,11 +759,13 @@ export type EditTeamApiResponse = /** status 200 Successfully edited team */ { egressPublic?: boolean } selfService?: { - service?: 'ingress'[] - policies?: 'edit policies'[] - team?: ('oidc' | 'managedMonitoring' | 'alerts' | 'resourceQuota' | 'networkPolicy')[] - apps?: ('argocd' | 'gitea')[] - access?: ('shell' | 'downloadKubeConfig' | 'downloadDockerConfig' | 'downloadCertificateAuthority')[] + teamMembers?: { + createServices: boolean + editSecurityPolicies: boolean + useCloudShell: boolean + downloadKubeconfig: boolean + downloadDockerLogin: boolean + } } } export type EditTeamApiArg = { @@ -881,7 +786,7 @@ export type EditTeamApiArg = { alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -891,27 +796,6 @@ export type EditTeamApiArg = { highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } resourceQuota?: { name: string @@ -922,11 +806,13 @@ export type EditTeamApiArg = { egressPublic?: boolean } selfService?: { - service?: 'ingress'[] - policies?: 'edit policies'[] - team?: ('oidc' | 'managedMonitoring' | 'alerts' | 'resourceQuota' | 'networkPolicy')[] - apps?: ('argocd' | 'gitea')[] - access?: ('shell' | 'downloadKubeConfig' | 'downloadDockerConfig' | 'downloadCertificateAuthority')[] + teamMembers?: { + createServices: boolean + editSecurityPolicies: boolean + useCloudShell: boolean + downloadKubeconfig: boolean + downloadDockerLogin: boolean + } } } } @@ -7149,7 +7035,7 @@ export type GetSettingsApiResponse = /** status 200 The request is successful. * alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -7159,27 +7045,6 @@ export type GetSettingsApiResponse = /** status 200 The request is successful. * highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } cluster?: { name: string @@ -7432,7 +7297,7 @@ export type EditSettingsApiArg = { alerts?: { repeatInterval?: string groupInterval?: string - receivers?: ('slack' | 'msteams' | 'opsgenie' | 'email' | 'none')[] + receivers?: ('slack' | 'msteams' | 'none')[] slack?: { channel?: string channelCrit?: string @@ -7442,27 +7307,6 @@ export type EditSettingsApiArg = { highPrio?: string lowPrio?: string } - opsgenie?: { - apiKey?: string - url?: string - responders?: ({ - type: 'team' | 'user' | 'escalation' | 'schedule' - } & ( - | { - id: string - } - | { - name: string - } - | { - username: string - } - ))[] - } - email?: { - critical?: string - nonCritical?: string - } } cluster?: { name: string diff --git a/src/theme/palette.ts b/src/theme/palette.ts index 140ca4d6e..95ade7e4c 100644 --- a/src/theme/palette.ts +++ b/src/theme/palette.ts @@ -213,6 +213,7 @@ const palette = { headline: '#343438', primaryText: '#696970', red: '#d63c42', + rowAlter: '#f4f5f6', tableStatic: '#e5e5ea', textBox: '#fff', textBoxBorder: '#696970', @@ -263,6 +264,7 @@ const palette = { headline: '#f7f7fa', primaryText: '#696970', red: '#d63c42', + rowAlter: '#47474D', tableStatic: '#696970', textBox: '#343438', textBoxBorder: '#696970',