diff --git a/src/app/test/database-detail-scale.test.tsx b/src/app/test/database-detail-scale.test.tsx index f0e2ba573..41ead44d9 100644 --- a/src/app/test/database-detail-scale.test.tsx +++ b/src/app/test/database-detail-scale.test.tsx @@ -170,7 +170,7 @@ describe("DatabaseScalePage", () => { /Changed from General Purpose \(M\) to Compute Optimized \(C\)/, ), ).toBeInTheDocument(); - expect(screen.getByText(/Container Size/)).toBeInTheDocument(); + expect(screen.getByText("Container Size")).toBeInTheDocument(); expect( screen.getByText(/Changed from 0.5 GB to 2 GB/), ).toBeInTheDocument(); diff --git a/src/bootup/index.ts b/src/bootup/index.ts index 140299c14..521dc3725 100644 --- a/src/bootup/index.ts +++ b/src/bootup/index.ts @@ -12,8 +12,10 @@ import { fetchEndpoints, fetchEnvironments, fetchLogDrains, + fetchManualScaleRecommendations, fetchMetricDrains, fetchOperationsByOrgId, + fetchServiceSizingPolicies, fetchServices, fetchStacks, } from "@app/deploy"; @@ -88,7 +90,16 @@ function* onFetchResourceData() { fetchSources.run(), fetchDeployments.run(), fetchMembershipsByOrgId.run({ orgId: org.id }), + fetchServiceSizingPolicies.run(), ]); + const orgs = [ + "afc1bc92-13c8-4848-bdfa-355ec07a4f7f", + "df0ee681-9e02-4c28-8916-3b215d539b08", + ]; + // feature flag temporarily + if (orgs.includes(org.id)) { + yield* fetchManualScaleRecommendations.run(); + } yield* group; } diff --git a/src/date/index.ts b/src/date/index.ts index 41f426148..bc9e41de6 100644 --- a/src/date/index.ts +++ b/src/date/index.ts @@ -1,7 +1,7 @@ // https://moment.github.io/luxon/#/formatting?id=table-of-tokens import { DateTime } from "luxon"; -const isoToDate = (dateStr = "") => { +export const isoToDate = (dateStr = "") => { return DateTime.fromISO(dateStr, { zone: "utc" }); }; diff --git a/src/deploy/database/index.ts b/src/deploy/database/index.ts index 38cad7011..7a0eadfd2 100644 --- a/src/deploy/database/index.ts +++ b/src/deploy/database/index.ts @@ -136,6 +136,7 @@ export interface DeployDatabaseRow extends DeployDatabase { diskSize: number; containerSize: number; imageDesc: string; + savings: number; } export const hasDeployDatabase = (a: DeployDatabase) => a.id !== ""; diff --git a/src/deploy/entities.ts b/src/deploy/entities.ts index 938475081..f72599b40 100644 --- a/src/deploy/entities.ts +++ b/src/deploy/entities.ts @@ -14,6 +14,7 @@ import { endpointEntities } from "./endpoint"; import { environmentEntities } from "./environment"; import { imageEntities } from "./image"; import { logDrainEntities } from "./log-drain"; +import { manualScaleRecommendationEntities } from "./manual_scale_recommendation"; import { metricDrainEntities } from "./metric-drain"; import { opEntities } from "./operation"; import { permissionEntities } from "./permission"; @@ -53,4 +54,5 @@ export const entities = { ...imageEntities, ...diskEntities, ...serviceSizingPolicyEntities, + ...manualScaleRecommendationEntities, }; diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 76c52d7f0..4cb7bc555 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -32,3 +32,4 @@ export * from "./support"; export * from "./entities"; export * from "./search"; export * from "./cost"; +export * from "./manual_scale_recommendation"; diff --git a/src/deploy/manual_scale_recommendation/index.ts b/src/deploy/manual_scale_recommendation/index.ts new file mode 100644 index 000000000..f9e6e6afc --- /dev/null +++ b/src/deploy/manual_scale_recommendation/index.ts @@ -0,0 +1,91 @@ +import { api, cacheTimer } from "@app/api"; +import { defaultEntity, defaultHalHref } from "@app/hal"; +import { type WebState, schema } from "@app/schema"; +import type { LinkResponse, ManualScaleRecommendation } from "@app/types"; +import { createSelector } from "starfx"; + +export interface DeployManualScaleRecommendationResponse { + id: number; + service_id: number; + cpu_usage: number; + ram_usage: number; + ram_target: number; + recommended_instance_class: string; + recommended_container_memory_limit_mb: number; + cost_savings: number; + metric_percentile: number; + created_at: string; + updated_at: string; + _links: { + service: LinkResponse; + }; + _type: "manual_service_sizing_recommendation"; +} + +export const defaultManualScaleRecommendationResponse = ( + s: Partial = {}, +): DeployManualScaleRecommendationResponse => { + const now = new Date().toISOString(); + return { + id: 0, + service_id: 0, + cpu_usage: 0, + ram_usage: 0, + ram_target: 0, + recommended_instance_class: "", + recommended_container_memory_limit_mb: 0, + cost_savings: 0, + metric_percentile: 0, + created_at: now, + updated_at: now, + _links: { + service: defaultHalHref(), + }, + ...s, + _type: "manual_service_sizing_recommendation", + }; +}; + +export const deserializeManualScaleRecommendation = ( + payload: DeployManualScaleRecommendationResponse, +): ManualScaleRecommendation => { + return { + id: `${payload.id}`, + serviceId: `${payload.service_id}`, + cpuUsage: payload.cpu_usage, + ramUsage: payload.ram_usage, + ramTarget: payload.ram_target, + recommendedInstanceClass: payload.recommended_instance_class, + recommendedContainerMemoryLimitMb: + payload.recommended_container_memory_limit_mb, + costSavings: payload.cost_savings, + metricPercentile: payload.metric_percentile, + createdAt: payload.created_at, + }; +}; + +export const manualScaleRecommendationEntities = { + manual_service_sizing_recommendation: defaultEntity({ + id: "manual_service_sizing_recommendation", + deserialize: deserializeManualScaleRecommendation, + save: schema.manualScaleRecommendations.add, + }), +}; + +export const selectManualScaleRecommendationByServiceId = createSelector( + schema.manualScaleRecommendations.selectTableAsList, + (_: WebState, p: { serviceId: string }) => p.serviceId, + (recs, serviceId) => + recs.find((r) => { + return r.serviceId === serviceId; + }) || schema.manualScaleRecommendations.empty, +); + +export const fetchManualScaleRecommendations = api.get( + "/manual_service_sizing_recommendations?per_page=5000", + { supervisor: cacheTimer() }, +); + +export const fetchManualScaleRecommendationById = api.get<{ + id: string; +}>("/manual_service_sizing_recommendations/:id"); diff --git a/src/deploy/search/index.ts b/src/deploy/search/index.ts index 85967b7ae..1d38b9407 100644 --- a/src/deploy/search/index.ts +++ b/src/deploy/search/index.ts @@ -50,7 +50,8 @@ export const selectServicesForTable = createSelector( selectApps, selectServicesByOrgId, selectEndpointsAsList, - (envs, apps, services, endpoints) => + schema.manualScaleRecommendations.selectTableAsList, + (envs, apps, services, endpoints, recs) => services // making sure we have a valid environment associated with it .filter((service) => { @@ -68,11 +69,13 @@ export const selectServicesForTable = createSelector( } else { resourceHandle = "Unknown"; } + const rec = recs.find((r) => r.serviceId === service.id); return { ...service, envHandle: env.handle, resourceHandle, + savings: rec?.costSavings || 0, cost: estimateMonthlyCost({ services: [service], endpoints: findEndpointsByServiceId(endpoints, service.id), @@ -93,6 +96,13 @@ const createServiceSortFn = ( return b.cost - a.cost; } + if (sortBy === "savings") { + if (sortDir === "asc") { + return a.savings - b.savings; + } + return b.savings - a.savings; + } + if (sortBy === "resourceHandle") { if (sortDir === "asc") { return a.resourceHandle.localeCompare(b.resourceHandle); @@ -530,6 +540,13 @@ const createDatabaseSortFn = ( return b.cost - a.cost; } + if (sortBy === "savings") { + if (sortDir === "asc") { + return a.savings - b.savings; + } + return b.savings - a.savings; + } + if (sortBy === "handle") { if (sortDir === "asc") { return a.handle.localeCompare(b.handle); @@ -581,7 +598,8 @@ export const selectDatabasesForTable = createSelector( selectEndpointsAsList, selectBackupsAsList, selectDatabaseImages, - (dbs, envs, ops, disks, services, endpoints, backups, images) => + schema.manualScaleRecommendations.selectTableAsList, + (dbs, envs, ops, disks, services, endpoints, backups, images, recs) => dbs .map((dbb): DeployDatabaseRow => { const env = findEnvById(envs, { id: dbb.environmentId }); @@ -600,6 +618,7 @@ export const selectDatabasesForTable = createSelector( }); const metrics = calcMetrics([service]); const img = findDatabaseImageById(images, { id: dbb.databaseImageId }); + const rec = recs.find((s) => s.serviceId === dbb.serviceId); return { ...dbb, imageDesc: img.description, @@ -608,6 +627,7 @@ export const selectDatabasesForTable = createSelector( diskSize: disk.size, cost, containerSize: metrics.totalMemoryLimit / 1024, + savings: rec?.costSavings || 0, }; }) .sort((a, b) => a.handle.localeCompare(b.handle)), diff --git a/src/deploy/service-sizing-policy/index.ts b/src/deploy/service-sizing-policy/index.ts index 8dd4a9a4d..03a08031b 100644 --- a/src/deploy/service-sizing-policy/index.ts +++ b/src/deploy/service-sizing-policy/index.ts @@ -1,4 +1,4 @@ -import { api, cacheShortTimer, thunks } from "@app/api"; +import { api, cacheShortTimer, cacheTimer, thunks } from "@app/api"; import { createSelector } from "@app/fx"; import { defaultEntity, defaultHalHref, extractIdFromLink } from "@app/hal"; import { schema } from "@app/schema"; @@ -134,6 +134,13 @@ export const selectAutoscalingEnabledByServiceId = createSelector( (policy) => policy.scalingEnabled, ); +export const fetchServiceSizingPolicies = api.get( + "/service_sizing_policies?per_page=5000", + { + supervisor: cacheTimer(), + }, +); + export const fetchServiceSizingPoliciesByEnvironmentId = api.get<{ id: string; }>("/accounts/:id/service_sizing_policies", { supervisor: cacheShortTimer() }); diff --git a/src/schema/factory.ts b/src/schema/factory.ts index 4469cadd0..be00a1bd5 100644 --- a/src/schema/factory.ts +++ b/src/schema/factory.ts @@ -33,6 +33,7 @@ import { type GithubIntegration, type InstanceClass, type Invitation, + type ManualScaleRecommendation, type Membership, ModalType, type Organization, @@ -894,3 +895,22 @@ export const defaultGithubIntegration = ( ...s, }; }; + +export const defaultManualScaleRecommendation = ( + s: Partial = {}, +): ManualScaleRecommendation => { + const now = new Date().toISOString(); + return { + id: "", + serviceId: "", + cpuUsage: 0, + ramUsage: 0, + ramTarget: 0, + costSavings: 0, + recommendedInstanceClass: "", + recommendedContainerMemoryLimitMb: 0, + metricPercentile: 0, + createdAt: now, + ...s, + }; +}; diff --git a/src/schema/schema.ts b/src/schema/schema.ts index cbc776e1a..fe189b395 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -85,5 +85,8 @@ export const [schema, initialState] = createSchema({ githubIntegrations: slice.table({ empty: factory.defaultGithubIntegration(), }), + manualScaleRecommendations: slice.table({ + empty: factory.defaultManualScaleRecommendation(), + }), }); export type WebState = typeof initialState; diff --git a/src/types/state.ts b/src/types/state.ts index dba8fe235..1850a4a12 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -65,6 +65,7 @@ export interface DeployServiceRow extends DeployService { envHandle: string; resourceHandle: string; cost: number; + savings: number; url?: string; } @@ -100,3 +101,16 @@ export interface GithubIntegration { createdAt: string; updatedAt: string; } + +export interface ManualScaleRecommendation { + id: string; + serviceId: string; + cpuUsage: number; + ramUsage: number; + ramTarget: number; + recommendedInstanceClass: string; + recommendedContainerMemoryLimitMb: number; + costSavings: number; + createdAt: string; + metricPercentile: number; +} diff --git a/src/ui/layouts/database-detail-layout.tsx b/src/ui/layouts/database-detail-layout.tsx index 3fc3cd2c0..c543a9874 100644 --- a/src/ui/layouts/database-detail-layout.tsx +++ b/src/ui/layouts/database-detail-layout.tsx @@ -44,6 +44,7 @@ import { DetailInfoItem, DetailPageHeaderView, DetailTitleBar, + ScaleRecsView, type TabItem, } from "../shared"; import { ActiveOperationNotice } from "../shared/active-operation-notice"; @@ -115,6 +116,9 @@ export function DatabaseHeader({ )} + + + ); diff --git a/src/ui/layouts/service-detail-layout.tsx b/src/ui/layouts/service-detail-layout.tsx index 0cd61739b..695f5d9d8 100644 --- a/src/ui/layouts/service-detail-layout.tsx +++ b/src/ui/layouts/service-detail-layout.tsx @@ -40,6 +40,7 @@ import { IconChevronDown, IconChevronRight, PreCode, + ScaleRecsView, type TabItem, listToInvertedTextColor, } from "../shared"; @@ -135,6 +136,9 @@ export function ServiceHeader({ {deploymentStrategy} + + + {service.command ? (
diff --git a/src/ui/pages/app-detail-service-scale.tsx b/src/ui/pages/app-detail-service-scale.tsx index 8e473d8b9..87df5f9a9 100644 --- a/src/ui/pages/app-detail-service-scale.tsx +++ b/src/ui/pages/app-detail-service-scale.tsx @@ -14,6 +14,7 @@ import { selectContainerProfilesForStack, selectEndpointsByServiceId, selectEnvironmentById, + selectManualScaleRecommendationByServiceId, selectPreviousServiceScale, selectServiceById, selectServiceScale, @@ -45,6 +46,7 @@ import { IconChevronRight, IconRefresh, KeyValueGroup, + ManualScaleReason, ServicePricingCalc, tokens, } from "../shared"; @@ -914,6 +916,9 @@ export const AppDetailServiceScalePage = () => { const endpoints = useSelector((s) => selectEndpointsByServiceId(s, { serviceId }), ); + const rec = useSelector((s) => + selectManualScaleRecommendationByServiceId(s, { serviceId: serviceId }), + ); const action = scaleService({ id: serviceId, @@ -999,10 +1004,25 @@ export const AppDetailServiceScalePage = () => {
+

Manual Scale

+ + + + + { const backups = useSelector((s) => selectBackupsByDatabaseId(s, { dbId: id }), ); + const rec = useSelector((s) => + selectManualScaleRecommendationByServiceId(s, { + serviceId: database.serviceId, + }), + ); const { scaler, @@ -95,106 +104,131 @@ export const DatabaseScalePage = () => { }); return ( - - -
- - - - -
- - + + + +
+

Manual Scale

-
+ + + - {hasChanges ? ( -
- Pending Changes -
- ) : null} - {scaler.containerProfile !== service.instanceClass ? ( -
-
Container Profile
-

- Changed from {currentContainerProfile.name} to{" "} - {requestedContainerProfile.name} -

+ + + +
- ) : null} - {scaler.diskSize !== disk.size ? ( -
-
Disk Size
-

- Changed from {disk.size} GB to {scaler.diskSize} GB -

-
- ) : null} - {scaler.containerSize !== service.containerMemoryLimitMb ? ( -
-
Container Size
-

- Changed from {service.containerMemoryLimitMb / 1024} GB to{" "} - {scaler.containerSize / 1024} GB -

-
- ) : null} - {hasChanges ? ( + - ) : null} - +
-
- {hasChanges ? ( - - ) : null} -
- - + {hasChanges ? ( + + ) : null} +
+ +
+
); }; diff --git a/src/ui/pages/styles.tsx b/src/ui/pages/styles.tsx index 465df1735..c32a0ddca 100644 --- a/src/ui/pages/styles.tsx +++ b/src/ui/pages/styles.tsx @@ -27,8 +27,11 @@ import { CheckBox, FormGroup, Group, - IconGitBranch, + IconAutoscale, IconPlusCircle, + IconScaleCheck, + IconScaleDown, + IconScaleUp, Input, InputSearch, KeyValueGroup, @@ -147,7 +150,11 @@ const Tables = () => ( - Row 1 Value 1 + + } key="test"> + Autoscaling + + Row 1 Value 2 Row 1 Value 3 Row 1 Value 4 @@ -159,7 +166,11 @@ const Tables = () => ( Row 1 Value 10 - Row 2 Value 1 + + } key="test"> + Right Sized + + Row 2 Value 2 Row 2 Value 3 Row 2 Value 4 @@ -171,7 +182,16 @@ const Tables = () => ( Row 2 Value 10 - Row 3 Value 1 + + } + key="test" + > + Scale Up + + View Metrics + Row 3 Value 2 Row 3 Value 3 Row 3 Value 4 @@ -183,7 +203,16 @@ const Tables = () => ( Row 3 Value 10 - Row 4 Value 1 + + } + key="test" + > + Scale Down + + Save up to $10.42 + Row 4 Value 2 Row 4 Value 3 Row 4 Value 4 @@ -518,18 +547,31 @@ const Pills = () => (

Customizable pill with icon

- } key="test"> - Basic Icon With Pill + } key="test"> + Autoscaling
- Error Pill + } key="test"> + Right Sized +
- Pending Pill + } key="test"> + Scale Up +
- Progress Pill + } + key="test" + > + Scale Down + +
+
+ Pending Pill
Success Pill diff --git a/src/ui/shared/app/services-detail.tsx b/src/ui/shared/app/services-detail.tsx index c838477d2..3bbe51ec5 100644 --- a/src/ui/shared/app/services-detail.tsx +++ b/src/ui/shared/app/services-detail.tsx @@ -28,9 +28,9 @@ import { CopyTextButton } from "../copy"; import { CostEstimateTooltip } from "../cost-estimate-tooltip"; import { Group } from "../group"; import { IconChevronDown, IconInfo } from "../icons"; -import { Pill } from "../pill"; import { DescBar, FilterBar, PaginateBar } from "../resource-list-view"; import { EnvStackCell } from "../resource-table"; +import { ScaleRecsView } from "../scale-recs"; import { EmptyTr, TBody, THead, Table, Td, Th, Tr } from "../table"; import { tokens } from "../tokens"; import { Tooltip } from "../tooltip"; @@ -149,16 +149,10 @@ const CostCell = ({ ); }; -const AutoscaleCell = ({ service }: { service: DeployServiceRow }) => { - const enabled = useSelector((s) => - selectAutoscalingEnabledById(s, { id: service.serviceSizingPolicyId }), - ); - const variant = enabled ? "success" : "default"; - const text = enabled ? "Enabled" : "Disabled"; - +const ScaleRecsCell = ({ service }: { service: DeployServiceRow }) => { return ( - {text} + ); }; @@ -188,7 +182,7 @@ const AppServiceByAppRow = ({ costLoading={costLoading} evaluateAutoscaling={autoscalingEnabled} /> - {autoscalingEnabled ? : null} + @@ -245,6 +239,7 @@ const AppServiceByOrgRow = ({ +
@@ -313,6 +308,18 @@ export function AppServicesByOrg({ />
+ onSort("savings")} + > + Scale Recs{" "} +
+ +
+ Actions @@ -357,9 +364,6 @@ export function AppServicesByApp({ costQueries.forEach((q) => useQuery(q)); const { isLoading: isCostLoading } = useCompositeLoader(costQueries); - const autoscalingEnabled = - stack.verticalAutoscaling || stack.horizontalAutoscaling; - return ( @@ -377,14 +381,12 @@ export function AppServicesByApp({ Command Details Est. Monthly Cost - {autoscalingEnabled ? Autoscaling : null} + Scale Recs Actions - {paginated.data.length === 0 ? ( - - ) : null} + {paginated.data.length === 0 ? : null} {paginated.data.map((service) => ( { return {database.id}; }; +const DatabaseScaleRecsCell = ({ database }: { database: DeployDatabase }) => { + const service = useSelector((s) => + selectServiceById(s, { id: database.serviceId }), + ); + return ( + + + + ); +}; + const DatabaseCostCell = ({ database, costLoading, @@ -300,11 +312,18 @@ export const DatabaseListByOrg = () => {
Est. Monthly Cost
+ onSort("savings")} + > +
Scaling Recs
+ + Actions - {paginated.data.length === 0 ? : null} + {paginated.data.length === 0 ? : null} {paginated.data.map((db) => ( @@ -313,6 +332,7 @@ export const DatabaseListByOrg = () => { + ))} @@ -386,11 +406,12 @@ export const DatabaseListByEnvironment = ({
Est. Monthly Cost
+ Scaling Recs Actions - {paginated.data.length === 0 ? : null} + {paginated.data.length === 0 ? : null} {paginated.data.map((db) => ( @@ -398,6 +419,7 @@ export const DatabaseListByEnvironment = ({ + ))} diff --git a/src/ui/shared/icons.tsx b/src/ui/shared/icons.tsx index 4bfebeab1..81e9739ac 100644 --- a/src/ui/shared/icons.tsx +++ b/src/ui/shared/icons.tsx @@ -657,3 +657,83 @@ export const IconExternalResource = (props: IconProps) => { ); }; + +export const IconScaleDown = (props: IconProps) => { + return ( + + + + ); +}; + +export const IconScaleUp = (props: IconProps) => { + return ( + + + + ); +}; + +export const IconAutoscale = (props: IconProps) => { + return ( + + + + ); +}; + +export const IconScaleCheck = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/src/ui/shared/index.ts b/src/ui/shared/index.ts index 398a47033..ca7835a48 100644 --- a/src/ui/shared/index.ts +++ b/src/ui/shared/index.ts @@ -74,3 +74,4 @@ export * from "./service-pricing-calc"; export * from "./cost-estimate-tooltip"; export * from "./us-states"; export * from "./countries"; +export * from "./scale-recs"; diff --git a/src/ui/shared/pill.tsx b/src/ui/shared/pill.tsx index 0da533a3e..85ac22207 100644 --- a/src/ui/shared/pill.tsx +++ b/src/ui/shared/pill.tsx @@ -49,7 +49,7 @@ export const Pill = ({ const defaultClassName = cn( "rounded-full border-2", "text-sm font-semibold text-black-500", - "px-2 flex gap-2 justify-between items-center w-fit", + "px-2 flex gap-1 justify-between items-center w-fit", variantToClassName(variant), ); return ( diff --git a/src/ui/shared/scale-recs.tsx b/src/ui/shared/scale-recs.tsx new file mode 100644 index 000000000..b2be29801 --- /dev/null +++ b/src/ui/shared/scale-recs.tsx @@ -0,0 +1,171 @@ +import { dateFromToday } from "@app/date"; +import { + GB, + getContainerProfileFromType, + selectAutoscalingEnabledById, + selectManualScaleRecommendationByServiceId, + selectServiceById, +} from "@app/deploy"; +import { useSelector } from "@app/react"; +import { + appServicePathMetricsUrl, + appServiceScalePathUrl, + databaseMetricsUrl, + databaseScaleUrl, +} from "@app/routes"; +import type { + DeployService, + InstanceClass, + ManualScaleRecommendation, +} from "@app/types"; +import { Link } from "react-router-dom"; +import { Banner } from "./banner"; +import { Group } from "./group"; +import { + IconAutoscale, + IconScaleCheck, + IconScaleDown, + IconScaleUp, +} from "./icons"; +import { Pill } from "./pill"; + +const isManualScaleRecValid = ( + service: DeployService, + rec: ManualScaleRecommendation, +) => { + if (rec.id === "") { + return { isValid: false, isProfileSame: true, isSizeSame: true }; + } + const isProfileSame = service.instanceClass.startsWith( + rec.recommendedInstanceClass, + ); + + const isSizeSame = + service.containerMemoryLimitMb === rec.recommendedContainerMemoryLimitMb; + // we want to expire recommendations that are greater than X days old + const withinTimelimit = new Date(rec.createdAt) > dateFromToday(2); + const isValid = !isProfileSame || !isSizeSame || !withinTimelimit; + return { isValid, isProfileSame, isSizeSame }; +}; + +export const ManualScaleReason = ({ + serviceId, + children, +}: { serviceId: string; children: React.ReactNode }) => { + const service = useSelector((s) => selectServiceById(s, { id: serviceId })); + const rec = useSelector((s) => + selectManualScaleRecommendationByServiceId(s, { serviceId: serviceId }), + ); + const { isValid, isProfileSame, isSizeSame } = isManualScaleRecValid( + service, + rec, + ); + + if (!isValid) { + return null; + } + + const recProfile = getContainerProfileFromType( + `${rec.recommendedInstanceClass}5` as InstanceClass, + ); + + let msg = "Based on container metrics in the last 14 days, we recommend"; + if (!isProfileSame) { + msg += ` modifying your container profile to ${recProfile.name} class`; + } + if (!isProfileSame && !isSizeSame) { + msg += " and "; + } + if (!isSizeSame) { + msg += ` modifying your container size to ${rec.recommendedContainerMemoryLimitMb / GB} GB`; + } + + return ( + + +
+ +
+
+ {msg}. + + We recommend conducting your own container and usage review before + scaling.{" "} + + + See metrics + +
+
{children}
+
+
+ ); +}; + +export const ManualScaleRecView = ({ serviceId }: { serviceId: string }) => { + const service = useSelector((s) => selectServiceById(s, { id: serviceId })); + const rec = useSelector((s) => + selectManualScaleRecommendationByServiceId(s, { serviceId: serviceId }), + ); + const { isValid } = isManualScaleRecValid(service, rec); + const savings = rec.costSavings; + + if (rec.id === "") { + return null; + } + + if (savings === 0 || !isValid) { + return ( + }> + Right Sized + + ); + } + const url = service.appId + ? appServiceScalePathUrl(service.appId, serviceId) + : databaseScaleUrl(service.databaseId); + + const scaleDir = savings < 0 ? "up" : "down"; + if (scaleDir === "up") { + return ( + + }> + Scale Up + + + ); + } + + return ( + <> + + }> + Scale Down + + +
Save up to ${savings.toFixed(2)}
+ + ); +}; + +export const ScaleRecsView = ({ service }: { service: DeployService }) => { + const enabled = useSelector((s) => + selectAutoscalingEnabledById(s, { id: service.serviceSizingPolicyId }), + ); + return ( +
+ {enabled ? ( + }> + Autoscaling + + ) : null} + {enabled ? null : } +
+ ); +};