diff --git a/cypress/integration/distroSettings/general_section.ts b/cypress/integration/distroSettings/general_section.ts index bde620981b..1fdd8a8e3c 100644 --- a/cypress/integration/distroSettings/general_section.ts +++ b/cypress/integration/distroSettings/general_section.ts @@ -16,7 +16,7 @@ describe("general section", () => { cy.contains("button", "Add alias").click(); cy.getInputByLabel("Alias").type("localhost-alias"); cy.getInputByLabel("Notes").type("this is a note"); - cy.getInputByLabel("Disable shallow clone for this distro.").check({ + cy.getInputByLabel("Disable shallow clone for this distro").check({ force: true, }); save(); @@ -26,7 +26,7 @@ describe("general section", () => { cy.reload(); cy.getInputByLabel("Alias").should("have.value", "localhost-alias"); cy.getInputByLabel("Notes").should("have.value", "this is a note"); - cy.getInputByLabel("Disable shallow clone for this distro.").should( + cy.getInputByLabel("Disable shallow clone for this distro").should( "be.checked" ); @@ -39,4 +39,16 @@ describe("general section", () => { save(); cy.validateToast("success"); }); + + describe("container pool distro", () => { + beforeEach(() => { + cy.visit("/distro/ubuntu1604-parent/settings/general"); + }); + + it("warns users that the distro will not be spawned for tasks", () => { + cy.contains( + "Distro is a container pool, so it cannot be spawned for tasks." + ).should("be.visible"); + }); + }); }); diff --git a/cypress/integration/distroSettings/host_section.ts b/cypress/integration/distroSettings/host_section.ts index c3aaddd67b..23c8873eeb 100644 --- a/cypress/integration/distroSettings/host_section.ts +++ b/cypress/integration/distroSettings/host_section.ts @@ -12,6 +12,8 @@ describe("host section", () => { cy.dataCy("maximum-hosts-input").should("not.exist"); cy.dataCy("idle-time-input").should("not.exist"); cy.dataCy("future-fraction-input").should("not.exist"); + cy.dataCy("rounding-rule-select").should("not.exist"); + cy.dataCy("feedback-rule-select").should("not.exist"); }); it("shows an error when selecting an incompatible host communication method", () => { @@ -29,8 +31,6 @@ describe("host section", () => { cy.getInputByLabel("SSH User").type("sudo"); cy.contains("button", "Add SSH option").click(); cy.getInputByLabel("SSH Option").type("BatchMode=yes"); - cy.selectLGOption("Host Allocator Rounding Rule", "Round down"); - cy.selectLGOption("Host Allocator Feedback Rule", "No feedback"); cy.selectLGOption( "Host Overallocation Rule", "Terminate hosts when overallocated" @@ -46,8 +46,6 @@ describe("host section", () => { cy.getInputByLabel("SSH User").clear(); cy.getInputByLabel("SSH User").type("ubuntu"); cy.dataCy("delete-item-button").click(); - cy.selectLGOption("Host Allocator Rounding Rule", "Default"); - cy.selectLGOption("Host Allocator Feedback Rule", "Default"); cy.selectLGOption("Host Overallocation Rule", "Default"); save(); diff --git a/cypress/integration/distroSettings/permissions.ts b/cypress/integration/distroSettings/permissions.ts new file mode 100644 index 0000000000..370c9841d8 --- /dev/null +++ b/cypress/integration/distroSettings/permissions.ts @@ -0,0 +1,83 @@ +describe("with various permission levels", () => { + it("hides the new distro button when a user cannot create distros", () => { + const userData = { + data: { + user: { + userId: "admin", + permissions: { + canCreateDistro: false, + distroPermissions: { + admin: true, + edit: true, + }, + }, + }, + }, + }; + cy.overwriteGQL("UserDistroSettingsPermissions", userData); + cy.visit("/distro/rhel71-power8-large/settings/general"); + cy.dataCy("new-distro-button").should("not.exist"); + cy.dataCy("delete-distro-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.get("textarea").should("not.be.disabled"); + }); + + it("disables the delete button when user lacks admin permissions", () => { + const userData = { + data: { + user: { + userId: "admin", + permissions: { + canCreateDistro: false, + distroPermissions: { + admin: false, + edit: true, + }, + }, + }, + }, + }; + cy.overwriteGQL("UserDistroSettingsPermissions", userData); + cy.visit("/distro/rhel71-power8-large/settings/general"); + cy.dataCy("new-distro-button").should("not.exist"); + cy.dataCy("delete-distro-button").should( + "have.attr", + "aria-disabled", + "true" + ); + cy.get("textarea").should("not.be.disabled"); + }); + + it("disables fields when user lacks edit permissions", () => { + const userData = { + data: { + user: { + userId: "admin", + permissions: { + canCreateDistro: false, + distroPermissions: { + admin: false, + edit: false, + }, + }, + }, + }, + }; + cy.overwriteGQL("UserDistroSettingsPermissions", userData); + cy.visit("/distro/rhel71-power8-large/settings/general"); + cy.dataCy("new-distro-button").should("not.exist"); + cy.dataCy("delete-distro-button").should( + "have.attr", + "aria-disabled", + "true" + ); + cy.dataCy("distro-settings-page").within(() => { + cy.get("input").should("be.disabled"); + cy.get("textarea").should("be.disabled"); + cy.get("button").should("have.attr", "aria-disabled", "true"); + }); + }); +}); diff --git a/cypress/integration/distroSettings/provider_section.ts b/cypress/integration/distroSettings/provider_section.ts index 23124b513b..21900c5915 100644 --- a/cypress/integration/distroSettings/provider_section.ts +++ b/cypress/integration/distroSettings/provider_section.ts @@ -18,7 +18,7 @@ describe("provider section", () => { force: true, }); cy.contains("button", "Add security group").click(); - cy.getInputByLabel("Security Group ID").type("group-1234"); + cy.getInputByLabel("Security Group ID").type("sg-1234"); cy.contains("button", "Add host").click(); cy.getInputByLabel("Name").type("host-1234"); save(); @@ -153,7 +153,7 @@ describe("provider section", () => { cy.getInputByLabel("EC2 AMI ID").type("ami-1234"); cy.getInputByLabel("Instance Type").type("m5.xlarge"); cy.contains("button", "Add security group").click(); - cy.getInputByLabel("Security Group ID").type("security-group-1234"); + cy.getInputByLabel("Security Group ID").type("sg-5678"); save(); cy.validateToast("success", "Updated distro."); @@ -230,7 +230,7 @@ describe("provider section", () => { cy.getInputByLabel("EC2 AMI ID").type("ami-1234"); cy.getInputByLabel("Instance Type").type("m5.xlarge"); cy.contains("button", "Add security group").click(); - cy.getInputByLabel("Security Group ID").type("security-group-1234"); + cy.getInputByLabel("Security Group ID").type("sg-0000"); save(); cy.validateToast("success", "Updated distro."); diff --git a/cypress/integration/distroSettings/task_section.ts b/cypress/integration/distroSettings/task_section.ts index 9db2ef9f38..5d77790ff9 100644 --- a/cypress/integration/distroSettings/task_section.ts +++ b/cypress/integration/distroSettings/task_section.ts @@ -36,7 +36,7 @@ describe("task section", () => { }); it("should surface warnings for invalid number inputs", () => { - const inputLabel = "Patch Factor (0 to 100 inclusive)"; + const inputLabel = "Patch Factor"; cy.selectLGOption("Task Planner Version", "Tunable"); cy.getInputByLabel(inputLabel).clear(); cy.getInputByLabel(inputLabel).type("500"); diff --git a/cypress/integration/version/task_filters.ts b/cypress/integration/version/task_filters.ts index 6aa319644c..c61e6e55ce 100644 --- a/cypress/integration/version/task_filters.ts +++ b/cypress/integration/version/task_filters.ts @@ -1,4 +1,4 @@ -import { urlSearchParamsAreUpdated } from "../../utils"; +import { urlSearchParamsAreUpdated, waitForTaskTable } from "../../utils"; const patch = { id: "5e4ff3abe3c3317e352062e4", @@ -10,7 +10,7 @@ const defaultPath = `${pathTasks}?sorts=STATUS%3AASC%3BBASE_STATUS%3ADESC`; describe("Tasks filters", () => { it("Should clear any filters with the Clear All Filters button and reset the table to its default state", () => { cy.visit(pathURLWithFilters); - waitForTable(); + waitForTaskTable(); cy.dataCy("clear-all-filters").click(); cy.location().should((loc) => { expect(loc.href).to.equal(loc.origin + defaultPath); @@ -40,7 +40,7 @@ describe("Tasks filters", () => { const urlParam = "variant"; it("Updates url with input value and fetches tasks filtered by variant", () => { cy.visit(defaultPath); - waitForTable(); + waitForTaskTable(); cy.toggleTableFilter(4); cy.dataCy("variant-input-wrapper") .find("input") @@ -53,7 +53,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: variantInputValue, }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("contain.text", 2); cy.toggleTableFilter(4); @@ -68,7 +68,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: null, }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("contain.text", 46); }); }); @@ -78,7 +78,7 @@ describe("Tasks filters", () => { const urlParam = "taskName"; it("Updates url with input value and fetches tasks filtered by task name", () => { cy.visit(defaultPath); - waitForTable(); + waitForTaskTable(); cy.toggleTableFilter(1); cy.dataCy("taskname-input-wrapper") .find("input") @@ -91,7 +91,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: taskNameInputValue, }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("contain.text", 1); cy.toggleTableFilter(1); @@ -106,7 +106,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: null, }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("contain.text", 46); }); }); @@ -115,7 +115,7 @@ describe("Tasks filters", () => { const urlParam = "statuses"; beforeEach(() => { cy.visit(defaultPath); - waitForTable(); + waitForTaskTable(); cy.toggleTableFilter(2); }); @@ -129,7 +129,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: "failed", }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("have.text", 2); cy.dataCy("filtered-count") @@ -145,7 +145,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: "failed-umbrella,failed,known-issue,success", }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should( "not.have.text", postFilterCount @@ -175,7 +175,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: "all", }); - waitForTable(); + waitForTaskTable(); selectCheckboxOption("All", false); assertChecked(taskStatuses, false); @@ -191,7 +191,7 @@ describe("Tasks filters", () => { const urlParam = "baseStatuses"; beforeEach(() => { cy.visit(defaultPath); - waitForTable(); + waitForTaskTable(); cy.toggleTableFilter(3); }); @@ -206,7 +206,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: "success", }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("have.text", 44); selectCheckboxOption("Succeeded", false); @@ -215,7 +215,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: null, }); - waitForTable(); + waitForTaskTable(); cy.dataCy("filtered-count").should("have.text", preFilterCount); }); }); @@ -229,7 +229,7 @@ describe("Tasks filters", () => { paramName: urlParam, search: "all", }); - waitForTable(); + waitForTaskTable(); selectCheckboxOption("All", false); assertChecked(taskStatuses, false); @@ -242,14 +242,6 @@ describe("Tasks filters", () => { }); }); -/** - * Function used to assert that table exists and is not loading. - */ -const waitForTable = () => { - cy.dataCy("tasks-table").should("be.visible"); - cy.dataCy("tasks-table").should("not.have.attr", "data-loading", "true"); -}; - /** * Function used to assert if checkboxes with certain labels are checked or unchecked. * @param statuses list of labels to assert on diff --git a/cypress/integration/version/task_table.ts b/cypress/integration/version/task_table.ts index da7c591cf7..07f28d7103 100644 --- a/cypress/integration/version/task_table.ts +++ b/cypress/integration/version/task_table.ts @@ -1,4 +1,7 @@ -import { clickOnPageSizeBtnAndAssertURLandTableSize } from "../../utils"; +import { + clickOnPageSizeBtnAndAssertURLandTableSize, + waitForTaskTable, +} from "../../utils"; const pathTasks = `/version/5e4ff3abe3c3317e352062e4/tasks`; const patchDescriptionTasksExist = "dist"; @@ -14,6 +17,7 @@ describe("Task table", () => { it("Updates the url when column headers are clicked", () => { cy.visit(pathTasks); + waitForTaskTable(); cy.location("search").should( "contain", "sorts=STATUS%3AASC%3BBASE_STATUS%3ADESC" diff --git a/cypress/utils/index.ts b/cypress/utils/index.ts index 7d3e810191..f2b7048e2d 100644 --- a/cypress/utils/index.ts +++ b/cypress/utils/index.ts @@ -72,3 +72,13 @@ export const clickSave = () => { .should("not.have.attr", "aria-disabled", "true") .click(); }; + +/** + * Wait for the AntD task table to fully render and not be in a loading state + * This function helps ensure table column header button clicks register + */ +export const waitForTaskTable = () => { + cy.dataCy("tasks-table") + .should("be.visible") + .should("not.have.attr", "data-loading", "true"); +}; diff --git a/package.json b/package.json index c962b4be2e..b20b644b4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spruce", - "version": "3.0.150", + "version": "3.0.151", "private": true, "scripts": { "bootstrap-logkeeper": "./scripts/bootstrap-logkeeper.sh", diff --git a/src/components/Buttons/LoadingButton.stories.tsx b/src/components/Buttons/LoadingButton.stories.tsx new file mode 100644 index 0000000000..f2252e1fde --- /dev/null +++ b/src/components/Buttons/LoadingButton.stories.tsx @@ -0,0 +1,21 @@ +import { Variant } from "@leafygreen-ui/button"; +import { CustomStoryObj, CustomMeta } from "test_utils/types"; +import { LoadingButton } from "."; + +export default { + component: LoadingButton, +} satisfies CustomMeta; + +export const Default: CustomStoryObj = { + render: (args) => Button text, + args: { + loading: false, + variant: Variant.Default, + }, + argTypes: { + variant: { + options: Object.values(Variant), + control: { type: "select" }, + }, + }, +}; diff --git a/src/components/Buttons/LoadingButton.tsx b/src/components/Buttons/LoadingButton.tsx index ed203e1a55..e4443df704 100644 --- a/src/components/Buttons/LoadingButton.tsx +++ b/src/components/Buttons/LoadingButton.tsx @@ -1,19 +1,20 @@ import { forwardRef } from "react"; import { ExtendableBox } from "@leafygreen-ui/box"; import LeafyGreenButton, { ButtonProps } from "@leafygreen-ui/button"; -import Icon from "components/Icon"; +import { Spinner } from "@leafygreen-ui/loading-indicator"; -type Props = ButtonProps & { +type Props = Omit & { loading?: boolean; }; export const LoadingButton: ExtendableBox< Props & { ref?: React.Ref }, "button" -> = forwardRef(({ leftGlyph, loading = false, ...rest }: Props, ref) => ( +> = forwardRef(({ loading = false, ...rest }: Props, ref) => ( : leftGlyph} + isLoading={loading} + loadingIndicator={} {...rest} /> )); diff --git a/src/components/Settings/EventLog/EventLog.tsx b/src/components/Settings/EventLog/EventLog.tsx index 4ab57ae962..bac0e2cc5b 100644 --- a/src/components/Settings/EventLog/EventLog.tsx +++ b/src/components/Settings/EventLog/EventLog.tsx @@ -1,8 +1,7 @@ import styled from "@emotion/styled"; -import Button from "@leafygreen-ui/button"; import Card from "@leafygreen-ui/card"; -import { Spinner } from "@leafygreen-ui/loading-indicator"; import { Subtitle } from "@leafygreen-ui/typography"; +import { LoadingButton } from "components/Buttons"; import { size } from "constants/tokens"; import { EventDiffTable } from "./EventDiffTable"; import { Header } from "./Header"; @@ -42,15 +41,14 @@ export const EventLog: React.FC = ({ ); })} {!allEventsFetched && !!events.length && ( - + )} {allEventsFetched && {allEventsFetchedCopy}} diff --git a/src/components/SpruceForm/errors.ts b/src/components/SpruceForm/errors.ts index d1b0e3b8ac..3eea1a1a14 100644 --- a/src/components/SpruceForm/errors.ts +++ b/src/components/SpruceForm/errors.ts @@ -44,6 +44,11 @@ export const transformErrors = (errors: AjvError[]) => ...error, message: "Please select one of the available options.", }; + case "pattern": + return { + ...error, + message: `Field should match pattern ${error.params.pattern}`, + }; case "format": switch (error.params.format) { case "noSpaces": diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 13f4fb4116..8d704d3637 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -60,10 +60,14 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ credentials: "include", method: "GET", redirect: "manual", - }).then(() => { - dispatch({ type: "deauthenticated" }); - window.location.href = `${getLoginDomain()}/login`; - }); + }) + .then(() => { + dispatch({ type: "deauthenticated" }); + window.location.href = `${getLoginDomain()}/login`; + }) + .catch((error) => { + leaveBreadcrumb("Logout failed", { error }, "user"); + }); }, dispatchAuthenticated: () => { if (!state.isAuthenticated) { diff --git a/src/gql/generated/types.ts b/src/gql/generated/types.ts index 2304720817..3bb8b7449a 100644 --- a/src/gql/generated/types.ts +++ b/src/gql/generated/types.ts @@ -648,7 +648,9 @@ export type GroupedBuildVariant = { export type GroupedFiles = { __typename?: "GroupedFiles"; + execution: Scalars["Int"]["output"]; files?: Maybe>; + taskId: Scalars["String"]["output"]; taskName?: Maybe; }; @@ -8470,7 +8472,11 @@ export type UserDistroSettingsPermissionsQuery = { permissions: { __typename?: "Permissions"; canCreateDistro: boolean; - distroPermissions: { __typename?: "DistroPermissions"; admin: boolean }; + distroPermissions: { + __typename?: "DistroPermissions"; + admin: boolean; + edit: boolean; + }; }; }; }; diff --git a/src/gql/queries/user-distro-settings-permissions.graphql b/src/gql/queries/user-distro-settings-permissions.graphql index 41be96777d..bd16143c6d 100644 --- a/src/gql/queries/user-distro-settings-permissions.graphql +++ b/src/gql/queries/user-distro-settings-permissions.graphql @@ -4,6 +4,7 @@ query UserDistroSettingsPermissions($distroId: String!) { canCreateDistro distroPermissions(options: { distroId: $distroId }) { admin + edit } } userId diff --git a/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx b/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx index e7a80c91df..b8da523168 100644 --- a/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx +++ b/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx @@ -7,7 +7,7 @@ import Tooltip from "@leafygreen-ui/tooltip"; import { Body, Disclaimer } from "@leafygreen-ui/typography"; import pluralize from "pluralize"; import Icon from "components/Icon"; -import { CharKey, ModifierKey } from "constants/keys"; +import { CharKey } from "constants/keys"; import { size } from "constants/tokens"; import { VariantTask } from "gql/generated/types"; import useKeyboardShortcut from "hooks/useKeyboardShortcut"; @@ -61,8 +61,7 @@ const ConfigureTasks: React.FC = ({ const searchRef = useRef(null); useKeyboardShortcut( { - charKey: CharKey.F, - modifierKeys: [ModifierKey.Control], + charKey: CharKey.ForwardSlash, }, () => { searchRef.current?.focus(); diff --git a/src/pages/configurePatch/configurePatchCore/index.tsx b/src/pages/configurePatch/configurePatchCore/index.tsx index fe6cee86dd..57c80bfc78 100644 --- a/src/pages/configurePatch/configurePatchCore/index.tsx +++ b/src/pages/configurePatch/configurePatchCore/index.tsx @@ -2,10 +2,10 @@ import { useMemo } from "react"; import { useMutation } from "@apollo/client"; import styled from "@emotion/styled"; import Button from "@leafygreen-ui/button"; -import { Spinner } from "@leafygreen-ui/loading-indicator"; import { Tab } from "@leafygreen-ui/tabs"; import TextInput from "@leafygreen-ui/text-input"; import { useNavigate } from "react-router-dom"; +import { LoadingButton } from "components/Buttons"; import { CodeChanges } from "components/CodeChanges"; import { MetadataCard, @@ -153,28 +153,29 @@ const ConfigurePatchCore: React.FC = ({ patch }) => { value={description} onChange={(e) => setDescription(e.target.value)} /> - {activated && ( - - window.history.state.idx > 0 - ? navigate(-1) - : navigate(getVersionRoute(id)) - } + + {activated && ( + + )} + - Cancel - - )} - } - > - Schedule - + Schedule + + @@ -315,8 +316,10 @@ const StyledInput = styled(TextInput)` width: 100%; `; -const StyledButton = styled(Button)` +const ButtonWrapper = styled.div` margin-top: ${size.m}; + display: flex; + gap: ${size.s}; `; const FlexRow = styled.div` diff --git a/src/pages/distroSettings/DeleteDistro/DeleteDistro.test.tsx b/src/pages/distroSettings/DeleteDistro/DeleteDistro.test.tsx index 0d1299a913..83e6e26de9 100644 --- a/src/pages/distroSettings/DeleteDistro/DeleteDistro.test.tsx +++ b/src/pages/distroSettings/DeleteDistro/DeleteDistro.test.tsx @@ -131,6 +131,7 @@ const isAdminMock: ApolloMock< distroPermissions: { __typename: "DistroPermissions", admin: true, + edit: true, }, }, }, @@ -159,6 +160,7 @@ const notAdminMock: ApolloMock< distroPermissions: { __typename: "DistroPermissions", admin: false, + edit: false, }, }, }, diff --git a/src/pages/distroSettings/NewDistro/NewDistroButton.test.tsx b/src/pages/distroSettings/NewDistro/NewDistroButton.test.tsx index 1ca1c5ca9f..4e42215787 100644 --- a/src/pages/distroSettings/NewDistro/NewDistroButton.test.tsx +++ b/src/pages/distroSettings/NewDistro/NewDistroButton.test.tsx @@ -41,6 +41,7 @@ describe("new distro button", () => { distroPermissions: { __typename: "DistroPermissions", admin: false, + edit: false, }, }, }, @@ -131,6 +132,7 @@ const hasPermissionsMock: ApolloMock< distroPermissions: { __typename: "DistroPermissions", admin: true, + edit: true, }, }, }, diff --git a/src/pages/distroSettings/Tabs.tsx b/src/pages/distroSettings/Tabs.tsx index 4f11d8e1b0..edf8e07e50 100644 --- a/src/pages/distroSettings/Tabs.tsx +++ b/src/pages/distroSettings/Tabs.tsx @@ -43,7 +43,10 @@ export const DistroSettingsTabs: React.FC = ({ distro }) => { + } /> ({ initialFormState, ...rest }: BaseTabProps) => { - const { tab } = useParams<{ tab: T }>(); + const { distroId, tab } = useParams<{ distroId: string; tab: T }>(); const state = useDistroSettingsContext(); usePopulateForm(initialFormState, tab); + const { data } = useQuery< + UserDistroSettingsPermissionsQuery, + UserDistroSettingsPermissionsQueryVariables + >(USER_DISTRO_SETTINGS_PERMISSIONS, { + variables: { distroId }, + }); + const canEditDistro = + data?.user?.permissions?.distroPermissions?.edit ?? false; + return ( + disabled={!canEditDistro} {...rest} state={state} tab={tab} diff --git a/src/pages/distroSettings/tabs/GeneralTab/GeneralTab.tsx b/src/pages/distroSettings/tabs/GeneralTab/GeneralTab.tsx index f6f9161301..5202d65971 100644 --- a/src/pages/distroSettings/tabs/GeneralTab/GeneralTab.tsx +++ b/src/pages/distroSettings/tabs/GeneralTab/GeneralTab.tsx @@ -1,18 +1,31 @@ import { useMemo } from "react"; +import { useParams } from "react-router-dom"; import { SettingsCard, SettingsCardTitle } from "components/SettingsCard"; +import { useSpruceConfig } from "hooks"; import { DeleteDistro } from "pages/distroSettings/DeleteDistro"; import { BaseTab } from "../BaseTab"; import { getFormSchema } from "./getFormSchema"; import { TabProps } from "./types"; -export const GeneralTab: React.FC = ({ distroData }) => { - const initialFormState = distroData; +export const GeneralTab: React.FC = ({ + distroData, + minimumHosts, +}) => { + const { distroId } = useParams(); + const { containerPools } = useSpruceConfig(); + const containerPoolDistros = + containerPools?.pools?.map(({ distro }) => distro) ?? []; - const formSchema = useMemo(() => getFormSchema(), []); + const isContainerDistro = containerPoolDistros.includes(distroId); + + const formSchema = useMemo( + () => getFormSchema(isContainerDistro, minimumHosts), + [isContainerDistro, minimumHosts] + ); return ( <> - + Remove Configuration diff --git a/src/pages/distroSettings/tabs/GeneralTab/getFormSchema.ts b/src/pages/distroSettings/tabs/GeneralTab/getFormSchema.ts index 576b2fdd07..8f0a297d94 100644 --- a/src/pages/distroSettings/tabs/GeneralTab/getFormSchema.ts +++ b/src/pages/distroSettings/tabs/GeneralTab/getFormSchema.ts @@ -1,8 +1,10 @@ -import { css } from "@emotion/react"; import { GetFormSchema } from "components/SpruceForm"; import { CardFieldTemplate } from "components/SpruceForm/FieldTemplates"; -export const getFormSchema = (): ReturnType => ({ +export const getFormSchema = ( + isContainerDistro: boolean, + minimumHosts: number +): ReturnType => ({ fields: {}, schema: { type: "object" as "object", @@ -33,37 +35,30 @@ export const getFormSchema = (): ReturnType => ({ }, }, }, - distroNote: { - type: "object" as "object", - title: "Notes", - properties: { - note: { - type: "string" as "string", - title: "Notes", - default: "", - }, - }, - }, distroOptions: { type: "object" as "object", title: "Distro Options", properties: { isCluster: { type: "boolean" as "boolean", - title: - "Mark distro as a cluster (jobs are not run on this host, used for special purposes).", + title: "Mark distro as cluster", default: false, }, disableShallowClone: { type: "boolean" as "boolean", - title: "Disable shallow clone for this distro.", + title: "Disable shallow clone for this distro", default: false, }, disabled: { type: "boolean" as "boolean", - title: "Disable queueing for this distro.", + title: "Disable queueing for this distro", default: false, }, + note: { + type: "string" as "string", + title: "Notes", + default: "", + }, }, }, }, @@ -71,6 +66,13 @@ export const getFormSchema = (): ReturnType => ({ uiSchema: { distroName: { "ui:ObjectFieldTemplate": CardFieldTemplate, + identifier: { + ...(isContainerDistro && { + "ui:warnings": [ + "Distro is a container pool, so it cannot be spawned for tasks.", + ], + }), + }, }, distroAliases: { "ui:rootFieldId": "aliases", @@ -84,25 +86,22 @@ export const getFormSchema = (): ReturnType => ({ }, }, }, - distroNote: { - "ui:ObjectFieldTemplate": CardFieldTemplate, - note: { - "ui:widget": "textarea", - "ui:elementWrapperCSS": textAreaCSS, - }, - }, distroOptions: { "ui:ObjectFieldTemplate": CardFieldTemplate, + isCluster: { + "ui:description": + "Jobs will not be run on this host. Used for special purposes.", + }, disabled: { "ui:description": "Tasks already in the task queue will be removed.", + ...(minimumHosts > 0 && { + "ui:tooltipDescription": `This will still allow the minimum number of hosts (${minimumHosts}) to start`, + }), + }, + note: { + "ui:rows": 7, + "ui:widget": "textarea", }, }, }, }); - -const textAreaCSS = css` - box-sizing: border-box; - textarea { - min-height: 150px; - } -`; diff --git a/src/pages/distroSettings/tabs/GeneralTab/transformers.test.tsx b/src/pages/distroSettings/tabs/GeneralTab/transformers.test.tsx index c0d91aa99e..f37c54b5e2 100644 --- a/src/pages/distroSettings/tabs/GeneralTab/transformers.test.tsx +++ b/src/pages/distroSettings/tabs/GeneralTab/transformers.test.tsx @@ -20,13 +20,11 @@ const generalForm: GeneralFormState = { distroAliases: { aliases: ["rhel71-power8", "rhel71-power8-build"], }, - distroNote: { - note: "distro note", - }, distroOptions: { isCluster: false, disableShallowClone: true, disabled: false, + note: "distro note", }, }; @@ -34,8 +32,8 @@ const generalGql: DistroInput = { ...distroData, name: "rhel71-power8-large", aliases: ["rhel71-power8", "rhel71-power8-build"], - note: "distro note", isCluster: false, disableShallowClone: true, disabled: false, + note: "distro note", }; diff --git a/src/pages/distroSettings/tabs/GeneralTab/transformers.ts b/src/pages/distroSettings/tabs/GeneralTab/transformers.ts index 151d05b286..24c11d30c2 100644 --- a/src/pages/distroSettings/tabs/GeneralTab/transformers.ts +++ b/src/pages/distroSettings/tabs/GeneralTab/transformers.ts @@ -16,25 +16,23 @@ export const gqlToForm = ((data) => { distroAliases: { aliases, }, - distroNote: { - note, - }, distroOptions: { isCluster, disableShallowClone, disabled, + note, }, }; }) satisfies GqlToFormFunction; export const formToGql = (( - { distroAliases, distroName, distroNote, distroOptions }, + { distroAliases, distroName, distroOptions }, distro ) => ({ ...distro, name: distroName.identifier, aliases: distroAliases.aliases, - note: distroNote.note, + note: distroOptions.note, isCluster: distroOptions.isCluster, disableShallowClone: distroOptions.disableShallowClone, disabled: distroOptions.disabled, diff --git a/src/pages/distroSettings/tabs/GeneralTab/types.ts b/src/pages/distroSettings/tabs/GeneralTab/types.ts index 409dbede7b..998ae4f641 100644 --- a/src/pages/distroSettings/tabs/GeneralTab/types.ts +++ b/src/pages/distroSettings/tabs/GeneralTab/types.ts @@ -5,16 +5,15 @@ export interface GeneralFormState { distroAliases: { aliases: string[]; }; - distroNote: { - note: string; - }; distroOptions: { isCluster: boolean; disableShallowClone: boolean; disabled: boolean; + note: string; }; } export type TabProps = { distroData: GeneralFormState; + minimumHosts: number; }; diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx index 731890413b..ae02c6de4a 100644 --- a/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx @@ -128,7 +128,10 @@ export const getFormSchema = ({ setup: setup.uiSchema(architecture, hasStaticProvider), bootstrapSettings: bootstrapProperties.uiSchema(architecture), sshConfig: sshConfigProperties.uiSchema(hasStaticProvider), - allocation: allocationProperties.uiSchema(hasEC2Provider), + allocation: allocationProperties.uiSchema( + hasEC2Provider, + hasStaticProvider + ), }, }; }; diff --git a/src/pages/distroSettings/tabs/HostTab/schemaFields.tsx b/src/pages/distroSettings/tabs/HostTab/schemaFields.tsx index 2d05d38002..5e26595c99 100644 --- a/src/pages/distroSettings/tabs/HostTab/schemaFields.tsx +++ b/src/pages/distroSettings/tabs/HostTab/schemaFields.tsx @@ -145,6 +145,7 @@ export const icecreamSchedulerHost = { title: "Icecream Scheduler Host", }, uiSchema: { + "ui:description": "Host name to connect to the icecream scheduler", "ui:elementWrapperCSS": indentCSS, }, }; @@ -155,6 +156,7 @@ export const icecreamConfigPath = { title: "Icecream Config File Path", }, uiSchema: { + "ui:description": "Path to the icecream config file", "ui:elementWrapperCSS": indentCSS, }, }; @@ -285,7 +287,7 @@ const lockedMemoryKb = { const virtualMemoryKb = { schema: { type: "number" as "number", - title: "Virtual Memory (kB)", + title: "Virtual Memory", minimum: -1, }, uiSchema: { @@ -362,6 +364,12 @@ const preconditionScripts = { script: { "ui:description": "The precondition script that must run and succeed before Jasper can start.", + "ui:elementWrapperCSS": css` + textarea { + font-family: ${fontFamilies.code}; + } + `, + "ui:rows": 8, "ui:widget": "textarea", }, }, @@ -401,6 +409,8 @@ const authorizedKeysFile = { }, uiSchema: (hasStaticProvider: boolean) => ({ "ui:data-cy": "authorized-keys-input", + "ui:description": "Path to file containing authorized SSH keys", + "ui:placeholder": "~/.ssh/authorized_keys", ...(!hasStaticProvider && { "ui:widget": "hidden" }), }), }; @@ -448,9 +458,11 @@ const roundingRule = { title: "Host Allocator Rounding Rule", oneOf: enumSelect(roundingRuleToCopy), }, - uiSchema: { + uiSchema: (hasStaticProvider: boolean) => ({ "ui:allowDeselect": false, - }, + "ui:data-cy": "rounding-rule-select", + ...(hasStaticProvider && { "ui:widget": "hidden" }), + }), }; const feedbackRule = { @@ -459,9 +471,11 @@ const feedbackRule = { title: "Host Allocator Feedback Rule", oneOf: enumSelect(feedbackRuleToCopy), }, - uiSchema: { + uiSchema: (hasStaticProvider: boolean) => ({ "ui:allowDeselect": false, - }, + "ui:data-cy": "feedback-rule-select", + ...(hasStaticProvider && { "ui:widget": "hidden" }), + }), }; const hostsOverallocatedRule = { @@ -490,7 +504,7 @@ const minimumHosts = { const maximumHosts = { schema: { type: "number" as "number", - title: "Maxiumum Number of Hosts Allowed", + title: "Maximum Number of Hosts Allowed", minimum: 0, }, uiSchema: (hasEC2Provider: boolean) => ({ @@ -502,7 +516,7 @@ const maximumHosts = { const acceptableHostIdleTime = { schema: { type: "number" as "number", - title: "Acceptable Host Idle Time (s)", + title: "Acceptable Host Idle Time (ms)", minimum: 0, }, uiSchema: (hasEC2Provider: boolean) => ({ @@ -614,11 +628,11 @@ export const allocation = { acceptableHostIdleTime: acceptableHostIdleTime.schema, futureHostFraction: futureHostFraction.schema, }, - uiSchema: (hasEC2Provider: boolean) => ({ + uiSchema: (hasEC2Provider: boolean, hasStaticProvider: boolean) => ({ "ui:ObjectFieldTemplate": CardFieldTemplate, version: version.uiSchema, - roundingRule: roundingRule.uiSchema, - feedbackRule: feedbackRule.uiSchema, + roundingRule: roundingRule.uiSchema(hasStaticProvider), + feedbackRule: feedbackRule.uiSchema(hasStaticProvider), hostsOverallocatedRule: hostsOverallocatedRule.uiSchema, minimumHosts: minimumHosts.uiSchema(hasEC2Provider), maximumHosts: maximumHosts.uiSchema(hasEC2Provider), diff --git a/src/pages/distroSettings/tabs/ProviderTab/schemaFields.ts b/src/pages/distroSettings/tabs/ProviderTab/schemaFields.ts index 3e8527fceb..98aba4438e 100644 --- a/src/pages/distroSettings/tabs/ProviderTab/schemaFields.ts +++ b/src/pages/distroSettings/tabs/ProviderTab/schemaFields.ts @@ -38,6 +38,7 @@ const securityGroups = { title: "Security Group ID", default: "", minLength: 1, + pattern: "^sg-.*", }, }, uiSchema: { @@ -257,10 +258,15 @@ const vpcOptions = { subnetId: { type: "string" as "string", title: "Default VPC Subnet ID", + default: "", + minLength: 1, + pattern: "^subnet-.*", }, subnetPrefix: { type: "string" as "string", title: "VPC Subnet Prefix", + default: "", + minLength: 1, }, }, }, diff --git a/src/pages/distroSettings/tabs/ProviderTab/styles.ts b/src/pages/distroSettings/tabs/ProviderTab/styles.ts index ce4089ef20..9fa21f7df8 100644 --- a/src/pages/distroSettings/tabs/ProviderTab/styles.ts +++ b/src/pages/distroSettings/tabs/ProviderTab/styles.ts @@ -5,14 +5,12 @@ import { size } from "constants/tokens"; const textAreaCSS = css` box-sizing: border-box; - max-width: ${STANDARD_FIELD_WIDTH}px; textarea { font-family: ${fontFamilies.code}; } `; const mergeCheckboxCSS = css` - max-width: ${STANDARD_FIELD_WIDTH}px; display: flex; justify-content: flex-end; margin-bottom: -20px; diff --git a/src/pages/distroSettings/tabs/TaskTab/getFormSchema.ts b/src/pages/distroSettings/tabs/TaskTab/getFormSchema.ts index 78ab84bd8b..b68c45d557 100644 --- a/src/pages/distroSettings/tabs/TaskTab/getFormSchema.ts +++ b/src/pages/distroSettings/tabs/TaskTab/getFormSchema.ts @@ -94,51 +94,48 @@ export const getFormSchema = ({ properties: { targetTime: { type: "number" as "number", - title: "Target Time (seconds)", + title: "Target Time (ms)", default: 0, minimum: 0, - maximum: 1800, }, patchFactor: { type: "number" as "number", - title: "Patch Factor (0 to 100 inclusive)", + title: "Patch Factor", default: 0, minimum: 0, maximum: 100, }, patchTimeInQueueFactor: { type: "number" as "number", - title: - "Patch Time in Queue Factor (0 to 100 inclusive)", + title: "Patch Time in Queue Factor", default: 0, minimum: 0, maximum: 100, }, mainlineTimeInQueueFactor: { type: "number" as "number", - title: - "Mainline Time in Queue Factor (0 to 100 inclusive)", + title: "Mainline Time in Queue Factor", default: 0, minimum: 0, maximum: 100, }, commitQueueFactor: { type: "number" as "number", - title: "Commit Queue Factor (0 to 100 inclusive)", + title: "Commit Queue Factor", default: 0, minimum: 0, maximum: 100, }, expectedRuntimeFactor: { type: "number" as "number", - title: "Expected Runtime Factor (0 to 100 inclusive)", + title: "Expected Runtime Factor", default: 0, minimum: 0, maximum: 100, }, generateTaskFactor: { type: "number" as "number", - title: "Generate Task Factor (0 to 100 inclusive)", + title: "Generate Task Factor", default: 0, minimum: 0, maximum: 100, @@ -195,6 +192,30 @@ export const getFormSchema = ({ tunableOptions: { "ui:field-data-cy": "tunable-options", ...(!hasEC2Provider && { "ui:widget": "hidden" }), + patchFactor: { + "ui:description": + "Set 0 to use global default. Value should range from 0 to 100 inclusive.", + }, + patchTimeInQueueFactor: { + "ui:description": + "Set 0 to use global default. Value should range from 0 to 100 inclusive.", + }, + mainlineTimeInQueueFactor: { + "ui:description": + "Set 0 to use global default. Value should range from 0 to 100 inclusive.", + }, + commitQueueFactor: { + "ui:description": + "Set 0 to use global default. Value should range from 0 to 100 inclusive.", + }, + expectedRuntimeFactor: { + "ui:description": + "Set 0 to use global default. Value should range from 0 to 100 inclusive.", + }, + generateTaskFactor: { + "ui:description": + "Set 0 to use global default. Value should range from 0 to 100 inclusive.", + }, }, }, dispatcherSettings: { diff --git a/src/pages/task/actionButtons/previousCommits/index.tsx b/src/pages/task/actionButtons/previousCommits/index.tsx index 66f5bbc50d..bca43f6812 100644 --- a/src/pages/task/actionButtons/previousCommits/index.tsx +++ b/src/pages/task/actionButtons/previousCommits/index.tsx @@ -1,11 +1,10 @@ import { useReducer, useEffect } from "react"; import { useLazyQuery, useQuery } from "@apollo/client"; import styled from "@emotion/styled"; -import Button from "@leafygreen-ui/button"; -import { Spinner } from "@leafygreen-ui/loading-indicator"; import { Option, Select } from "@leafygreen-ui/select"; import Tooltip from "@leafygreen-ui/tooltip"; import { useTaskAnalytics } from "analytics"; +import { LoadingButton } from "components/Buttons"; import { ConditionalWrapper } from "components/ConditionalWrapper"; import { finishedTaskStatuses } from "constants/task"; import { size } from "constants/tokens"; @@ -201,22 +200,21 @@ export const PreviousCommits: React.FC = ({ taskId }) => { )} > - + );