diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index e869b52f69..f2d67bc298 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -95,7 +95,7 @@ "@babel/preset-typescript": "^7.15.0", "@ianvs/prettier-plugin-sort-imports": "^4.1.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@porter-dev/api-contracts": "^0.2.118", + "@porter-dev/api-contracts": "^0.2.131", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", @@ -2072,9 +2072,9 @@ } }, "node_modules/@bufbuild/protobuf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz", - "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.8.0.tgz", + "integrity": "sha512-qR9FwI8QKIveDnUYutvfzbC21UZJJryYrLuZGjeZ/VGz+vXelUkK+xgkOHsvPEdYEdxtgUUq4313N8QtOehJ1Q==", "dev": true }, "node_modules/@discoveryjs/json-ext": { @@ -2754,9 +2754,9 @@ } }, "node_modules/@porter-dev/api-contracts": { - "version": "0.2.118", - "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.118.tgz", - "integrity": "sha512-A5cPRfTNKfC7qQ6gHFLyLRWU1bTDj4mHIB2XL4l3CqUl3KsX6p7EgwjEI3YX5sVwoUcGnlatiZ+BqgrLhlf4cg==", + "version": "0.2.131", + "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.131.tgz", + "integrity": "sha512-Ui66wdOQmWik6c6uvXn1m6SkcO7LO67BAqbkg8kY5F4YMHvusDhXEbq1Pl9FoaRQUsRk2OaZgxWXyTGj3wPYHQ==", "dev": true, "dependencies": { "@bufbuild/protobuf": "^1.1.0" @@ -19584,9 +19584,9 @@ } }, "@bufbuild/protobuf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz", - "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.8.0.tgz", + "integrity": "sha512-qR9FwI8QKIveDnUYutvfzbC21UZJJryYrLuZGjeZ/VGz+vXelUkK+xgkOHsvPEdYEdxtgUUq4313N8QtOehJ1Q==", "dev": true }, "@discoveryjs/json-ext": { @@ -20056,9 +20056,9 @@ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, "@porter-dev/api-contracts": { - "version": "0.2.118", - "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.118.tgz", - "integrity": "sha512-A5cPRfTNKfC7qQ6gHFLyLRWU1bTDj4mHIB2XL4l3CqUl3KsX6p7EgwjEI3YX5sVwoUcGnlatiZ+BqgrLhlf4cg==", + "version": "0.2.131", + "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.131.tgz", + "integrity": "sha512-Ui66wdOQmWik6c6uvXn1m6SkcO7LO67BAqbkg8kY5F4YMHvusDhXEbq1Pl9FoaRQUsRk2OaZgxWXyTGj3wPYHQ==", "dev": true, "requires": { "@bufbuild/protobuf": "^1.1.0" diff --git a/dashboard/package.json b/dashboard/package.json index 1349371986..6001b21596 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -102,7 +102,7 @@ "@babel/preset-typescript": "^7.15.0", "@ianvs/prettier-plugin-sort-imports": "^4.1.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@porter-dev/api-contracts": "^0.2.118", + "@porter-dev/api-contracts": "^0.2.131", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", diff --git a/dashboard/src/assets/moon.svg b/dashboard/src/assets/moon.svg new file mode 100644 index 0000000000..6abf6e5eef --- /dev/null +++ b/dashboard/src/assets/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/src/lib/porter-apps/services.ts b/dashboard/src/lib/porter-apps/services.ts index 4f4d423bc2..dd6838a702 100644 --- a/dashboard/src/lib/porter-apps/services.ts +++ b/dashboard/src/lib/porter-apps/services.ts @@ -110,6 +110,7 @@ export const serviceValidator = z.object({ }), smartOptimization: serviceBooleanValidator.optional(), terminationGracePeriodSeconds: serviceNumberValidator.optional(), + sleep: serviceBooleanValidator.optional(), config: z.discriminatedUnion("type", [ webConfigValidator, workerConfigValidator, @@ -148,6 +149,7 @@ export type SerializedService = { gpuCoresNvidia: number; }; terminationGracePeriodSeconds?: number; + sleep?: boolean; config: | { type: "web"; @@ -314,6 +316,7 @@ export function serializeService(service: ClientService): SerializedService { gpuCoresNvidia: service.gpu.gpuCoresNvidia.value, }, terminationGracePeriodSeconds: service.terminationGracePeriodSeconds?.value, + sleep: service.sleep?.value, config: match(service.config) .with({ type: "web" }, (config) => Object.freeze({ @@ -386,6 +389,7 @@ export function deserializeService({ instances: ServiceField.number(service.instances, override?.instances), port: ServiceField.number(service.port, override?.port), cpuCores: ServiceField.number(service.cpuCores, override?.cpuCores), + sleep: ServiceField.boolean(service.sleep, override?.sleep), gpu: { enabled: ServiceField.boolean( service.gpu?.enabled, @@ -600,6 +604,7 @@ export function serviceProto(service: SerializedService): Service { runOptional: service.run, instancesOptional: service.instances, type: serviceTypeEnumProto(config.type), + sleep: service.sleep, config: { value: { ...config, @@ -616,6 +621,7 @@ export function serviceProto(service: SerializedService): Service { runOptional: service.run, instancesOptional: service.instances, type: serviceTypeEnumProto(config.type), + sleep: service.sleep, config: { value: { ...config, @@ -678,6 +684,7 @@ export function serializedServiceFromProto({ ...service, run: service.runOptional ?? service.run, instances: service.instancesOptional ?? service.instances, + sleep: service.sleep, config: { type: "web" as const, autoscaling: value.autoscaling ? value.autoscaling : undefined, @@ -690,6 +697,7 @@ export function serializedServiceFromProto({ ...service, run: service.runOptional ?? service.run, instances: service.instancesOptional ?? service.instances, + sleep: service.sleep, config: { type: "worker" as const, autoscaling: value.autoscaling ? value.autoscaling : undefined, diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx index b42c5d724f..6add174305 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx @@ -15,6 +15,7 @@ import { import chip from "assets/computer-chip.svg"; import job from "assets/job.png"; +import moon from "assets/moon.svg"; import web from "assets/web.png"; import worker from "assets/worker.png"; @@ -112,6 +113,15 @@ const ServiceContainer: React.FC = ({ )} + {service.sleep?.value && ( + <> + + + + Sleeping + + + )} {service.canDelete && ( @@ -260,7 +270,9 @@ const reflectiveGleam = keyframes` } `; -const TagContainer = styled.div` +const TagContainer = styled.div<{ + disableAnimation?: boolean; +}>` box-sizing: border-box; display: flex; flex-direction: row; @@ -277,7 +289,8 @@ const TagContainer = styled.div` ); background-size: 200% 200%; border-radius: 10px; - animation: ${reflectiveGleam} 4s infinite linear; + animation: ${reflectiveGleam} ${(props) => + props.disableAnimation ? "" : "4s infinite"} border: 1px solid rgba(255, 255, 255, 0.2); `; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx index f62fad9006..f8d29b4f6b 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx @@ -47,6 +47,20 @@ const Resources: React.FC = ({ } ); + const sleepEnabled = watch(`app.services.${index}.sleep`, { + readOnly: false, + value: false, + }); + + const disabledMessage = ( + defaultMessage: string, + isAsleep?: boolean + ): string => { + return isAsleep + ? "This service is asleep. Disable sleep mode to edit resources." + : defaultMessage; + }; + return ( <> @@ -75,10 +89,11 @@ const Resources: React.FC = ({ }); }} step={0.01} - disabled={value.readOnly} - disabledTooltip={ - "You may only edit this field in your porter.yaml." - } + disabled={value.readOnly || sleepEnabled?.value} + disabledTooltip={disabledMessage( + "You may only edit this field in your porter.yaml.", + sleepEnabled?.value + )} isSmartOptimizationOn={false} decimalsToRoundTo={2} /> @@ -107,16 +122,49 @@ const Resources: React.FC = ({ }); }} step={10} - disabled={value.readOnly} - disabledTooltip={ - "You may only edit this field in your porter.yaml." - } + disabled={value.readOnly || sleepEnabled?.value} + disabledTooltip={disabledMessage( + "You may only edit this field in your porter.yaml.", + sleepEnabled?.value + )} isSmartOptimizationOn={false} /> )} /> - {currentProject?.gpu_enabled && } + {service.config.type !== "job" && ( + <> + + + Sleep Service + +  (?) + + + + ( + { + onChange({ + ...value, + value: !value?.value, + }); + }} + > + Pause all instances. + + )} + /> + + )} {match(service.config) .with({ type: "job" }, () => null) .with({ type: "predeploy" }, () => null) @@ -128,13 +176,18 @@ const Resources: React.FC = ({ @@ -162,10 +215,11 @@ const Resources: React.FC = ({ value: !value.value, }); }} - disabled={value.readOnly} - disabledTooltip={ - "You may only edit this field in your porter.yaml." - } + disabled={value.readOnly || sleepEnabled?.value} + disabledTooltip={disabledMessage( + "You may only edit this field in your porter.yaml.", + sleepEnabled?.value + )} > Enable autoscaling (overrides instances) @@ -182,15 +236,17 @@ const Resources: React.FC = ({ label="Min instances" placeholder="ex: 1" disabled={ - config.autoscaling?.minInstances?.readOnly ?? - !config.autoscaling?.enabled.value + (config.autoscaling?.minInstances?.readOnly ?? + !config.autoscaling?.enabled.value) || + sleepEnabled?.value } width="300px" - disabledTooltip={ + disabledTooltip={disabledMessage( config.autoscaling?.minInstances?.readOnly ? "You may only edit this field in your porter.yaml." - : "Enable autoscaling to specify min instances." - } + : "Enable autoscaling to specify min instances.", + sleepEnabled?.value + )} {...register( `app.services.${index}.config.autoscaling.minInstances.value` )} @@ -201,15 +257,17 @@ const Resources: React.FC = ({ label="Max instances" placeholder="ex: 10" disabled={ - config.autoscaling?.maxInstances?.readOnly ?? - !config.autoscaling?.enabled.value + (config.autoscaling?.maxInstances?.readOnly ?? + !config.autoscaling?.enabled.value) || + sleepEnabled?.value } width="300px" - disabledTooltip={ + disabledTooltip={disabledMessage( config.autoscaling?.maxInstances?.readOnly ? "You may only edit this field in your porter.yaml." - : "Enable autoscaling to specify max instances." - } + : "Enable autoscaling to specify max instances.", + sleepEnabled?.value + )} {...register( `app.services.${index}.config.autoscaling.maxInstances.value` )} @@ -225,7 +283,11 @@ const Resources: React.FC = ({ min={0} max={100} value={value?.value.toString() ?? "50"} - disabled={value?.readOnly || !config.autoscaling?.enabled} + disabled={ + value?.readOnly || + !config.autoscaling?.enabled || + sleepEnabled?.value + } width="300px" setValue={(e) => { onChange({ @@ -233,11 +295,12 @@ const Resources: React.FC = ({ value: e, }); }} - disabledTooltip={ + disabledTooltip={disabledMessage( value?.readOnly ? "You may only edit this field in your porter.yaml." - : "Enable autoscaling to specify CPU threshold." - } + : "Enable autoscaling to specify CPU threshold.", + sleepEnabled?.value + )} /> )} /> @@ -252,7 +315,11 @@ const Resources: React.FC = ({ min={0} max={100} value={value?.value.toString() ?? "50"} - disabled={value?.readOnly || !config.autoscaling?.enabled} + disabled={ + value?.readOnly || + !config.autoscaling?.enabled || + sleepEnabled?.value + } width="300px" setValue={(e) => { onChange({ @@ -260,11 +327,12 @@ const Resources: React.FC = ({ value: e, }); }} - disabledTooltip={ + disabledTooltip={disabledMessage( value?.readOnly ? "You may only edit this field in your porter.yaml." - : "Enable autoscaling to specify RAM threshold." - } + : "Enable autoscaling to specify RAM threshold.", + sleepEnabled?.value + )} /> )} /> diff --git a/dashboard/src/shared/icons/MoonBase.tsx b/dashboard/src/shared/icons/MoonBase.tsx new file mode 100644 index 0000000000..17f70227ba --- /dev/null +++ b/dashboard/src/shared/icons/MoonBase.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import { type IconProps } from "./types"; + +const MoonBaseIcon: React.FC = ({ className, styles, fill }) => { + return ( + + + + ); +}; + +export default MoonBaseIcon; diff --git a/dashboard/src/shared/icons/PullRequest.tsx b/dashboard/src/shared/icons/PullRequest.tsx index e2a7002e4a..d8c9bd45e0 100644 --- a/dashboard/src/shared/icons/PullRequest.tsx +++ b/dashboard/src/shared/icons/PullRequest.tsx @@ -1,9 +1,6 @@ import React from "react"; -type IconProps = { - className?: string; - styles?: React.CSSProperties; - fill?: string; -}; + +import { type IconProps } from "./types"; const PullRequestIcon: React.FC = ({ className, styles, fill }) => { return ( diff --git a/dashboard/src/shared/icons/types.ts b/dashboard/src/shared/icons/types.ts new file mode 100644 index 0000000000..d54a10e551 --- /dev/null +++ b/dashboard/src/shared/icons/types.ts @@ -0,0 +1,5 @@ +export type IconProps = { + className?: string; + styles?: React.CSSProperties; + fill?: string; +}; diff --git a/go.mod b/go.mod index 08d8ec7191..9f5a7e5d6d 100644 --- a/go.mod +++ b/go.mod @@ -84,7 +84,7 @@ require ( github.com/matryer/is v1.4.0 github.com/nats-io/nats.go v1.24.0 github.com/open-policy-agent/opa v0.44.0 - github.com/porter-dev/api-contracts v0.2.127 + github.com/porter-dev/api-contracts v0.2.131 github.com/riandyrn/otelchi v0.5.1 github.com/santhosh-tekuri/jsonschema/v5 v5.0.1 github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d diff --git a/go.sum b/go.sum index ccc6585dd2..76343a7672 100644 --- a/go.sum +++ b/go.sum @@ -1525,8 +1525,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw= -github.com/porter-dev/api-contracts v0.2.127 h1:pF2vV9sohSzBIauBunII1n7f2puqMXdEGza4GdSkQBQ= -github.com/porter-dev/api-contracts v0.2.127/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8= +github.com/porter-dev/api-contracts v0.2.131 h1:WxungE4EL5F8oacVB52i3vKuxyf1UaebNlA4eJmcKLM= +github.com/porter-dev/api-contracts v0.2.131/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8= github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M= github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= diff --git a/internal/porter_app/v2/yaml.go b/internal/porter_app/v2/yaml.go index 89e28082e5..3b693824bd 100644 --- a/internal/porter_app/v2/yaml.go +++ b/internal/porter_app/v2/yaml.go @@ -189,6 +189,7 @@ type Service struct { Private *bool `yaml:"private,omitempty" validate:"excluded_unless=Type web"` IngressAnnotations map[string]string `yaml:"ingressAnnotations,omitempty" validate:"excluded_unless=Type web"` DisableTLS *bool `yaml:"disableTLS,omitempty" validate:"excluded_unless=Type web"` + Sleep *bool `yaml:"sleep,omitempty" validate:"excluded_unless=Type job"` } // AutoScaling represents the autoscaling settings for web services @@ -444,6 +445,9 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) ( if service.DisableTLS != nil { webConfig.DisableTls = service.DisableTLS } + if service.Sleep != nil { + serviceProto.Sleep = service.Sleep + } serviceProto.Config = &porterv1.Service_WebConfig{ WebConfig: webConfig, @@ -475,6 +479,10 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) ( } workerConfig.HealthCheck = healthCheck + if service.Sleep != nil { + serviceProto.Sleep = service.Sleep + } + serviceProto.Config = &porterv1.Service_WorkerConfig{ WorkerConfig: workerConfig, } @@ -582,6 +590,7 @@ func appServiceFromProto(service *porterv1.Service) (Service, error) { SmartOptimization: service.SmartOptimization, GPU: gpu, TerminationGracePeriodSeconds: service.TerminationGracePeriodSeconds, + Sleep: service.Sleep, } switch service.Type {