diff --git a/apps/parsley/src/gql/generated/types.ts b/apps/parsley/src/gql/generated/types.ts index 1837b10df..ad2efbbdd 100644 --- a/apps/parsley/src/gql/generated/types.ts +++ b/apps/parsley/src/gql/generated/types.ts @@ -453,7 +453,8 @@ export type DistroInput = { providerSettingsList: Array; setup: Scalars["String"]["input"]; setupAsSudo: Scalars["Boolean"]["input"]; - sshKey: Scalars["String"]["input"]; + /** @deprecated removing this field shortly */ + sshKey?: InputMaybe; sshOptions: Array; user: Scalars["String"]["input"]; userSpawnAllowed: Scalars["Boolean"]["input"]; @@ -2462,7 +2463,8 @@ export type SpruceConfig = { containerPools?: Maybe; githubOrgs: Array; jira?: Maybe; - keys: Array; + /** @deprecated removing this field shortly */ + keys?: Maybe>; providers?: Maybe; secretFields: Array; slack?: Maybe; diff --git a/apps/spruce/cypress/integration/spawn/host.ts b/apps/spruce/cypress/integration/spawn/host.ts index f18e8912b..4b2278abb 100644 --- a/apps/spruce/cypress/integration/spawn/host.ts +++ b/apps/spruce/cypress/integration/spawn/host.ts @@ -88,23 +88,16 @@ describe("Navigating to Spawn Host page", () => { }); describe("Spawn host modal", () => { - it("Should disable 'Never expire' checkbox when max number of unexpirable hosts is met (2)", () => { + it("Should disable 'Unexpirable Host' radio box when max number of unexpirable hosts is met (2)", () => { cy.visit("/spawn/host"); cy.contains("Spawn a host").click(); cy.dataCy("distro-input").click(); cy.dataCy("distro-option-ubuntu1804-workstation") .should("be.visible") .click(); - cy.dataCy("never-expire-checkbox").should( - "have.attr", - "aria-checked", - "false", - ); - cy.dataCy("never-expire-checkbox").should( - "have.css", - "pointer-events", - "none", - ); + cy.dataCy("expirable-radio-box").children().should("have.length", 2); + cy.getInputByLabel("Expirable Host").should("not.be.disabled"); + cy.getInputByLabel("Unexpirable Host").should("be.disabled"); }); it("Clicking on the spawn host button should open a spawn host modal.", () => { diff --git a/apps/spruce/src/components/ProjectSelect/__snapshots__/ProjectSelect.stories.storyshot b/apps/spruce/src/components/ProjectSelect/__snapshots__/ProjectSelect.stories.storyshot index 158d0afb7..d84f8cc4f 100644 --- a/apps/spruce/src/components/ProjectSelect/__snapshots__/ProjectSelect.stories.storyshot +++ b/apps/spruce/src/components/ProjectSelect/__snapshots__/ProjectSelect.stories.storyshot @@ -12,7 +12,7 @@ exports[`Snapshot Tests ProjectSelect.stories Default 1`] = ` Project
props.width ? props.width : ""}; `; diff --git a/apps/spruce/src/components/Spawn/getFormSchema.tsx b/apps/spruce/src/components/Spawn/getFormSchema.tsx new file mode 100644 index 000000000..3b7e99c5e --- /dev/null +++ b/apps/spruce/src/components/Spawn/getFormSchema.tsx @@ -0,0 +1,178 @@ +import { css } from "@emotion/react"; +import Badge from "@leafygreen-ui/badge"; +import { Body } from "@leafygreen-ui/typography"; +import widgets from "components/SpruceForm/Widgets"; +import { prettifyTimeZone } from "constants/fieldMaps"; +import { size } from "constants/tokens"; + +const defaultStartTime = new Date(); +defaultStartTime.setHours(8, 0, 0, 0); +const defaultEndTime = new Date(); +defaultEndTime.setHours(20, 0, 0, 0); + +type HostUptimeProps = { + hostUptimeValidation?: { + enabledHoursCount: number; + errors: string[]; + warnings: string[]; + }; + timeZone?: string; + totalUptimeHours?: number; +}; + +export const getHostUptimeSchema = ({ + hostUptimeValidation, + timeZone, +}: HostUptimeProps) => ({ + schema: { + type: "object" as "object", + title: "", + properties: { + useDefaultUptimeSchedule: { + type: "boolean" as "boolean", + title: "Use default host uptime schedule (Mon–Fri, 8am–8pm)", + default: true, + }, + sleepSchedule: { + type: "object" as "object", + title: "", + properties: { + enabledWeekdays: { + type: "array" as "array", + title: "", + default: [false, true, true, true, true, true, false], + items: { + type: "boolean" as "boolean", + }, + }, + timeSelection: { + type: "object" as "object", + title: "", + properties: { + startTime: { + type: "string" as "string", + title: "Start Time", + default: defaultStartTime.toString(), + }, + endTime: { + type: "string" as "string", + title: "End Time", + default: defaultEndTime.toString(), + }, + or: { + type: "null" as "null", + }, + runContinuously: { + type: "boolean" as "boolean", + title: "Run continuously for enabled days", + }, + }, + dependencies: { + runContinuously: { + oneOf: [ + { + properties: { + runContinuously: { enum: [false] }, + }, + }, + { + properties: { + runContinuously: { enum: [true] }, + startTime: { readOnly: true }, + endTime: { readOnly: true }, + }, + }, + ], + }, + }, + }, + }, + }, + details: { + type: "null" as "null", + }, + }, + dependencies: { + useDefaultUptimeSchedule: { + oneOf: [ + { + properties: { + useDefaultUptimeSchedule: { enum: [false] }, + }, + }, + { + properties: { + useDefaultUptimeSchedule: { enum: [true] }, + sleepSchedule: { readOnly: true }, + }, + }, + ], + }, + }, + }, + uiSchema: { + useDefaultUptimeSchedule: { + "ui:bold": true, + }, + sleepSchedule: { + enabledWeekdays: { + "ui:addable": false, + "ui:showLabel": false, + "ui:widget": widgets.DayPickerWidget, + }, + timeSelection: { + "ui:fieldSetCSS": css` + align-items: center; + display: flex; + gap: ${size.xs}; + > * { + width: fit-content; + } + `, + startTime: { + "ui:format": "HH:mm", + "ui:useUtc": false, + "ui:widget": widgets.TimeWidget, + }, + endTime: { + "ui:format": "HH:mm", + "ui:useUtc": false, + "ui:widget": widgets.TimeWidget, + }, + or: { + "ui:showLabel": false, + "ui:descriptionNode": or, + }, + runContinuously: { + "ui:elementWrapperCSS": css` + margin-bottom: 0; + white-space: nowrap; + width: fit-content; + `, + }, + }, + }, + details: { + "ui:descriptionNode": ( +
+ ), + "ui:showLabel": false, + "ui:warnings": hostUptimeValidation?.warnings, + "ui:errors": hostUptimeValidation?.errors, + }, + }, +}); + +const Details: React.FC<{ timeZone: string; totalUptimeHours: number }> = ({ + timeZone, + totalUptimeHours, +}) => ( + <> + All times are displayed in{" "} + {prettifyTimeZone.get(timeZone) ?? timeZone} •{" "} + {totalUptimeHours} host uptime hours per week + +); diff --git a/apps/spruce/src/components/Spawn/index.tsx b/apps/spruce/src/components/Spawn/index.tsx index 5c67e61d0..86cea9ae2 100644 --- a/apps/spruce/src/components/Spawn/index.tsx +++ b/apps/spruce/src/components/Spawn/index.tsx @@ -1,3 +1,4 @@ export * from "./Layout"; export { DetailsCard } from "./DetailsCard"; export { MountVolumeSelect } from "./MountVolumeSelect"; +export { maxUptimeHours, validateUptimeSchedule } from "./utils"; diff --git a/apps/spruce/src/components/Spawn/spawnHostModal/getFormSchema.tsx b/apps/spruce/src/components/Spawn/spawnHostModal/getFormSchema.tsx index e8e3db461..c1a1366b9 100644 --- a/apps/spruce/src/components/Spawn/spawnHostModal/getFormSchema.tsx +++ b/apps/spruce/src/components/Spawn/spawnHostModal/getFormSchema.tsx @@ -8,30 +8,38 @@ import { SpawnTaskQuery, MyVolumesQuery, } from "gql/generated/types"; +import { isProduction } from "utils/environmentVariables"; import { shortenGithash } from "utils/string"; +import { getHostUptimeSchema } from "../getFormSchema"; import { getDefaultExpiration } from "../utils"; import { DEFAULT_VOLUME_SIZE } from "./constants"; import { validateTask } from "./utils"; import { DistroDropdown } from "./Widgets/DistroDropdown"; interface Props { + awsRegions: string[]; + disableExpirationCheckbox: boolean; + distroIdQueryParam?: string; distros: { adminOnly: boolean; isVirtualWorkStation: boolean; name?: string; }[]; - awsRegions: string[]; - disableExpirationCheckbox: boolean; - distroIdQueryParam?: string; + hostUptimeValidation?: { + enabledHoursCount: number; + errors: string[]; + warnings: string[]; + }; + isMigration: boolean; isVirtualWorkstation: boolean; - noExpirationCheckboxTooltip: string; myPublicKeys: MyPublicKeysQuery["myPublicKeys"]; + noExpirationCheckboxTooltip: string; spawnTaskData?: SpawnTaskQuery["task"]; - userAwsRegion?: string; - volumes: MyVolumesQuery["myVolumes"]; - isMigration: boolean; + timeZone?: string; useSetupScript?: boolean; useProjectSetupScript?: boolean; + userAwsRegion?: string; + volumes: MyVolumesQuery["myVolumes"]; } export const getFormSchema = ({ @@ -39,11 +47,13 @@ export const getFormSchema = ({ disableExpirationCheckbox, distroIdQueryParam, distros, + hostUptimeValidation, isMigration, isVirtualWorkstation, myPublicKeys, noExpirationCheckboxTooltip, spawnTaskData, + timeZone, useProjectSetupScript = false, useSetupScript = false, userAwsRegion, @@ -61,44 +71,47 @@ export const getFormSchema = ({ const availableVolumes = volumes ? volumes.filter((v) => v.homeVolume && !v.hostID) : []; + const hostUptime = getHostUptimeSchema({ hostUptimeValidation, timeZone }); return { fields: {}, schema: { type: "object" as "object", properties: { - requiredHostInformationTitle: { - title: "Required Host Information", - type: "null", - }, - distro: { - type: "string" as "string", - title: "Distro", - default: distroIdQueryParam, - enum: distros?.map(({ name }) => name), - minLength: 1, - }, - region: { - type: "string" as "string", - title: "Region", - default: userAwsRegion || (awsRegions?.length && awsRegions[0]), - oneOf: [ - ...(awsRegions?.map((r) => ({ + requiredSection: { + type: "object" as "object", + title: "", + properties: { + distro: { type: "string" as "string", - title: r, - enum: [r], - })) || []), - ], - minLength: 1, + title: "Distro", + default: distroIdQueryParam, + enum: distros?.map(({ name }) => name), + minLength: 1, + }, + region: { + type: "string" as "string", + title: "Region", + default: userAwsRegion || (awsRegions?.length && awsRegions[0]), + oneOf: [ + ...(awsRegions?.map((r) => ({ + type: "string" as "string", + title: r, + enum: [r], + })) || []), + ], + minLength: 1, + }, + }, }, publicKeySection: { - title: "", type: "object", + title: "Key selection", properties: { useExisting: { - title: "Key selection", default: true, type: "boolean" as "boolean", + title: "", oneOf: [ { type: "boolean" as "boolean", @@ -193,6 +206,56 @@ export const getFormSchema = ({ }, }, }, + expirationDetails: { + title: "Expiration Details", + type: "object" as "object", + properties: { + noExpiration: { + default: false, + type: "boolean" as "boolean", + title: "", + oneOf: [ + { + type: "boolean" as "boolean", + title: "Expirable Host", + enum: [false], + }, + { + type: "boolean" as "boolean", + title: "Unexpirable Host", + enum: [true], + }, + ], + }, + }, + dependencies: { + noExpiration: { + oneOf: [ + { + properties: { + noExpiration: { + enum: [false], + }, + expiration: { + type: "string" as "string", + title: "Expiration", + default: getDefaultExpiration(), + minLength: 6, + }, + }, + }, + { + properties: { + noExpiration: { + enum: [true], + }, + ...(!isProduction() && { hostUptime: hostUptime.schema }), + }, + }, + ], + }, + }, + }, optionalInformationTitle: { title: "Optional Host Details", type: "null", @@ -308,46 +371,6 @@ export const getFormSchema = ({ }, }, }), - expirationDetails: { - title: "", - type: "object" as "object", - properties: { - noExpiration: { - default: false, - type: "boolean" as "boolean", - title: "Never expire", - }, - expiration: { - type: "string" as "string", - title: "Expiration", - default: getDefaultExpiration(), - minLength: 6, - }, - }, - dependencies: { - noExpiration: { - oneOf: [ - { - properties: { - noExpiration: { - enum: [false], - }, - }, - }, - { - properties: { - noExpiration: { - enum: [true], - }, - expiration: { - readOnly: true, - }, - }, - }, - ], - }, - }, - }, ...(shouldRenderVolumeSelection && { homeVolumeDetails: { type: "object" as "object", @@ -447,18 +470,23 @@ export const getFormSchema = ({ }, }, uiSchema: { - distro: { - "ui:widget": DistroDropdown, - "ui:elementWrapperCSS": dropdownWrapperClassName, - "ui:data-cy": "distro-input", - "ui:distros": distros, - }, - region: { - "ui:data-cy": "region-select", - "ui:disabled": isMigration, - "ui:elementWrapperCSS": dropdownWrapperClassName, - "ui:placeholder": "Select a region", - "ui:allowDeselect": false, + requiredSection: { + "ui:fieldSetCSS": css` + display: flex; + `, + distro: { + "ui:widget": DistroDropdown, + "ui:elementWrapperCSS": dropdownWrapperClassName, + "ui:data-cy": "distro-input", + "ui:distros": distros, + }, + region: { + "ui:data-cy": "region-select", + "ui:disabled": isMigration, + "ui:elementWrapperCSS": dropdownWrapperClassName, + "ui:placeholder": "Select a region", + "ui:allowDeselect": false, + }, }, publicKeySection: { useExisting: { @@ -495,11 +523,13 @@ export const getFormSchema = ({ }, }, expirationDetails: { + "ui:tooltipTitle": noExpirationCheckboxTooltip ?? "", noExpiration: { - "ui:disabled": disableExpirationCheckbox, - "ui:tooltipDescription": noExpirationCheckboxTooltip ?? "", - "ui:data-cy": "never-expire-checkbox", + "ui:enumDisabled": disableExpirationCheckbox ? [true] : null, + "ui:data-cy": "expirable-radio-box", + "ui:widget": widgets.RadioBoxWidget, }, + hostUptime: hostUptime.uiSchema, expiration: { "ui:disableBefore": add(today, { days: 1 }), "ui:disableAfter": add(today, { days: 30 }), diff --git a/apps/spruce/src/components/Spawn/spawnHostModal/transformer.test.ts b/apps/spruce/src/components/Spawn/spawnHostModal/transformer.test.ts index 0eea1cd99..f0f6beeed 100644 --- a/apps/spruce/src/components/Spawn/spawnHostModal/transformer.test.ts +++ b/apps/spruce/src/components/Spawn/spawnHostModal/transformer.test.ts @@ -1,4 +1,6 @@ +import { SpawnHostInput } from "gql/generated/types"; import { formToGql } from "./transformer"; +import { FormState } from "./types"; describe("spawn host modal", () => { it("correctly converts from a form to GQL", () => { @@ -9,6 +11,7 @@ describe("spawn host modal", () => { formData, myPublicKeys, spawnTaskData: null, + timeZone: "America/New_York", }), ).toStrictEqual(mutationInput); }); @@ -23,6 +26,7 @@ describe("spawn host modal", () => { myPublicKeys, spawnTaskData: null, migrateVolumeId, + timeZone: "America/New_York", }), ).toStrictEqual({ ...mutationInput, @@ -35,11 +39,13 @@ describe("spawn host modal", () => { const myPublicKeys = [{ name: "a_key", key: "key value" }]; -const data = [ +const data: Array<{ formData: FormState; mutationInput: SpawnHostInput }> = [ { formData: { - distro: "ubuntu1804-workstation", - region: "us-east-1", + requiredSection: { + distro: "ubuntu1804-workstation", + region: "us-east-1", + }, publicKeySection: { useExisting: false, newPublicKey: "blah blahsart", @@ -85,12 +91,15 @@ const data = [ setUpScript: "setup!!!", spawnHostsStartedByTask: false, taskSync: false, + sleepSchedule: null, }, }, { formData: { - distro: "rhel71-power8-large", - region: "rofl-east", + requiredSection: { + distro: "rhel71-power8-large", + region: "rofl-east", + }, publicKeySection: { useExisting: true, publicKeyNameDropdown: "a_key", @@ -100,7 +109,9 @@ const data = [ setupScriptSection: { defineSetupScriptCheckbox: false }, expirationDetails: { noExpiration: true, - expiration: "Wed Oct 19 2022 08:56:42 GMT-0400 (Eastern Daylight Time)", + hostUptime: { + useDefaultUptimeSchedule: true, + }, }, homeVolumeDetails: { selectExistingVolume: true, volumeSelect: "" }, }, @@ -123,6 +134,14 @@ const data = [ setUpScript: null, spawnHostsStartedByTask: false, taskSync: false, + sleepSchedule: { + dailyStartTime: "08:00", + dailyStopTime: "20:00", + permanentlyExempt: false, + timeZone: "America/New_York", + shouldKeepOff: false, + wholeWeekdaysOff: [0, 6], + }, }, }, ]; diff --git a/apps/spruce/src/components/Spawn/spawnHostModal/transformer.ts b/apps/spruce/src/components/Spawn/spawnHostModal/transformer.ts index 9926d8f4c..c5d2dde99 100644 --- a/apps/spruce/src/components/Spawn/spawnHostModal/transformer.ts +++ b/apps/spruce/src/components/Spawn/spawnHostModal/transformer.ts @@ -1,5 +1,6 @@ import { MyPublicKeysQuery, + SleepScheduleInput, SpawnTaskQuery, SpawnHostMutationVariables, } from "gql/generated/types"; @@ -14,6 +15,7 @@ interface Props { myPublicKeys: MyPublicKeysQuery["myPublicKeys"]; spawnTaskData?: SpawnTaskQuery["task"]; migrateVolumeId?: string; + timeZone?: string; } export const formToGql = ({ formData, @@ -21,17 +23,18 @@ export const formToGql = ({ migrateVolumeId, myPublicKeys, spawnTaskData, + timeZone, }: Props): SpawnHostMutationVariables["spawnHostInput"] => { const { - distro, expirationDetails, homeVolumeDetails, loadData, publicKeySection, - region, + requiredSection: { distro, region }, setupScriptSection, userdataScriptSection, } = formData || {}; + const { hostUptime } = expirationDetails; return { isVirtualWorkStation, userDataScript: userdataScriptSection?.runUserdataScript @@ -41,6 +44,10 @@ export const formToGql = ({ ? null : new Date(expirationDetails?.expiration), noExpiration: expirationDetails?.noExpiration, + sleepSchedule: + expirationDetails?.noExpiration && hostUptime + ? getSleepSchedule(hostUptime, timeZone) + : null, volumeId: migrateVolumeId || (isVirtualWorkStation && homeVolumeDetails?.selectExistingVolume @@ -84,3 +91,55 @@ export const formToGql = ({ taskSync: !!(loadData?.loadDataOntoHostAtStartup && loadData?.taskSync), }; }; + +const getSleepSchedule = ( + { + sleepSchedule, + useDefaultUptimeSchedule, + }: FormState["expirationDetails"]["hostUptime"], + timeZone: string, +): SleepScheduleInput => { + if (useDefaultUptimeSchedule) { + return getDefaultSleepSchedule({ timeZone }); + } + + const { + enabledWeekdays, + timeSelection: { endTime, runContinuously, startTime }, + } = sleepSchedule; + + const schedule = { + permanentlyExempt: false, + shouldKeepOff: false, + timeZone, + wholeWeekdaysOff: enabledWeekdays.reduce((accum, isEnabled, i) => { + if (!isEnabled) { + accum.push(i); + } + return accum; + }, []), + } as SleepScheduleInput; + + if (!runContinuously) { + const startDate = new Date(startTime); + const stopDate = new Date(endTime); + schedule.dailyStartTime = `${startDate.getHours()}:${startDate.getMinutes()}`; + schedule.dailyStopTime = `${stopDate.getHours()}:${stopDate.getMinutes()}`; + } + + return schedule; +}; + +const getDefaultSleepSchedule = ({ timeZone }): SleepScheduleInput => { + const sleepSchedule: SleepScheduleInput = { + dailyStartTime: "08:00", + dailyStopTime: "20:00", + permanentlyExempt: false, + // TODO: Add pause + shouldKeepOff: false, + timeZone, + wholeWeekdaysOff: [0, 6], + }; + + return sleepSchedule; +}; diff --git a/apps/spruce/src/components/Spawn/spawnHostModal/types.ts b/apps/spruce/src/components/Spawn/spawnHostModal/types.ts index b517b3d70..22e8feb55 100644 --- a/apps/spruce/src/components/Spawn/spawnHostModal/types.ts +++ b/apps/spruce/src/components/Spawn/spawnHostModal/types.ts @@ -1,6 +1,8 @@ export type FormState = { - distro?: string; - region?: string; + requiredSection?: { + distro?: string; + region?: string; + }; publicKeySection?: { useExisting: boolean; newPublicKey?: string; @@ -8,6 +10,21 @@ export type FormState = { savePublicKey?: boolean; newPublicKeyName?: string; }; + expirationDetails?: { + noExpiration: boolean; + expiration?: string; + hostUptime?: { + useDefaultUptimeSchedule: boolean; + sleepSchedule?: { + enabledWeekdays: boolean[]; + timeSelection: { + startTime: string; + endTime: string; + runContinuously: boolean; + }; + }; + }; + }; userdataScriptSection?: { runUserdataScript: boolean; userdataScript?: string; @@ -16,10 +33,6 @@ export type FormState = { defineSetupScriptCheckbox: boolean; setupScript?: string; }; - expirationDetails?: { - noExpiration: boolean; - expiration?: string; - }; homeVolumeDetails?: { selectExistingVolume: boolean; volumeSize?: number; diff --git a/apps/spruce/src/components/Spawn/utils.ts b/apps/spruce/src/components/Spawn/utils.ts index b673fb782..8620f0f21 100644 --- a/apps/spruce/src/components/Spawn/utils.ts +++ b/apps/spruce/src/components/Spawn/utils.ts @@ -1,8 +1,16 @@ +import { differenceInHours } from "date-fns"; + +const daysInWeek = 7; +const hoursInDay = 24; +export const maxUptimeHours = (daysInWeek - 1) * hoursInDay; +const suggestedUptimeHours = (daysInWeek - 2) * hoursInDay; + interface GetNoExpirationCheckboxTooltipCopyProps { disableExpirationCheckbox: boolean; isVolume: boolean; limit: number; } + export const getNoExpirationCheckboxTooltipCopy = ({ disableExpirationCheckbox, isVolume, @@ -21,3 +29,101 @@ export const getDefaultExpiration = () => { nextWeek.setDate(nextWeek.getDate() + 7); return nextWeek.toString(); }; + +type ValidateInput = { + enabledWeekdays: boolean[]; + endTime: string; + runContinuously: boolean; + startTime: string; + useDefaultUptimeSchedule: boolean; +}; + +export const validateUptimeSchedule = ({ + enabledWeekdays, + endTime, + runContinuously, + startTime, + useDefaultUptimeSchedule, +}: ValidateInput): { + enabledHoursCount: number; + errors: string[]; + warnings: string[]; +} => { + const { enabledHoursCount, enabledWeekdaysCount } = getEnabledHoursCount({ + enabledWeekdays, + endTime, + runContinuously, + startTime, + }); + + if (useDefaultUptimeSchedule) { + return { + enabledHoursCount, + errors: [], + warnings: [], + }; + } + + if (enabledHoursCount > maxUptimeHours) { + // Return error based on whether runContinously enabled + if (runContinuously) { + return { + enabledHoursCount, + errors: ["Please pause your host for at least 1 day per week."], + warnings: [], + }; + } + const hourlyRequirement = Math.floor(maxUptimeHours / enabledWeekdaysCount); + return { + enabledHoursCount, + errors: [ + `Please reduce your host uptime to a max of ${hourlyRequirement} hours per day.`, + ], + warnings: [], + }; + } + + if (enabledHoursCount > suggestedUptimeHours) { + // Return warning based on whether runContinuously enabled + if (runContinuously) { + return { + enabledHoursCount, + errors: [], + warnings: ["Consider pausing your host for 2 days per week."], + }; + } + const hourlySuggestion = Math.floor( + suggestedUptimeHours / enabledWeekdaysCount, + ); + return { + enabledHoursCount, + errors: [], + warnings: [ + `Consider running your host for ${hourlySuggestion} hours per day or fewer.`, + ], + }; + } + // No error + return { + enabledHoursCount, + errors: [], + warnings: [], + }; +}; + +const getEnabledHoursCount = ({ + enabledWeekdays, + endTime, + runContinuously, + startTime, +}: Omit) => { + const enabledWeekdaysCount = + enabledWeekdays?.filter((day) => day).length ?? 0; + const enabledHoursCount = runContinuously + ? enabledWeekdaysCount * hoursInDay + : enabledWeekdaysCount * getDailyUptime({ startTime, endTime }); + return { enabledHoursCount, enabledWeekdaysCount }; +}; + +const getDailyUptime = ({ endTime, startTime }) => + differenceInHours(new Date(endTime), new Date(startTime)); diff --git a/apps/spruce/src/components/SpruceForm/FieldTemplates/ObjectFieldTemplates/index.tsx b/apps/spruce/src/components/SpruceForm/FieldTemplates/ObjectFieldTemplates/index.tsx index 5e6c525ab..628362b5c 100644 --- a/apps/spruce/src/components/SpruceForm/FieldTemplates/ObjectFieldTemplates/index.tsx +++ b/apps/spruce/src/components/SpruceForm/FieldTemplates/ObjectFieldTemplates/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable jsdoc/valid-types */ import styled from "@emotion/styled"; import Banner from "@leafygreen-ui/banner"; +import { InfoSprinkle } from "@leafygreen-ui/info-sprinkle"; import { Subtitle } from "@leafygreen-ui/typography"; import { ObjectFieldTemplateProps } from "@rjsf/core"; import { Accordion } from "components/Accordion"; @@ -20,14 +21,18 @@ export const ObjectFieldTemplate = ({ }: ObjectFieldTemplateProps) => { const errors = uiSchema["ui:errors"] ?? []; const warnings = uiSchema["ui:warnings"] ?? []; + const tooltipTitle = uiSchema["ui:tooltipTitle"] ?? null; return (
{(uiSchema["ui:title"] || title) && ( - + + + {tooltipTitle && {tooltipTitle}} + )} {description && ( = ({ const border = uiSchema["ui:border"]; const showLabel = uiSchema["ui:showLabel"] ?? true; const fieldDataCy = uiSchema["ui:field-data-cy"]; + const descriptionNode = uiSchema["ui:descriptionNode"]; + const errors = uiSchema["ui:errors"] ?? []; + const warnings = uiSchema["ui:warnings"] ?? []; return ( !hidden && ( <> @@ -32,7 +36,17 @@ export const DefaultFieldTemplate: React.FC = ({ )} {/* eslint-disable-next-line react/jsx-no-useless-fragment */} - {isNullType && <>{description}} + {isNullType && <>{descriptionNode || description}} + {isNullType && !!errors.length && ( + + {errors.join(", ")} + + )} + {isNullType && !!warnings.length && ( + + {warnings.join(", ")} + + )} ` `border-${border}: 1px solid ${gray.light1}; padding-${border}: ${size.s};`} width: 100%; `; + +const StyledBanner = styled(Banner)` + margin: ${size.xs} 0; +`; diff --git a/apps/spruce/src/components/SpruceForm/Widgets/DateTimePicker.tsx b/apps/spruce/src/components/SpruceForm/Widgets/DateTimePicker.tsx index 07e36e697..e7482d5c7 100644 --- a/apps/spruce/src/components/SpruceForm/Widgets/DateTimePicker.tsx +++ b/apps/spruce/src/components/SpruceForm/Widgets/DateTimePicker.tsx @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; import { Description, Label } from "@leafygreen-ui/typography"; import { utcToZonedTime, zonedTimeToUtc } from "date-fns-tz"; import DatePicker from "components/DatePicker"; -import TimePicker from "components/TimePicker"; +import AntdTimePicker from "components/TimePicker"; import { size } from "constants/tokens"; import { useUserTimeZone } from "hooks/useUserTimeZone"; import ElementWrapper from "../ElementWrapper"; @@ -56,7 +56,7 @@ export const DateTimePicker: React.FC< disabled={isDisabled} disabledDate={disabledDate} /> - = ({ disabled, id, label, onChange, options, readonly, value = "" }) => { + const { + description, + elementWrapperCSS, + format, + showLabel, + useUtc = true, + } = options; + const timezone = useUserTimeZone(); + const currentDateTime = useUtc + ? utcToZonedTime(new Date(value || null), timezone) + : new Date(value || null); + const isDisabled = disabled || readonly; + const handleChange = (d: Date) => { + if (useUtc) { + onChange(zonedTimeToUtc(d, timezone).toString()); + } else { + onChange(d.toString()); + } + }; + + return ( + + {showLabel !== false && ( + + )} + {description && {description}} + + + ); +}; + // Fixes bug where DatePicker won't handle onClick events -const getPopupContainer = (triggerNode: HTMLElement) => triggerNode.parentNode; +const getPopupContainer = (triggerNode: HTMLElement) => + triggerNode.parentNode.parentNode; diff --git a/apps/spruce/src/components/SpruceForm/Widgets/DayPicker.tsx b/apps/spruce/src/components/SpruceForm/Widgets/DayPicker.tsx index 24f000d9c..aa48b0151 100644 --- a/apps/spruce/src/components/SpruceForm/Widgets/DayPicker.tsx +++ b/apps/spruce/src/components/SpruceForm/Widgets/DayPicker.tsx @@ -10,6 +10,7 @@ export const DayPickerWidget: React.FC = ({ onChange, options, readonly, + value, }) => { const { description, elementWrapperCSS, showLabel } = options; @@ -25,7 +26,11 @@ export const DayPickerWidget: React.FC = ({
)} {description && {description}} - + ); }; diff --git a/apps/spruce/src/components/SpruceForm/Widgets/index.tsx b/apps/spruce/src/components/SpruceForm/Widgets/index.tsx index 78d918ec8..0a5c36f06 100644 --- a/apps/spruce/src/components/SpruceForm/Widgets/index.tsx +++ b/apps/spruce/src/components/SpruceForm/Widgets/index.tsx @@ -1,4 +1,4 @@ -import { DateTimePicker } from "./DateTimePicker"; +import { DateTimePicker, TimePicker } from "./DateTimePicker"; import { DayPickerWidget } from "./DayPicker"; import { LeafyGreenTextInput, @@ -14,6 +14,7 @@ import { MultiSelect } from "./MultiSelect"; const widgets = { DateTimeWidget: DateTimePicker, DayPickerWidget, + TimeWidget: TimePicker, TextWidget: LeafyGreenTextInput, TextareaWidget: LeafyGreenTextArea, CheckboxWidget: LeafyGreenCheckBox, diff --git a/apps/spruce/src/components/SpruceForm/__snapshots__/SpruceForm.stories.storyshot b/apps/spruce/src/components/SpruceForm/__snapshots__/SpruceForm.stories.storyshot index 098575722..bdbb4a1c5 100644 --- a/apps/spruce/src/components/SpruceForm/__snapshots__/SpruceForm.stories.storyshot +++ b/apps/spruce/src/components/SpruceForm/__snapshots__/SpruceForm.stories.storyshot @@ -16,7 +16,7 @@ exports[`Snapshot Tests SpruceForm.stories Example1 1`] = ` novalidate="" >
[value, str]), +); + export const listOfDateFormatStrings = [ "MM-dd-yyyy", "dd-MM-yyyy", diff --git a/apps/spruce/src/gql/generated/types.ts b/apps/spruce/src/gql/generated/types.ts index 07eee2796..9bc693a4c 100644 --- a/apps/spruce/src/gql/generated/types.ts +++ b/apps/spruce/src/gql/generated/types.ts @@ -453,7 +453,8 @@ export type DistroInput = { providerSettingsList: Array; setup: Scalars["String"]["input"]; setupAsSudo: Scalars["Boolean"]["input"]; - sshKey: Scalars["String"]["input"]; + /** @deprecated removing this field shortly */ + sshKey?: InputMaybe; sshOptions: Array; user: Scalars["String"]["input"]; userSpawnAllowed: Scalars["Boolean"]["input"]; @@ -2462,7 +2463,8 @@ export type SpruceConfig = { containerPools?: Maybe; githubOrgs: Array; jira?: Maybe; - keys: Array; + /** @deprecated removing this field shortly */ + keys?: Maybe>; providers?: Maybe; secretFields: Array; slack?: Maybe; @@ -8261,7 +8263,11 @@ export type SpruceConfigQuery = { email?: string | null; host?: string | null; } | null; - keys: Array<{ __typename?: "SSHKey"; location: string; name: string }>; + keys?: Array<{ + __typename?: "SSHKey"; + location: string; + name: string; + }> | null; providers?: { __typename?: "CloudProviderConfig"; aws?: { diff --git a/apps/spruce/src/pages/spawn/spawnHost/spawnHostButton/SpawnHostModal.tsx b/apps/spruce/src/pages/spawn/spawnHost/spawnHostButton/SpawnHostModal.tsx index a8fd47fe2..6b87bcdef 100644 --- a/apps/spruce/src/pages/spawn/spawnHost/spawnHostButton/SpawnHostModal.tsx +++ b/apps/spruce/src/pages/spawn/spawnHost/spawnHostButton/SpawnHostModal.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation } from "@apollo/client"; import { useLocation } from "react-router-dom"; import { useSpawnAnalytics } from "analytics"; import { ConfirmationModal } from "components/ConfirmationModal"; +import { maxUptimeHours, validateUptimeSchedule } from "components/Spawn"; import { formToGql, getFormSchema, @@ -10,7 +11,7 @@ import { useVirtualWorkstationDefaultExpiration, FormState, } from "components/Spawn/spawnHostModal"; -import { SpruceForm } from "components/SpruceForm"; +import { SpruceForm, ValidateProps } from "components/SpruceForm"; import { useToastContext } from "context/toast"; import { SpawnHostMutation, @@ -20,6 +21,7 @@ import { } from "gql/generated/types"; import { SPAWN_HOST } from "gql/mutations"; import { SPAWN_TASK } from "gql/queries"; +import { useUserTimeZone } from "hooks"; import { omit } from "utils/object"; import { getString, parseQueryString } from "utils/queryString"; @@ -34,6 +36,7 @@ export const SpawnHostModal: React.FC = ({ }) => { const dispatchToast = useToastContext(); const spawnAnalytics = useSpawnAnalytics(); + const timeZone = useUserTimeZone(); // Handle distroId, taskId query param const { search } = useLocation(); @@ -72,8 +75,10 @@ export const SpawnHostModal: React.FC = ({ const selectedDistro = useMemo( () => - formSchemaInput?.distros?.find(({ name }) => name === formState.distro), - [formSchemaInput.distros, formState.distro], + formSchemaInput?.distros?.find( + ({ name }) => name === formState?.requiredSection?.distro, + ), + [formSchemaInput.distros, formState?.requiredSection?.distro], ); useVirtualWorkstationDefaultExpiration({ @@ -83,12 +88,28 @@ export const SpawnHostModal: React.FC = ({ disableExpirationCheckbox: formSchemaInput.disableExpirationCheckbox, }); + const hostUptimeValidation = useMemo( + () => + validateUptimeSchedule({ + enabledWeekdays: + formState?.expirationDetails?.hostUptime?.sleepSchedule + ?.enabledWeekdays, + ...formState?.expirationDetails?.hostUptime?.sleepSchedule + ?.timeSelection, + useDefaultUptimeSchedule: + formState?.expirationDetails?.hostUptime?.useDefaultUptimeSchedule, + }), + [formState?.expirationDetails?.hostUptime], + ); + const { schema, uiSchema } = getFormSchema({ ...formSchemaInput, distroIdQueryParam, + hostUptimeValidation, isMigration: false, isVirtualWorkstation: !!selectedDistro?.isVirtualWorkStation, spawnTaskData: spawnTaskData?.task, + timeZone, useSetupScript: !!formState?.setupScriptSection?.defineSetupScriptCheckbox, useProjectSetupScript: !!formState?.loadData?.runProjectSpecificSetupScript, }); @@ -103,6 +124,7 @@ export const SpawnHostModal: React.FC = ({ formData: formState, myPublicKeys: formSchemaInput.myPublicKeys, spawnTaskData: spawnTaskData?.task, + timeZone, }); spawnAnalytics.sendEvent({ name: "Spawned a host", @@ -138,7 +160,27 @@ export const SpawnHostModal: React.FC = ({ setFormState(formData); setHasError(errors.length > 0); }} + // @ts-expect-error rjsf v4 has insufficient typing for its validator + validate={validate(hostUptimeValidation?.enabledHoursCount ?? 0)} /> ); }; + +const validate = (enabledHoursCount: number) => + (({ expirationDetails }, errors) => { + const { hostUptime } = expirationDetails ?? {}; + if (!hostUptime) return errors; + + const { useDefaultUptimeSchedule } = hostUptime; + + if (!useDefaultUptimeSchedule) { + if (enabledHoursCount > maxUptimeHours) { + errors.expirationDetails?.hostUptime?.sleepSchedule?.addError( + "Insufficient hours", + ); + } + } + + return errors; + }) satisfies ValidateProps; diff --git a/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/MigrateVolumeModal.tsx b/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/MigrateVolumeModal.tsx index 6217c8b6a..ae5766840 100644 --- a/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/MigrateVolumeModal.tsx +++ b/apps/spruce/src/pages/spawn/spawnVolume/spawnVolumeTableActions/MigrateVolumeModal.tsx @@ -69,8 +69,8 @@ export const MigrateVolumeModal: React.FC = ({ ); const selectedDistro = useMemo( - () => distros?.find(({ name }) => name === form?.distro), - [distros, form.distro], + () => distros?.find(({ name }) => name === form?.requiredSection?.distro), + [distros, form?.requiredSection?.distro], ); const { schema, uiSchema } = getFormSchema({