From 3fe26adcbf186cc4d5a95464161f14845ada4ad3 Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Thu, 25 May 2023 15:58:45 -0700 Subject: [PATCH 01/12] region dropdown --- package-lock.json | 4 +- src/aws-utilization-provider.ts | 22 +- .../aws-account-utilization.tsx | 5 +- .../aws-cloudwatch-logs-utilization.ts | 36 ++-- .../aws-ec2-instance-utilization.ts | 31 ++- .../aws-ecs-utilization.ts | 40 ++-- .../aws-nat-gateway-utilization.ts | 38 ++-- .../aws-s3-utilization.tsx | 35 ++-- .../aws-service-utilization.ts | 31 ++- .../ebs-volumes-utilization.tsx | 196 +++++++++++++----- src/service-utilizations/rds-utilization.tsx | 65 ++++++ src/types/constants.ts | 3 + src/types/types.ts | 4 +- .../utilization-recommendations-types.ts | 17 +- src/utils/utils.ts | 4 +- .../aws-utilization-recommendations.tsx | 36 +++- src/widgets/aws-utilization.tsx | 2 +- .../recommendations-action-summary.tsx | 34 ++- .../utilization-recommendations-ui.tsx | 8 +- 19 files changed, 461 insertions(+), 150 deletions(-) diff --git a/package-lock.json b/package-lock.json index 005580a..bded446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tinystacks/ops-aws-utilization-widgets", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tinystacks/ops-aws-utilization-widgets", - "version": "0.0.2", + "version": "0.0.3", "license": "BSD-3-Clause", "dependencies": { "@aws-sdk/client-account": "^3.315.0", diff --git a/src/aws-utilization-provider.ts b/src/aws-utilization-provider.ts index b8ee0e2..21f0e4e 100644 --- a/src/aws-utilization-provider.ts +++ b/src/aws-utilization-provider.ts @@ -17,7 +17,7 @@ type AwsUtilizationProviderType = Provider & { utilization?: { [key: AwsResourceType | string]: Utilization }; - regions?: string[]; + region?: string; }; class AwsUtilizationProvider extends BaseProvider { @@ -29,13 +29,13 @@ class AwsUtilizationProvider extends BaseProvider { utilization: { [key: AwsResourceType | string]: Utilization }; - regions: string[]; + region: string; constructor (props: AwsUtilizationProviderType) { super(props); const { services, - regions + region } = props; this.utilizationClasses = {}; @@ -50,7 +50,7 @@ class AwsUtilizationProvider extends BaseProvider { 'EbsVolume', 'RdsInstance' ]); - this.regions = regions || [ 'us-east-1' ]; + this.region = region || 'us-east-1'; } static fromJson (props: AwsUtilizationProviderType) { @@ -75,9 +75,10 @@ class AwsUtilizationProvider extends BaseProvider { async refreshUtilizationData ( service: AwsResourceType, credentialsProvider: AwsCredentialsProvider, + region: string, overrides?: AwsServiceOverrides ): Promise> { - await this.utilizationClasses[service]?.getUtilization(credentialsProvider, this.regions, overrides); + await this.utilizationClasses[service]?.getUtilization(credentialsProvider, [ region ], overrides); return this.utilizationClasses[service]?.utilization; } @@ -92,12 +93,12 @@ class AwsUtilizationProvider extends BaseProvider { } async hardRefresh ( - credentialsProvider: AwsCredentialsProvider, overrides: AwsUtilizationOverrides = {} + credentialsProvider: AwsCredentialsProvider, region: string, overrides: AwsUtilizationOverrides = {} ) { for (const service of this.services) { const serviceOverrides = overrides[service]; this.utilization[service] = await this.refreshUtilizationData( - service, credentialsProvider, serviceOverrides + service, credentialsProvider, region, serviceOverrides ); await cache.set(service, this.utilization[service]); } @@ -106,19 +107,20 @@ class AwsUtilizationProvider extends BaseProvider { } async getUtilization ( - credentialsProvider: AwsCredentialsProvider, overrides: AwsUtilizationOverrides = {} + credentialsProvider: AwsCredentialsProvider, region: string, overrides: AwsUtilizationOverrides = {} ) { + console.log(this.utilization); for (const service of this.services) { const serviceOverrides = overrides[service]; if (serviceOverrides?.forceRefesh) { this.utilization[service] = await this.refreshUtilizationData( - service, credentialsProvider, serviceOverrides + service, credentialsProvider, region, serviceOverrides ); await cache.set(service, this.utilization[service]); } else { this.utilization[service] = await cache.getOrElse( service, - async () => await this.refreshUtilizationData(service, credentialsProvider, serviceOverrides) + async () => await this.refreshUtilizationData(service, credentialsProvider, region, serviceOverrides) ); } } diff --git a/src/service-utilizations/aws-account-utilization.tsx b/src/service-utilizations/aws-account-utilization.tsx index c242790..2f9aa7e 100644 --- a/src/service-utilizations/aws-account-utilization.tsx +++ b/src/service-utilizations/aws-account-utilization.tsx @@ -2,6 +2,7 @@ import { AwsCredentialsProvider } from '@tinystacks/ops-aws-core-widgets'; import { AwsServiceUtilization } from './aws-service-utilization.js'; import { CostExplorer } from '@aws-sdk/client-cost-explorer'; import { Pricing } from '@aws-sdk/client-pricing'; +import { AwsServiceOverrides } from '../types/types.js'; /** * The most relevant apis are the AWS Price List and AWS Cost Explorer APIs @@ -25,7 +26,9 @@ export class awsAccountUtilization extends AwsServiceUtilization { + async getUtilization ( + awsCredentialsProvider: AwsCredentialsProvider, regions: string[], _overrides: AwsServiceOverrides + ): Promise { const region = regions[0]; await this.checkPermissionsForCostExplorer(awsCredentialsProvider, region); diff --git a/src/service-utilizations/aws-cloudwatch-logs-utilization.ts b/src/service-utilizations/aws-cloudwatch-logs-utilization.ts index 9dd9c32..e8ca736 100644 --- a/src/service-utilizations/aws-cloudwatch-logs-utilization.ts +++ b/src/service-utilizations/aws-cloudwatch-logs-utilization.ts @@ -4,7 +4,7 @@ import { AwsCredentialsProvider } from '@tinystacks/ops-aws-core-widgets'; import _ from 'lodash'; import { ONE_GB_IN_BYTES } from '../types/constants.js'; import { AwsServiceOverrides } from '../types/types.js'; -import { getHourlyCost, listAllRegions, rateLimitMap } from '../utils/utils.js'; +import { getHourlyCost, rateLimitMap } from '../utils/utils.js'; import { AwsServiceUtilization } from './aws-service-utilization.js'; const ONE_HUNDRED_MB_IN_BYTES = 104857600; @@ -226,12 +226,26 @@ export class AwsCloudwatchLogsUtilization extends AwsServiceUtilization { + const resourceId = (resourceArn.split(':').at(-1)).split('/').at(-1); if (actionName === 'terminateInstance') { - const resourceId = (resourceArn.split(':').at(-1)).split('/').at(-1); await this.terminateInstance(awsCredentialsProvider, resourceId, region); } } @@ -380,12 +380,23 @@ export class AwsEc2InstanceUtilization extends AwsServiceUtilization('ecs-util-cache', { backend: { @@ -906,19 +906,32 @@ export class AwsEcsUtilization extends AwsServiceUtilization { + const resourceId = (resourceArn.split(':').at(-1)).split('/').at(-1); if (actionName === 'deleteNatGateway') { const ec2Client = new EC2({ credentials: await awsCredentialsProvider.getCredentials(), region }); - const resourceId = resourceArn.split(':').at(-1); await this.deleteNatGateway(ec2Client, resourceId); } } @@ -186,25 +187,38 @@ export class AwsNatGatewayUtilization extends AwsServiceUtilization const bucketArn = Arns.S3(bucketName); await this.getLifecyclePolicy(bucketArn, bucketName, region); await this.getIntelligentTieringConfiguration(bucketArn, bucketName, region); + + const monthlyCost = this.bucketCostData[bucketName]?.monthlyCost || 0; + await this.fillData( + bucketArn, + credentials, + region, + { + resourceId: bucketName, + region, + monthlyCost, + hourlyCost: getHourlyCost(monthlyCost) + } + ); // TODO: Change bucketName to bucketArn - this.addData(bucketArn, 'resourceId', bucketName); - this.addData(bucketArn, 'region', region); - if (bucketName in this.bucketCostData) { - const monthlyCost = this.bucketCostData[bucketName].monthlyCost; - this.addData(bucketArn, 'monthlyCost', monthlyCost); - this.addData(bucketArn, 'hourlyCost', getHourlyCost(monthlyCost)); - } + // this.addData(bucketArn, 'resourceId', bucketName); + // this.addData(bucketArn, 'region', region); + // if (bucketName in this.bucketCostData) { + // const monthlyCost = this.bucketCostData[bucketName].monthlyCost; + // this.addData(bucketArn, 'monthlyCost', monthlyCost); + // this.addData(bucketArn, 'hourlyCost', getHourlyCost(monthlyCost)); + // } }; await rateLimitMap(allS3Buckets, 5, 5, analyzeS3Bucket); } async getUtilization ( - awsCredentialsProvider: AwsCredentialsProvider, regions: string[], _overrides?: AwsServiceOverrides + awsCredentialsProvider: AwsCredentialsProvider, regions: string[], _overrides?: AwsServiceOverrides ): Promise { const credentials = await awsCredentialsProvider.getCredentials(); - const usedRegions = regions || await listAllRegions(credentials); - for (const region of usedRegions) { + for (const region of regions) { await this.getRegionalUtilization(credentials, region); } - this.getEstimatedMaxMonthlySavings(); } async getIntelligentTieringConfiguration (bucketArn: string, bucketName: string, region: string) { diff --git a/src/service-utilizations/aws-service-utilization.ts b/src/service-utilizations/aws-service-utilization.ts index ddfa3db..cae6793 100644 --- a/src/service-utilizations/aws-service-utilization.ts +++ b/src/service-utilizations/aws-service-utilization.ts @@ -9,6 +9,9 @@ export abstract class AwsServiceUtilization { this._utilization = {}; } + /* TODO: all services have a sub getRegionalUtilization function that needs to be deprecated + * since calls are now region specific + */ abstract getUtilization ( awsCredentialsProvider: AwsCredentialsProvider, regions?: string[], overrides?: any ): void | Promise; @@ -27,6 +30,25 @@ export abstract class AwsServiceUtilization { this.utilization[resourceArn].scenarios[scenarioType] = scenario; } + protected async fillData ( + resourceArn: string, + credentials: any, + region: string, + data: { [ key: keyof Data ]: Data[keyof Data] } + ) { + for (const key in data) { + this.addData(resourceArn, key, data[key]); + } + await this.identifyCloudformationStack( + credentials, + region, + resourceArn, + data.resourceId, + data.associatedResourceId + ); + this.getEstimatedMaxMonthlySavings(resourceArn); + } + protected addData (resourceArn: string, dataType: keyof Data, value: any) { // only add data if recommendation exists for resource if (resourceArn in this.utilization) { @@ -51,8 +73,9 @@ export abstract class AwsServiceUtilization { } } - protected getEstimatedMaxMonthlySavings () { - for (const resourceArn in this.utilization) { + protected getEstimatedMaxMonthlySavings (resourceArn: string) { + // for (const resourceArn in this.utilization) { + if (resourceArn in this.utilization) { const scenarios = (this.utilization as Utilization)[resourceArn].scenarios; const maxSavingsPerScenario = Object.values(scenarios).map((scenario) => { return Math.max( @@ -61,8 +84,8 @@ export abstract class AwsServiceUtilization { scenario.optimize?.monthlySavings || 0 ); }); - const maxSavingsPerResource = Math.max(...maxSavingsPerScenario); - this.utilization[resourceArn].data.maxMonthlySavings = maxSavingsPerResource; + const maxSavingsForResource = Math.max(...maxSavingsPerScenario); + this.addData(resourceArn, 'maxMonthlySavings', maxSavingsForResource); } } diff --git a/src/service-utilizations/ebs-volumes-utilization.tsx b/src/service-utilizations/ebs-volumes-utilization.tsx index fabeaff..451722e 100644 --- a/src/service-utilizations/ebs-volumes-utilization.tsx +++ b/src/service-utilizations/ebs-volumes-utilization.tsx @@ -1,26 +1,32 @@ import { AwsCredentialsProvider } from '@tinystacks/ops-aws-core-widgets'; import { AwsServiceUtilization } from './aws-service-utilization.js'; -import { EC2 } from '@aws-sdk/client-ec2'; +import { DescribeVolumesCommandOutput, EC2 } from '@aws-sdk/client-ec2'; import { AwsServiceOverrides } from '../types/types.js'; import { Volume } from '@aws-sdk/client-ec2'; import { CloudWatch } from '@aws-sdk/client-cloudwatch'; +import { Arns } from '../types/constants.js'; +import { getHourlyCost, rateLimitMap } from '../utils/utils.js'; export type ebsVolumesUtilizationScenarios = 'hasAttachedInstances' | 'volumeReadWriteOps'; export class ebsVolumesUtilization extends AwsServiceUtilization { + accountId: string; + volumeCosts: { [ volumeId: string ]: number }; + constructor () { super(); + this.volumeCosts = {}; } async doAction ( awsCredentialsProvider: AwsCredentialsProvider, actionName: string, resourceArn: string, region: string ): Promise { + const resourceId = (resourceArn.split(':').at(-1)).split('/').at(-1); if (actionName === 'deleteEBSVolume') { const ec2Client = new EC2({ credentials: await awsCredentialsProvider.getCredentials(), region: region }); - const resourceId = resourceArn.split(':').at(-1); await this.deleteEBSVolume(ec2Client, resourceId); } } @@ -32,65 +38,153 @@ export class ebsVolumesUtilization extends AwsServiceUtilization { - const region = regions[0]; + async getAllVolumes (credentials: any, region: string) { const ec2Client = new EC2({ - credentials: await awsCredentialsProvider.getCredentials(), - region: region + credentials, + region }); - - if(_overrides){ - await this.deleteEBSVolume(ec2Client, _overrides.resourceArn); - } - let volumes: Volume[] = []; + let describeVolumesRes: DescribeVolumesCommandOutput; + do { + describeVolumesRes = await ec2Client.describeVolumes({ + NextToken: describeVolumesRes?.NextToken + }); + volumes = [ ...volumes, ...describeVolumesRes?.Volumes || [] ]; + } while (describeVolumesRes?.NextToken); - let res = await ec2Client.describeVolumes({}); - volumes = [...res.Volumes]; + return volumes; + } - while(res.NextToken){ - res = await ec2Client.describeVolumes({ - NextToken: res.NextToken - }); - volumes = [...volumes, ...res.Volumes]; + getVolumeCost (volume: Volume) { + if (volume.VolumeId in this.volumeCosts) { + return this.volumeCosts[volume.VolumeId]; } - - - this.findUnusedVolumes(volumes); - const promises: Promise[] = []; - - const cloudWatchClient = new CloudWatch({ - credentials: await awsCredentialsProvider.getCredentials(), - region: region - }); - for (let i = 0; i < volumes.length; ++i) { - promises.push(this.getReadWriteVolume(cloudWatchClient, volumes[i].VolumeId)); + let cost = 0; + const storage = volume.Size || 0; + switch (volume.VolumeType) { + case 'gp3': { + const iops = volume.Iops || 0; + const throughput = volume.Throughput || 0; + cost = (0.08 * storage) + (0.005 * iops) + (0.040 * throughput); + break; + } + case 'gp2': { + cost = 0.10 * storage; + break; + } + case 'io2': { + let iops = volume.Iops || 0; + let iopsCost = 0; + if (iops > 64000) { + const iopsCharged = iops - 640000; + iopsCost += (0.032 * iopsCharged); + iops -= iopsCharged; + } + if (iops > 32000) { + const iopsCharged = iops - 320000; + iopsCost += (0.046 * iopsCharged); + iops -= iopsCharged; + } + iopsCost += (0.065 * iops); + cost = (0.125 * volume.Size) + iopsCost; + break; + } + case 'io1': { + cost = (0.125 * storage) + (0.065 * volume.Iops); + break; + } + case 'st1': { + cost = 0.045 * storage; + break; + } + case 'sc1': { + cost = 0.015 * storage; + break; + } + default: { + const iops = volume.Iops || 0; + const throughput = volume.Throughput || 0; + cost = (0.08 * storage) + (0.005 * iops) + (0.040 * throughput); + break; + } } - void await Promise.all(promises).catch(e => console.log(e)); + this.volumeCosts[volume.VolumeId] = cost; + return cost; + } - + async getRegionalUtilization (credentials: any, region: string) { + const volumes = await this.getAllVolumes(credentials, region); + + const analyzeEbsVolume = async (volume: Volume) => { + const volumeId = volume.VolumeId; + const volumeArn = Arns.Ebs(region, this.accountId, volumeId); + + const cloudWatchClient = new CloudWatch({ + credentials, + region + }); + + this.checkForUnusedVolume(volume, volumeArn); + await this.getReadWriteVolume(cloudWatchClient, volume, volumeArn); + + const monthlyCost = this.volumeCosts[volumeArn] || 0; + await this.fillData( + volumeArn, + credentials, + region, + { + resourceId: volumeId, + region, + monthlyCost, + hourlyCost: getHourlyCost(monthlyCost) + } + ); + + // this.addData(volumeArn, 'resourceId', volumeId); + // this.addData(volumeArn, 'region', region); + // if (volumeArn in this.volumeCosts) { + // const monthlyCost = this.volumeCosts[volumeArn]; + // this.addData(volumeArn, 'monthlyCost', monthlyCost); + // this.addData(volumeArn, 'hourlyCost', getHourlyCost(monthlyCost)); + // } + // await this.identifyCloudformationStack( + // credentials, + // region, + // volumeArn, + // volumeId + // ); + }; + await rateLimitMap(volumes, 5, 5, analyzeEbsVolume); } - findUnusedVolumes (volumes: Volume[]){ - volumes.forEach((volume) => { - if(!volume.Attachments || volume.Attachments.length === 0){ - this.addScenario(volume.VolumeId, 'hasAttachedInstances', { - value: 'false', - delete: { - action: 'deleteEBSVolume', - isActionable: true, - reason: 'This EBS volume does not have any attahcments' - } - }); - } - }); + async getUtilization ( + awsCredentialsProvider: AwsCredentialsProvider, regions: string[], _overrides?: AwsServiceOverrides + ): Promise { + const credentials = await awsCredentialsProvider.getCredentials(); + for (const region of regions) { + await this.getRegionalUtilization(credentials, region); + } } - async getReadWriteVolume (cloudWatchClient: CloudWatch, volumeId: string){ + checkForUnusedVolume (volume: Volume, volumeArn: string) { + if(!volume.Attachments || volume.Attachments.length === 0){ + const cost = this.getVolumeCost(volume); + this.addScenario(volumeArn, 'hasAttachedInstances', { + value: 'false', + delete: { + action: 'deleteEBSVolume', + isActionable: true, + reason: 'This EBS volume does not have any attahcments', + monthlySavings: cost + } + }); + } + } + + async getReadWriteVolume (cloudWatchClient: CloudWatch, volume: Volume, volumeArn: string){ + const volumeId = volume.VolumeId; const writeOpsMetricRes = await cloudWatchClient.getMetricStatistics({ Namespace: 'AWS/EBS', MetricName: 'VolumeWriteOps', @@ -129,16 +223,16 @@ export class ebsVolumesUtilization extends AwsServiceUtilization element === 0 )){ - this.addScenario(volumeId, 'volumeReadWriteOps', { + const cost = this.getVolumeCost(volume); + this.addScenario(volumeArn, 'volumeReadWriteOps', { value: '0', delete: { action: 'deleteEBSVolume', isActionable: true, - reason: 'No operations performed on this volume in the last week' + reason: 'No operations performed on this volume in the last week', + monthlySavings: cost } }); } } - - } \ No newline at end of file diff --git a/src/service-utilizations/rds-utilization.tsx b/src/service-utilizations/rds-utilization.tsx index 4cfa8df..fe1ea61 100644 --- a/src/service-utilizations/rds-utilization.tsx +++ b/src/service-utilizations/rds-utilization.tsx @@ -3,6 +3,7 @@ import { AwsServiceUtilization } from './aws-service-utilization.js'; import { AwsServiceOverrides } from '../types/types.js'; import { RDS, DBInstance } from '@aws-sdk/client-rds'; import { CloudWatch } from '@aws-sdk/client-cloudwatch'; +import { Pricing } from '@aws-sdk/client-pricing'; export type rdsInstancesUtilizationScenarios = 'hasDatabaseConnections' | 'cpuUtilization' | 'shouldScaleDownStorage' | 'hasAutoScalingEnabled'; @@ -32,10 +33,71 @@ export class rdsInstancesUtilization extends AwsServiceUtilization { + const credentials = await awsCredentialsProvider.getCredentials(); const region = regions[0]; const rdsClient = new RDS({ credentials: await awsCredentialsProvider.getCredentials(), @@ -43,6 +105,9 @@ export class rdsInstancesUtilization extends AwsServiceUtilization { console.log(JSON.stringify(instance, null, 2)); }); + const cost = await this.getRdsInstanceCost(credentials, 'us-east-1', res.DBInstances[0]); + console.log(cost); let dbInstances: DBInstance[] = []; diff --git a/src/types/constants.ts b/src/types/constants.ts index ec44ac5..12acff5 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -9,6 +9,9 @@ export const Arns = { }, S3 (bucketName: string) { return `arn:aws:s3:::${bucketName}`; + }, + Ebs (region: string, accountId: string, volumeId: string) { + return `arn:aws:ec2:${region}:${accountId}:volume/${volumeId}`; } }; diff --git a/src/types/types.ts b/src/types/types.ts index 8dd6a68..980fcb1 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -74,8 +74,8 @@ export type Utilization = { export type UserInput = { [ key: string ]: any } export type AwsServiceOverrides = { - resourceArn?: string, - scenarioType?: string, + resourceArn: string, + scenarioType: string, delete?: boolean, scaleDown?: boolean, optimize?: boolean, diff --git a/src/types/utilization-recommendations-types.ts b/src/types/utilization-recommendations-types.ts index 318c3a8..22739ed 100644 --- a/src/types/utilization-recommendations-types.ts +++ b/src/types/utilization-recommendations-types.ts @@ -21,17 +21,22 @@ interface Refresh { onRefresh: () => void; } -export type UtilizationRecommendationsUiProps = HasUtilization & HasResourcesAction & Refresh -export type UtilizationRecommendationsWidget = Widget & HasActionType & HasUtilization & { - regions: string[] -}; +interface Regions { + onRegionChange: (region: string) => void; + allRegions: string[]; + region: string; +} + +export type UtilizationRecommendationsUiProps = HasUtilization & HasResourcesAction & Refresh & Regions; +export type UtilizationRecommendationsWidget = Widget & HasActionType & HasUtilization & Regions export type RecommendationsCallback = (props: RecommendationsOverrides) => void; export type RecommendationsOverrides = { refresh?: boolean; resourceActions?: { actionType: string, resourceArns: string[] - } + }; + region?: string; }; export type RecommendationsTableProps = HasActionType & HasUtilization & { onContinue: (resourceArns: string[]) => void; @@ -39,7 +44,7 @@ export type RecommendationsTableProps = HasActionType & HasUtilization & { onRefresh: () => void; }; export type RecommendationsActionsSummaryProps = Widget & HasUtilization; -export type RecommendationsActionSummaryProps = HasUtilization & { +export type RecommendationsActionSummaryProps = HasUtilization & Regions & { onContinue: (selectedActionType: ActionType) => void; onRefresh: () => void; }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6714b10..12b340c 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -46,9 +46,9 @@ export function findProvider (providers: BaseProvider[] return provider as T; } -export async function listAllRegions (credentials: any) { +export async function listAllRegions (awsCredentialsProvider: AwsCredentialsProvider) { const accountClient = new Account({ - credentials, + credentials: await awsCredentialsProvider.getCredentials(), region: 'us-east-1' }); diff --git a/src/widgets/aws-utilization-recommendations.tsx b/src/widgets/aws-utilization-recommendations.tsx index 8b2f5f6..e80c163 100644 --- a/src/widgets/aws-utilization-recommendations.tsx +++ b/src/widgets/aws-utilization-recommendations.tsx @@ -11,9 +11,14 @@ import get from 'lodash.get'; export class AwsUtilizationRecommendations extends BaseWidget { utilization?: { [key: AwsResourceType | string]: Utilization }; + allRegions?: string[]; + region?: string; + constructor (props: UtilizationRecommendationsWidget) { super(props); this.utilization = props.utilization; + this.allRegions = props.allRegions; + this.region = props.region || 'us-east-1'; } static fromJson (props: UtilizationRecommendationsWidget) { @@ -23,7 +28,9 @@ export class AwsUtilizationRecommendations extends BaseWidget { toJson () { return { ...super.toJson(), - utilization: this.utilization + utilization: this.utilization, + allRegions: this.allRegions, + region: this.region }; } @@ -31,15 +38,25 @@ export class AwsUtilizationRecommendations extends BaseWidget { const depMap = { utils: '../utils/utils.js' }; - const { getAwsCredentialsProvider, getAwsUtilizationProvider } = await import(depMap.utils); + const { + getAwsCredentialsProvider, + getAwsUtilizationProvider, + listAllRegions + } = await import(depMap.utils); const utilProvider = getAwsUtilizationProvider(providers); const awsCredsProvider = getAwsCredentialsProvider(providers); - + this.allRegions = await listAllRegions(awsCredsProvider); + if (overrides?.refresh) { - await utilProvider.hardRefresh(awsCredsProvider); + await utilProvider.hardRefresh(awsCredsProvider, this.region); + } + + if (overrides?.region) { + this.region = overrides.region; + await utilProvider.hardRefresh(awsCredsProvider, this.region); } - this.utilization = await utilProvider.getUtilization(awsCredsProvider); + this.utilization = await utilProvider.getUtilization(awsCredsProvider, this.region); if (overrides?.resourceActions) { const { actionType, resourceArns } = overrides.resourceActions; @@ -78,11 +95,20 @@ export class AwsUtilizationRecommendations extends BaseWidget { }); } + function onRegionChange (region: string) { + overridesCallback({ + region + }); + } + return ( ); } diff --git a/src/widgets/aws-utilization.tsx b/src/widgets/aws-utilization.tsx index f04e14e..5eaab96 100644 --- a/src/widgets/aws-utilization.tsx +++ b/src/widgets/aws-utilization.tsx @@ -28,7 +28,7 @@ export class AwsUtilization extends BaseWidget { const { getAwsCredentialsProvider, getAwsUtilizationProvider } = await import(depMap.utils); const utilProvider = getAwsUtilizationProvider(providers); const awsCredsProvider = getAwsCredentialsProvider(providers); - this.utilization = await utilProvider.getUtilization(awsCredsProvider); + this.utilization = await utilProvider.getUtilization(awsCredsProvider, this.region); } static fromJson (object: AwsUtilizationType): AwsUtilization { diff --git a/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx b/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx index ec6603e..67f14fe 100644 --- a/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx +++ b/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx @@ -1,16 +1,27 @@ import React from 'react'; -import { Box, Button, Flex, Heading, Icon, Spacer, Stack, Text } from '@chakra-ui/react'; -import { DeleteIcon, ArrowForwardIcon, ArrowDownIcon } from '@chakra-ui/icons'; +import { + Box, + Button, + Flex, + Heading, + Icon, + Menu, + MenuButton, + MenuItem, + MenuList, + Spacer, + Stack, + Text +} from '@chakra-ui/react'; +import { DeleteIcon, ArrowForwardIcon, ArrowDownIcon, ChevronDownIcon } from '@chakra-ui/icons'; import { TbVectorBezier2 } from 'react-icons/tb/index.js'; import { filterUtilizationForActionType, getNumberOfResourcesFromFilteredActions } from '../../utils/utilization.js'; import { ActionType } from '../../types/types.js'; import { RecommendationsActionSummaryProps } from '../../types/utilization-recommendations-types.js'; import { TbRefresh } from 'react-icons/tb/index.js'; - - export function RecommendationsActionSummary (props: RecommendationsActionSummaryProps) { - const { utilization, onContinue, onRefresh } = props; + const { utilization, onContinue, onRefresh, allRegions, region: regionLabel, onRegionChange } = props; const deleteChanges = filterUtilizationForActionType(utilization, ActionType.DELETE); const scaleDownChanges = filterUtilizationForActionType(utilization, ActionType.SCALE_DOWN); @@ -65,6 +76,19 @@ export function RecommendationsActionSummary (props: RecommendationsActionSummar return ( + + + }> + {regionLabel} + + + {allRegions.map(region => + onRegionChange(region)}>{region} + )} + + + +
{actionSummaryStack( ActionType.DELETE, , 'Delete', numDeleteChanges, 'Resources that have had no recent activity.' diff --git a/src/widgets/utilization-recommendations-ui/utilization-recommendations-ui.tsx b/src/widgets/utilization-recommendations-ui/utilization-recommendations-ui.tsx index d144965..f5ef080 100644 --- a/src/widgets/utilization-recommendations-ui/utilization-recommendations-ui.tsx +++ b/src/widgets/utilization-recommendations-ui/utilization-recommendations-ui.tsx @@ -12,7 +12,7 @@ enum WizardSteps { } export function UtilizationRecommendationsUi (props: UtilizationRecommendationsUiProps) { - const { utilization, onResourcesAction, onRefresh } = props; + const { utilization, onResourcesAction, onRefresh, allRegions, region, onRegionChange } = props; const [wizardStep, setWizardStep] = useState(WizardSteps.SUMMARY); const [selectedResourceArns, setSelectedResourceArns] = useState([]); const [actionType, setActionType] = useState(ActionType.DELETE); @@ -26,6 +26,9 @@ export function UtilizationRecommendationsUi (props: UtilizationRecommendationsU setActionType(selectedActionType); setWizardStep(WizardSteps.TABLE); }} + allRegions={allRegions} + onRegionChange={onRegionChange} + region={region} /> ); } @@ -73,6 +76,9 @@ export function UtilizationRecommendationsUi (props: UtilizationRecommendationsU setActionType(selectedActionType); setWizardStep(WizardSteps.TABLE); }} + allRegions={allRegions} + region={region} + onRegionChange={onRegionChange} /> ); // #endregion From 04cb3405d38726f823724f73a3a1c9ba11d6983c Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Thu, 25 May 2023 16:48:09 -0700 Subject: [PATCH 02/12] removed region from provider --- src/aws-utilization-provider.ts | 4 +--- src/service-utilizations/rds-utilization.tsx | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/aws-utilization-provider.ts b/src/aws-utilization-provider.ts index a3cf9b7..f45f4c5 100644 --- a/src/aws-utilization-provider.ts +++ b/src/aws-utilization-provider.ts @@ -34,8 +34,7 @@ class AwsUtilizationProvider extends BaseProvider { constructor (props: AwsUtilizationProviderType) { super(props); const { - services, - region + services } = props; this.utilizationClasses = {}; @@ -50,7 +49,6 @@ class AwsUtilizationProvider extends BaseProvider { 'EbsVolume', 'RdsInstance' ]); - this.region = region || 'us-east-1'; } static fromJson (props: AwsUtilizationProviderType) { diff --git a/src/service-utilizations/rds-utilization.tsx b/src/service-utilizations/rds-utilization.tsx index fe1ea61..fd6b3e9 100644 --- a/src/service-utilizations/rds-utilization.tsx +++ b/src/service-utilizations/rds-utilization.tsx @@ -89,8 +89,6 @@ export class rdsInstancesUtilization extends AwsServiceUtilization Date: Fri, 26 May 2023 12:40:40 -0700 Subject: [PATCH 03/12] center region selector --- src/service-utilizations/rds-utilization.tsx | 6 +++++- .../recommendations-action-summary.tsx | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/service-utilizations/rds-utilization.tsx b/src/service-utilizations/rds-utilization.tsx index fd6b3e9..335ec4d 100644 --- a/src/service-utilizations/rds-utilization.tsx +++ b/src/service-utilizations/rds-utilization.tsx @@ -34,6 +34,7 @@ export class rdsInstancesUtilization extends AwsServiceUtilization { console.log(JSON.stringify(instance, null, 2)); }); + res.DBInstances.forEach(instance => console.log(instance.AllocatedStorage)); + // res.DBInstances.map((instance) => { console.log(JSON.stringify(instance, null, 2)); }); const cost = await this.getRdsInstanceCost(credentials, 'us-east-1', res.DBInstances[0]); console.log(cost); diff --git a/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx b/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx index 67f14fe..cc08d35 100644 --- a/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx +++ b/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx @@ -76,18 +76,18 @@ export function RecommendationsActionSummary (props: RecommendationsActionSummar return ( - + }> {regionLabel} - + {allRegions.map(region => onRegionChange(region)}>{region} )} - +
{actionSummaryStack( ActionType.DELETE, , 'Delete', numDeleteChanges, From c73e81c5da12df3edda0d40009a4d1c9a15c24f1 Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Tue, 30 May 2023 14:59:02 -0700 Subject: [PATCH 04/12] added pricing to rds --- src/service-utilizations/rds-utilization.tsx | 535 ++++++++++++++----- 1 file changed, 394 insertions(+), 141 deletions(-) diff --git a/src/service-utilizations/rds-utilization.tsx b/src/service-utilizations/rds-utilization.tsx index 335ec4d..4357edd 100644 --- a/src/service-utilizations/rds-utilization.tsx +++ b/src/service-utilizations/rds-utilization.tsx @@ -1,14 +1,46 @@ import { AwsCredentialsProvider } from '@tinystacks/ops-aws-core-widgets'; import { AwsServiceUtilization } from './aws-service-utilization.js'; import { AwsServiceOverrides } from '../types/types.js'; -import { RDS, DBInstance } from '@aws-sdk/client-rds'; +import { RDS, DBInstance, DescribeDBInstancesCommandOutput } from '@aws-sdk/client-rds'; import { CloudWatch } from '@aws-sdk/client-cloudwatch'; import { Pricing } from '@aws-sdk/client-pricing'; +import get from 'lodash.get'; +import { ONE_GB_IN_BYTES } from '../types/constants.js'; +import { getHourlyCost } from '../utils/utils.js'; + +const oneMonthAgo = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)); + +// monthly costs +type StorageAndIOCosts = { + totalStorageCost: number, + iopsCost: number, + throughputCost?: number +}; + +// monthly costs +type RdsCosts = StorageAndIOCosts & { + totalCost: number, + instanceCost: number +} + +type RdsMetrics = { + totalIops: number; + totalThroughput: number; + freeStorageSpace: number; + totalBackupStorageBilled: number; + cpuUtilization: number; + databaseConnections: number; +}; export type rdsInstancesUtilizationScenarios = 'hasDatabaseConnections' | 'cpuUtilization' | 'shouldScaleDownStorage' | 'hasAutoScalingEnabled'; export class rdsInstancesUtilization extends AwsServiceUtilization { + private instanceCosts: { [instanceId: string]: RdsCosts }; + private rdsClient: RDS; + private cwClient: CloudWatch; + private pricingClient: Pricing; + private region: string; async doAction ( awsCredentialsProvider: AwsCredentialsProvider, actionName: string, resourceArn: string, region: string @@ -25,6 +57,7 @@ export class rdsInstancesUtilization extends AwsServiceUtilization { + const res = await this.cwClient.getMetricData({ + MetricDataQueries: [ + { + Id: 'readIops', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'ReadIOPS', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month + Stat: 'Average' + } + }, + { + Id: 'writeIops', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'WriteIOPS', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month + Stat: 'Average' + } + }, + { + Id: 'readThroughput', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'ReadThroughput', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month + Stat: 'Average' + } + }, + { + Id: 'writeThroughput', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'WriteThroughput', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month + Stat: 'Average' + } + }, + { + Id: 'freeStorageSpace', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'FreeStorageSpace', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month + Stat: 'Average' + } + }, + { + Id: 'totalBackupStorageBilled', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'TotalBackupStorageBilled', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month + Stat: 'Average' + } + }, + { + Id: 'cpuUtilization', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'CPUUtilization', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month, + Stat: 'Maximum', + Unit: 'Percent' + } + }, + { + Id: 'databaseConnections', + MetricStat: { + Metric: { + Namespace: 'AWS/RDS', + MetricName: 'DatabaseConnections', + Dimensions: [{ + Name: 'DBInstanceIdentifier', + Value: dbInstance.DBInstanceIdentifier + }] + }, + Period: 30 * 24 * 12 * 300, // 1 month, + Stat: 'Sum' + } + } + ], + StartTime: oneMonthAgo, + EndTime: new Date() }); - let dbEngine = ''; - // if (dbInstance.Engine === 'aurora-mysql') { + const readIops = get(res, 'MetricDataResults[0].Values[0]', 0); + const writeIops = get(res, 'MetricDataResults[1].Values[0]', 0); + const readThroughput = get(res, 'MetricDataResults[2].Values[0]', 0); + const writeThroughput = get(res, 'MetricDataResults[3].Values[0]', 0); + const freeStorageSpace = get(res, 'MetricDataResults[4].Values[0]', 0); + const totalBackupStorageBilled = get(res, 'MetricDataResults[5].Values[0]', 0); + const cpuUtilization = get(res, 'MetricDataResults[6].Values[6]', 0); + const databaseConnections = get(res, 'MetricDataResults[7].Values[0]', 0); + + return { + totalIops: readIops + writeIops, + totalThroughput: readThroughput + writeThroughput, + freeStorageSpace, + totalBackupStorageBilled, + cpuUtilization, + databaseConnections + }; + } - // } else if (dbInstance.Engine === 'aurora-postgresql') { + private getAuroraCosts ( + storageUsedInGB: number, + totalBackupStorageBilled: number, + totalIops: number + ): StorageAndIOCosts { + const storageCost = storageUsedInGB * 0.10; + const backupStorageCost = (totalBackupStorageBilled / ONE_GB_IN_BYTES) * 0.021; + const iopsCost = (totalIops / 1000000) * 0.20; // per 1 million requests + + return { + totalStorageCost: storageCost + backupStorageCost, + iopsCost + }; + } - // } - if (dbInstance.Engine.startsWith('aurora')) { - const parts = dbInstance.Engine.split('-'); - if (parts[1] === 'mysql') { - dbEngine = 'Aurora MySQL'; + private getOtherDbCosts ( + dbInstance: DBInstance, + storageUsedInGB: number, + totalIops: number, + totalThroughput: number + ): StorageAndIOCosts { + let storageCost = 0; + let iopsCost = 0; + let throughputCost = 0; + if (dbInstance.StorageType === 'gp2') { + if (dbInstance.MultiAZ) { + storageCost = storageUsedInGB * 0.23; } else { - dbEngine = 'Aurora PostgreSQL'; + storageCost = storageUsedInGB * 0.115; + } + } else if (dbInstance.StorageType === 'gp3') { + if (dbInstance.MultiAZ) { + storageCost = storageUsedInGB * 0.23; + iopsCost = totalIops > 3000 ? (totalIops - 3000) * 0.04 : 0; + // verify throughput metrics are in MB/s + throughputCost = totalThroughput > 125 ? (totalThroughput - 125) * 0.160 : 0; + } else { + storageCost = storageUsedInGB * 0.115; + iopsCost = totalIops > 3000 ? (totalIops - 3000) * 0.02 : 0; + // verify throughput metrics are in MB/s + throughputCost = totalThroughput > 125 ? (totalThroughput - 125) * 0.080 : 0; } } else { - dbEngine = dbInstance.Engine; + if (dbInstance.MultiAZ) { + storageCost = (dbInstance.AllocatedStorage || 0) * 0.25; + iopsCost = (dbInstance.Iops || 0) * 0.20; + } else { + storageCost = (dbInstance.AllocatedStorage || 0) * 0.125; + iopsCost = (dbInstance.Iops || 0) * 0.10; + } } + return { + totalStorageCost: storageCost, + iopsCost, + throughputCost + }; + } + + private getStorageAndIOCosts (dbInstance: DBInstance, metrics: RdsMetrics) { + const { + totalIops, + totalThroughput, + freeStorageSpace, + totalBackupStorageBilled + } = metrics; + const dbInstanceClass = dbInstance.DBInstanceClass; + const storageUsedInGB = + dbInstance.AllocatedStorage ? + dbInstance.AllocatedStorage - (freeStorageSpace / ONE_GB_IN_BYTES) : + 0; + if (dbInstanceClass.startsWith('aurora')) { + return this.getAuroraCosts(storageUsedInGB, totalBackupStorageBilled, totalIops); + } else { + // mysql, postgresql, mariadb, oracle, sql server + return this.getOtherDbCosts(dbInstance, storageUsedInGB, totalIops, totalThroughput); + } + } + + /* easier to hard code for now but saving for later + * volumeName is instance.storageType + * need to call for Provisioned IOPS and Database Storage + async getRdsStorageCost () { const res = await pricingClient.getProducts({ ServiceCode: 'AmazonRDS', Filters: [ { Type: 'TERM_MATCH', - Field: 'instanceType', - Value: dbInstance.DBInstanceClass + Field: 'volumeName', + Value: 'io1' }, { Type: 'TERM_MATCH', Field: 'regionCode', - Value: region + Value: 'us-east-1' }, { Type: 'TERM_MATCH', - Field: 'databaseEngine', - Value: dbEngine + Field: 'deploymentOption', + Value: 'Single-AZ' }, { Type: 'TERM_MATCH', - Field: 'deploymentOption', - Value: dbInstance.MultiAZ ? 'Multi-AZ' : 'Single-AZ' - } + Field: 'productFamily', + Value: 'Provisioned IOPS' + }, ] }); + } + */ - console.log(res.PriceList); + // TODO: implement serverless cost? + // TODO: implement i/o optimized cost? + async getRdsInstanceCosts (dbInstance: DBInstance, metrics: RdsMetrics) { + if (dbInstance.DBInstanceIdentifier in this.instanceCosts) { + return this.instanceCosts[dbInstance.DBInstanceIdentifier]; + } - const onDemandData = JSON.parse(res.PriceList[0] as string).terms.OnDemand; - const onDemandKeys = Object.keys(onDemandData); - const priceDimensionsData = onDemandData[onDemandKeys[0]].priceDimensions; - const priceDimensionsKeys = Object.keys(priceDimensionsData); - const pricePerHour = priceDimensionsData[priceDimensionsKeys[0]].pricePerUnit.USD; + let dbEngine = ''; + if (dbInstance.Engine.startsWith('aurora')) { + if (dbInstance.Engine.endsWith('mysql')) { + dbEngine = 'Aurora MySQL'; + } else { + dbEngine = 'Aurora PostgreSQL'; + } + } else { + dbEngine = dbInstance.Engine; + } - return pricePerHour * 24 * 30; + try { + const res = await this.pricingClient.getProducts({ + ServiceCode: 'AmazonRDS', + Filters: [ + { + Type: 'TERM_MATCH', + Field: 'instanceType', + Value: dbInstance.DBInstanceClass + }, + { + Type: 'TERM_MATCH', + Field: 'regionCode', + Value: this.region + }, + { + Type: 'TERM_MATCH', + Field: 'databaseEngine', + Value: dbEngine + }, + { + Type: 'TERM_MATCH', + Field: 'deploymentOption', + Value: dbInstance.MultiAZ ? 'Multi-AZ' : 'Single-AZ' + } + ] + }); + + const onDemandData = JSON.parse(res.PriceList[0] as string).terms.OnDemand; + const onDemandKeys = Object.keys(onDemandData); + const priceDimensionsData = onDemandData[onDemandKeys[0]].priceDimensions; + const priceDimensionsKeys = Object.keys(priceDimensionsData); + const pricePerHour = priceDimensionsData[priceDimensionsKeys[0]].pricePerUnit.USD; + const instanceCost = pricePerHour * 24 * 30; + + const { totalStorageCost, iopsCost, throughputCost = 0 } = this.getStorageAndIOCosts(dbInstance, metrics); + const totalCost = instanceCost + totalStorageCost + iopsCost + throughputCost; + const rdsCosts = { + totalCost, + instanceCost, + totalStorageCost, + iopsCost, + throughputCost + } as RdsCosts; + + this.instanceCosts[dbInstance.DBInstanceIdentifier] = rdsCosts; + return rdsCosts; + } catch (e) { + return { + totalCost: 0, + instanceCost: 0, + totalStorageCost: 0, + iopsCost: 0, + throughputCost: 0 + } as RdsCosts; + } } async getUtilization ( awsCredentialsProvider: AwsCredentialsProvider, regions: string[], _overrides?: AwsServiceOverrides ): Promise { const credentials = await awsCredentialsProvider.getCredentials(); - const region = regions[0]; - const rdsClient = new RDS({ - credentials: await awsCredentialsProvider.getCredentials(), - region: region + this.region = regions[0]; + this.rdsClient = new RDS({ + credentials, + region: this.region + }); + this.cwClient = new CloudWatch({ + credentials, + region: this.region + }); + this.pricingClient = new Pricing({ + credentials, + region: this.region }); - - let res = await rdsClient.describeDBInstances({}); - res.DBInstances.forEach(instance => console.log(instance.AllocatedStorage)); - // res.DBInstances.map((instance) => { console.log(JSON.stringify(instance, null, 2)); }); - const cost = await this.getRdsInstanceCost(credentials, 'us-east-1', res.DBInstances[0]); - console.log(cost); let dbInstances: DBInstance[] = []; - - dbInstances = [...res.DBInstances]; - - while(res.Marker){ - res = await rdsClient.describeDBInstances({ - Marker: res.Marker - }); - dbInstances = [...dbInstances, ...res.DBInstances]; - } - - - const promises: Promise[] = []; - - const cloudWatchClient = new CloudWatch({ - credentials: await awsCredentialsProvider.getCredentials(), - region: region - }); - - for (let i = 0; i < dbInstances.length; ++i) { - promises.push(this.getDatabaseConnections(cloudWatchClient, dbInstances[i].DBInstanceIdentifier)); - promises.push(this.checkInstanceStorage(cloudWatchClient, dbInstances[i])); - promises.push(this.getCPUUTilization(cloudWatchClient, dbInstances[i].DBInstanceIdentifier)); + let describeDBInstancesRes: DescribeDBInstancesCommandOutput; + do { + describeDBInstancesRes = await this.rdsClient.describeDBInstances({}); + dbInstances = [ ...dbInstances, ...describeDBInstancesRes.DBInstances ]; + } while (describeDBInstancesRes?.Marker); + + for (const dbInstance of dbInstances) { + const dbInstanceId = dbInstance.DBInstanceIdentifier; + const dbInstanceArn = dbInstance.DBInstanceArn || dbInstanceId; + const metrics = await this.getRdsInstanceMetrics(dbInstance); + await this.getDatabaseConnections(metrics, dbInstance, dbInstanceArn); + await this.checkInstanceStorage(metrics, dbInstance, dbInstanceArn); + await this.getCPUUtilization(metrics, dbInstance, dbInstanceArn); + + const monthlyCost = this.instanceCosts[dbInstanceId]?.totalCost; + await this.fillData(dbInstanceArn, credentials, this.region, { + resourceId: dbInstanceId, + region: this.region, + monthlyCost, + hourlyCost: getHourlyCost(monthlyCost) + }); } - - void await Promise.all(promises).catch(e => console.log(e)); - } - async getDatabaseConnections (cloudWatchClient: CloudWatch, dbInstanceIdentifier: string){ - const connectionsRes = await cloudWatchClient.getMetricStatistics({ - Namespace: 'AWS/RDS', - MetricName: 'DatabaseConnections', - StartTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - EndTime: new Date(Date.now()), - Period: 43200, - Statistics: ['Sum'], - Dimensions: [{ - Name: 'DBInstanceIdentifier', - Value: dbInstanceIdentifier - }] - }); - - const dbConnectionStats = connectionsRes.Datapoints.map((data) => { - return data.Sum; - }); - - if(dbConnectionStats.length === 0 || dbConnectionStats.every( element => element === 0 )){ - this.addScenario(dbInstanceIdentifier, 'hasDatabaseConnections', { + async getDatabaseConnections (metrics: RdsMetrics, dbInstance: DBInstance, dbInstanceArn: string){ + if (!metrics.databaseConnections) { + const { totalCost } = await this.getRdsInstanceCosts(dbInstance, metrics); + this.addScenario(dbInstanceArn, 'hasDatabaseConnections', { value: 'false', delete: { action: 'deleteInstance', isActionable: true, - reason: 'This instance does not have any db connections' + reason: 'This instance does not have any db connections', + monthlySavings: totalCost } }); } } - async checkInstanceStorage (cloudWatchClient: CloudWatch, dbInstance: DBInstance){ + async checkInstanceStorage (metrics: RdsMetrics, dbInstance: DBInstance, dbInstanceArn: string) { //if theres a maxallocatedstorage then storage auto-scaling is enabled if(!dbInstance.MaxAllocatedStorage){ - this.addScenario(dbInstance.DBInstanceIdentifier, 'hasAutoScalingEnabled', { + await this.getRdsInstanceCosts(dbInstance, metrics); + this.addScenario(dbInstanceArn, 'hasAutoScalingEnabled', { value: 'false', optimize: { action: '', //didnt find an action for this, need to do it in the console isActionable: false, - reason: 'This instance does not have storage auto-scaling turned on' + reason: 'This instance does not have storage auto-scaling turned on', + monthlySavings: 0 } }); } - - //get amount of available storage space - const storageSpaceRes = await cloudWatchClient.getMetricStatistics({ - Namespace: 'AWS/RDS', - MetricName: 'FreeStorageSpace', - StartTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), //instead of 7 days, should we consider 30 days? - EndTime: new Date(Date.now()), - Period: 43200, - Statistics: ['Sum'], - Dimensions: [{ - Name: 'DBInstanceIdentifier', - Value: dbInstance.DBInstanceIdentifier - }] - }); - - const storageSpaceStats = storageSpaceRes.Datapoints.map((data) => { - return data.Sum; - }); - if(storageSpaceStats.length > 0 && storageSpaceStats.every(element => element >= (dbInstance.AllocatedStorage/2))){ + if (metrics.freeStorageSpace >= (dbInstance.AllocatedStorage / 2)) { + await this.getRdsInstanceCosts(dbInstance, metrics); this.addScenario(dbInstance.DBInstanceIdentifier, 'shouldScaleDownStorage', { value: 'true', scaleDown: { action: '', isActionable: false, - reason: 'This instance has more than half of its allocated storage still available' + reason: 'This instance has more than half of its allocated storage still available', + monthlySavings: 0 } }); } } - async getCPUUTilization (cloudWatchClient: CloudWatch, dbInstanceIdentifier: string){ - - const metricStats = await cloudWatchClient.getMetricStatistics({ - Namespace: 'AWS/RDS', - MetricName: 'CPUUtilization', - StartTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - EndTime: new Date(Date.now()), - Period: 43200, - Statistics: ['Maximum'], - Dimensions: [{ - Name: 'DBInstanceIdentifier', - Value: dbInstanceIdentifier - }], - Unit: 'Percent' - }); - - const cpuValues = metricStats.Datapoints.map((data) => { - return data.Maximum; - }); - - const maxCPU = Math.max(...cpuValues); - - if(maxCPU < 50){ - this.addScenario(dbInstanceIdentifier, 'cpuUtilization', { - value: maxCPU.toString(), - scaleDown: { + async getCPUUtilization (metrics: RdsMetrics, dbInstance: DBInstance, dbInstanceArn: string){ + if (metrics.cpuUtilization < 50) { + await this.getRdsInstanceCosts(dbInstance, metrics); + this.addScenario(dbInstanceArn, 'cpuUtilization', { + value: metrics.cpuUtilization.toString(), + scaleDown: { action: '', isActionable: false, - reason: 'Max CPU Utilization is under 50%' + reason: 'Max CPU Utilization is under 50%', + monthlySavings: 0 } - } - ); + }); } - } } \ No newline at end of file From 35a6abd6e7402fceb0a0a91bc6edf5e3207d8736 Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Tue, 30 May 2023 15:06:39 -0700 Subject: [PATCH 05/12] removed dead code --- .../aws-cloudwatch-logs-utilization.ts | 7 ------- .../aws-ec2-instance-utilization.ts | 6 ------ src/service-utilizations/aws-ecs-utilization.ts | 14 -------------- .../aws-nat-gateway-utilization.ts | 6 ------ src/service-utilizations/aws-s3-utilization.tsx | 9 --------- .../ebs-volumes-utilization.tsx | 14 -------------- 6 files changed, 56 deletions(-) diff --git a/src/service-utilizations/aws-cloudwatch-logs-utilization.ts b/src/service-utilizations/aws-cloudwatch-logs-utilization.ts index e8ca736..06f6387 100644 --- a/src/service-utilizations/aws-cloudwatch-logs-utilization.ts +++ b/src/service-utilizations/aws-cloudwatch-logs-utilization.ts @@ -239,13 +239,6 @@ export class AwsCloudwatchLogsUtilization extends AwsServiceUtilization hourlyCost: getHourlyCost(monthlyCost) } ); - - // TODO: Change bucketName to bucketArn - // this.addData(bucketArn, 'resourceId', bucketName); - // this.addData(bucketArn, 'region', region); - // if (bucketName in this.bucketCostData) { - // const monthlyCost = this.bucketCostData[bucketName].monthlyCost; - // this.addData(bucketArn, 'monthlyCost', monthlyCost); - // this.addData(bucketArn, 'hourlyCost', getHourlyCost(monthlyCost)); - // } }; await rateLimitMap(allS3Buckets, 5, 5, analyzeS3Bucket); diff --git a/src/service-utilizations/ebs-volumes-utilization.tsx b/src/service-utilizations/ebs-volumes-utilization.tsx index 451722e..3113386 100644 --- a/src/service-utilizations/ebs-volumes-utilization.tsx +++ b/src/service-utilizations/ebs-volumes-utilization.tsx @@ -141,20 +141,6 @@ export class ebsVolumesUtilization extends AwsServiceUtilization Date: Tue, 30 May 2023 15:13:05 -0700 Subject: [PATCH 06/12] version bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5567f8f..0e55c1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tinystacks/ops-aws-utilization-widgets", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tinystacks/ops-aws-utilization-widgets", - "version": "0.0.5", + "version": "0.0.6", "license": "BSD-3-Clause", "dependencies": { "@aws-sdk/client-account": "^3.315.0", diff --git a/package.json b/package.json index 945736f..2d7bd94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinystacks/ops-aws-utilization-widgets", - "version": "0.0.5", + "version": "0.0.6", "main": "dist/index.js", "type": "module", "files": [ From 67880acd55315ea59780b8b0ea9a0503aa4d5439 Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Mon, 5 Jun 2023 14:07:33 -0700 Subject: [PATCH 07/12] cost checkpoint --- package-lock.json | 892 +++++++++++++++++- package.json | 5 +- src/aws-utilization-provider.ts | 109 ++- .../confirm-recommendations.tsx | 0 .../confirm-single-recommendation.tsx | 0 .../recommendations-action-summary.tsx | 0 .../recommendations-table.tsx | 0 .../service-table-row.tsx | 0 .../utilization-recommendations-ui.tsx | 0 src/types/types.ts | 8 +- src/utils/utils.ts | 39 +- src/widgets/aws-cost-by-service.tsx | 74 ++ .../aws-utilization-recommendations.tsx | 4 +- 13 files changed, 1125 insertions(+), 6 deletions(-) rename src/{widgets => components}/utilization-recommendations-ui/confirm-recommendations.tsx (100%) rename src/{widgets => components}/utilization-recommendations-ui/confirm-single-recommendation.tsx (100%) rename src/{widgets => components}/utilization-recommendations-ui/recommendations-action-summary.tsx (100%) rename src/{widgets => components}/utilization-recommendations-ui/recommendations-table.tsx (100%) rename src/{widgets => components}/utilization-recommendations-ui/service-table-row.tsx (100%) rename src/{widgets => components}/utilization-recommendations-ui/utilization-recommendations-ui.tsx (100%) create mode 100644 src/widgets/aws-cost-by-service.tsx diff --git a/package-lock.json b/package-lock.json index 0e55c1a..41f7fa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@aws-sdk/client-cloudformation": "^3.315.0", "@aws-sdk/client-cloudwatch": "^3.301.0", "@aws-sdk/client-cloudwatch-logs": "^3.296.0", + "@aws-sdk/client-cost-and-usage-report-service": "^3.342.0", "@aws-sdk/client-cost-explorer": "^3.319.0", "@aws-sdk/client-ec2": "^3.303.0", "@aws-sdk/client-ecs": "^3.315.0", @@ -30,6 +31,7 @@ "@tinystacks/ops-model": "^0.2.0", "cached": "^6.1.0", "dayjs": "^1.11.7", + "fast-csv": "^4.3.6", "http-errors": "^2.0.0", "lodash.chunk": "^4.2.0", "lodash.get": "^4.4.2", @@ -38,7 +40,8 @@ "lodash.startcase": "^4.4.0", "react": "^18.2.0", "react-icons": "^4.8.0", - "simple-statistics": "^7.8.3" + "simple-statistics": "^7.8.3", + "zlib": "^1.0.5" }, "devDependencies": { "@babel/cli": "^7.20.7", @@ -813,6 +816,777 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cost-and-usage-report-service/-/client-cost-and-usage-report-service-3.342.0.tgz", + "integrity": "sha512-EDeVuFRqMBLs//8AakV2GwSeMJR5BbDqrKw88VP2HRAIS47w7JbUXXcQUt1tlw69dKYL4Qxm5b1TtDr3OPnDnQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.342.0", + "@aws-sdk/config-resolver": "3.342.0", + "@aws-sdk/credential-provider-node": "3.342.0", + "@aws-sdk/fetch-http-handler": "3.342.0", + "@aws-sdk/hash-node": "3.342.0", + "@aws-sdk/invalid-dependency": "3.342.0", + "@aws-sdk/middleware-content-length": "3.342.0", + "@aws-sdk/middleware-endpoint": "3.342.0", + "@aws-sdk/middleware-host-header": "3.342.0", + "@aws-sdk/middleware-logger": "3.342.0", + "@aws-sdk/middleware-recursion-detection": "3.342.0", + "@aws-sdk/middleware-retry": "3.342.0", + "@aws-sdk/middleware-serde": "3.342.0", + "@aws-sdk/middleware-signing": "3.342.0", + "@aws-sdk/middleware-stack": "3.342.0", + "@aws-sdk/middleware-user-agent": "3.342.0", + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/node-http-handler": "3.342.0", + "@aws-sdk/smithy-client": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/url-parser": "3.342.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.342.0", + "@aws-sdk/util-defaults-mode-node": "3.342.0", + "@aws-sdk/util-endpoints": "3.342.0", + "@aws-sdk/util-retry": "3.342.0", + "@aws-sdk/util-user-agent-browser": "3.342.0", + "@aws-sdk/util-user-agent-node": "3.342.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/abort-controller": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.342.0.tgz", + "integrity": "sha512-W1lAYldbzDjfn8vwnwNe+6qNWfSu1+JrdiVIRSwsiwKvF2ahjKuaLoc8rJM09C6ieNWRi5634urFgfwAJuv6vg==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/client-sso": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.342.0.tgz", + "integrity": "sha512-DbEL+sWBua/04zTlJ6QmUsOpbeIlnPp8eYXQllCwsFzsIT04MjMI4hCZNia/weymwcq3vWTJOk2++SZf0sCGcw==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.342.0", + "@aws-sdk/fetch-http-handler": "3.342.0", + "@aws-sdk/hash-node": "3.342.0", + "@aws-sdk/invalid-dependency": "3.342.0", + "@aws-sdk/middleware-content-length": "3.342.0", + "@aws-sdk/middleware-endpoint": "3.342.0", + "@aws-sdk/middleware-host-header": "3.342.0", + "@aws-sdk/middleware-logger": "3.342.0", + "@aws-sdk/middleware-recursion-detection": "3.342.0", + "@aws-sdk/middleware-retry": "3.342.0", + "@aws-sdk/middleware-serde": "3.342.0", + "@aws-sdk/middleware-stack": "3.342.0", + "@aws-sdk/middleware-user-agent": "3.342.0", + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/node-http-handler": "3.342.0", + "@aws-sdk/smithy-client": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/url-parser": "3.342.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.342.0", + "@aws-sdk/util-defaults-mode-node": "3.342.0", + "@aws-sdk/util-endpoints": "3.342.0", + "@aws-sdk/util-retry": "3.342.0", + "@aws-sdk/util-user-agent-browser": "3.342.0", + "@aws-sdk/util-user-agent-node": "3.342.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.342.0.tgz", + "integrity": "sha512-C1jeKD39pWXlpGRxhWWBw2No1lyZnyIN72M2Qg3BWK6QlsSDtd9kdhpGS9rQU0i1F4w5x178a+qiGWHHMhCwLg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.342.0", + "@aws-sdk/fetch-http-handler": "3.342.0", + "@aws-sdk/hash-node": "3.342.0", + "@aws-sdk/invalid-dependency": "3.342.0", + "@aws-sdk/middleware-content-length": "3.342.0", + "@aws-sdk/middleware-endpoint": "3.342.0", + "@aws-sdk/middleware-host-header": "3.342.0", + "@aws-sdk/middleware-logger": "3.342.0", + "@aws-sdk/middleware-recursion-detection": "3.342.0", + "@aws-sdk/middleware-retry": "3.342.0", + "@aws-sdk/middleware-serde": "3.342.0", + "@aws-sdk/middleware-stack": "3.342.0", + "@aws-sdk/middleware-user-agent": "3.342.0", + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/node-http-handler": "3.342.0", + "@aws-sdk/smithy-client": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/url-parser": "3.342.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.342.0", + "@aws-sdk/util-defaults-mode-node": "3.342.0", + "@aws-sdk/util-endpoints": "3.342.0", + "@aws-sdk/util-retry": "3.342.0", + "@aws-sdk/util-user-agent-browser": "3.342.0", + "@aws-sdk/util-user-agent-node": "3.342.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/client-sts": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.342.0.tgz", + "integrity": "sha512-MUgYm/2ra1Pwoqw9ng75rVsvTLQvLHZLsTjJuKJ4hnHx1GdmQt4/ZlG1q/J2ZK2o6RZXqgavscz/nyrZH0QumA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.342.0", + "@aws-sdk/credential-provider-node": "3.342.0", + "@aws-sdk/fetch-http-handler": "3.342.0", + "@aws-sdk/hash-node": "3.342.0", + "@aws-sdk/invalid-dependency": "3.342.0", + "@aws-sdk/middleware-content-length": "3.342.0", + "@aws-sdk/middleware-endpoint": "3.342.0", + "@aws-sdk/middleware-host-header": "3.342.0", + "@aws-sdk/middleware-logger": "3.342.0", + "@aws-sdk/middleware-recursion-detection": "3.342.0", + "@aws-sdk/middleware-retry": "3.342.0", + "@aws-sdk/middleware-sdk-sts": "3.342.0", + "@aws-sdk/middleware-serde": "3.342.0", + "@aws-sdk/middleware-signing": "3.342.0", + "@aws-sdk/middleware-stack": "3.342.0", + "@aws-sdk/middleware-user-agent": "3.342.0", + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/node-http-handler": "3.342.0", + "@aws-sdk/smithy-client": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/url-parser": "3.342.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.342.0", + "@aws-sdk/util-defaults-mode-node": "3.342.0", + "@aws-sdk/util-endpoints": "3.342.0", + "@aws-sdk/util-retry": "3.342.0", + "@aws-sdk/util-user-agent-browser": "3.342.0", + "@aws-sdk/util-user-agent-node": "3.342.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "fast-xml-parser": "4.1.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/config-resolver": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.342.0.tgz", + "integrity": "sha512-jUg6DTTrCvG8AOPv5NRJ6PSQSC5fEI2gVv4luzvrGkRJULYbIqpdfUYdW7jB3rWAWC79pQQr5lSqC5DWH91stw==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-config-provider": "3.310.0", + "@aws-sdk/util-middleware": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.342.0.tgz", + "integrity": "sha512-mufOcoqdXZXkvA7u6hUcJz6wKpVaho8SRWCvJrGO4YkyudUAoI9KSP5R4U+gtneDJ2Y/IEKPuw8ugNfANa1J+A==", + "dependencies": { + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-imds": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.342.0.tgz", + "integrity": "sha512-ReaHwFLfcsEYjDFvi95OFd+IU8frPwuAygwL56aiMT7Voc0oy3EqB3MFs3gzFxdLsJ0vw9TZMRbaouepAEVCkA==", + "dependencies": { + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/url-parser": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.342.0.tgz", + "integrity": "sha512-VJ7+IlI3rx5XfO8AarbKeqNVwfExsWW0S6fqBXIim0s10FJAy7R+wxYyhZhawfRm0ydCggT+Ji6dftS+WXF8fg==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.342.0", + "@aws-sdk/credential-provider-imds": "3.342.0", + "@aws-sdk/credential-provider-process": "3.342.0", + "@aws-sdk/credential-provider-sso": "3.342.0", + "@aws-sdk/credential-provider-web-identity": "3.342.0", + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/shared-ini-file-loader": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.342.0.tgz", + "integrity": "sha512-u3oUo0UxGEaHLtIx7a38aFLgcTe1OevCNe5exL3ugf5C4ifvUjM8rLWySQ9zrKRgPT2yDRYG/oq4ezjoR9fhHg==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.342.0", + "@aws-sdk/credential-provider-imds": "3.342.0", + "@aws-sdk/credential-provider-ini": "3.342.0", + "@aws-sdk/credential-provider-process": "3.342.0", + "@aws-sdk/credential-provider-sso": "3.342.0", + "@aws-sdk/credential-provider-web-identity": "3.342.0", + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/shared-ini-file-loader": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.342.0.tgz", + "integrity": "sha512-q03yJQPa4jnZtwKFW3yEYNMcpYH7wQzbEOEXjnXG4v8935oOttZjXBvRK7ax+f0D1ZHZFeFSashjw0A/bi1efQ==", + "dependencies": { + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/shared-ini-file-loader": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.342.0.tgz", + "integrity": "sha512-ank2703Riz5gwTxC11FDnZtMcq1Z1JjN3Nd53ahyZ+KOJPgWXEw+uolEuzMl4oAovmbTJ6WANo2qMVmLzZEaQg==", + "dependencies": { + "@aws-sdk/client-sso": "3.342.0", + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/shared-ini-file-loader": "3.342.0", + "@aws-sdk/token-providers": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.342.0.tgz", + "integrity": "sha512-+an5oGnzoXMmGJql0Qs9MtyQTmz5GFqrWleQ0k9UVhN3uIfCS9AITS7vb+q1+G7A7YXy9+KshgBhcHco0G/JWQ==", + "dependencies": { + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/eventstream-codec": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-codec/-/eventstream-codec-3.342.0.tgz", + "integrity": "sha512-IwtvSuplioMyiu/pQgpazKkGWDM5M5BOx85zmsB0uNxt6rmje8+WqPmGmuPdmJv4bLC5dJPLovcCp/fuH8XWhA==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/fetch-http-handler": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.342.0.tgz", + "integrity": "sha512-zsC23VUQMHEu4OKloLCVyWLG0ns6n+HKZ9euGLnNO3l0VSRne9qj/94yR+4jr/h04M7MhGf9mlczGfnZUFxs5w==", + "dependencies": { + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/querystring-builder": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-base64": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/hash-node": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.342.0.tgz", + "integrity": "sha512-cFgXy9CDNQdYCdJBsG91FF0P0tNkCfi7+vTy7fzAEchxLxhcfLtC0cS6+gv2e3Dy8mv+uqp45Tu24+8Trx9hJQ==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-buffer-from": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/invalid-dependency": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.342.0.tgz", + "integrity": "sha512-3qza2Br1jGKJi8toPYG9u5aGJ3sbGmJLgKDvlga7q3F8JaeB92He6muRJ07eyDvxZ9jiKhLZ2mtYoVcEjI7Mgw==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-content-length": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.342.0.tgz", + "integrity": "sha512-7LUMZqhihSAptGRFFQvuwt9nCLNzNPkGd1oU1RpVXw6YPQfKP9Ec5tgg4oUlv1t58IYQvdVj5ITKp4X2aUJVPg==", + "dependencies": { + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-endpoint": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.342.0.tgz", + "integrity": "sha512-/rE+3a2EbNQoylc7vyN+O6GFfcLitboZ8f/Kdkld3Ijcp9whPHdfjiqujlwyiUTgBVP3BqgyB3r7AZDloc7B0g==", + "dependencies": { + "@aws-sdk/middleware-serde": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/url-parser": "3.342.0", + "@aws-sdk/util-middleware": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.342.0.tgz", + "integrity": "sha512-EOoix2D2Mk3NQtv7UVhJttfttGYechQxKuGvCI8+8iEKxqlyXaKqAkLR07BQb6epMYeKP4z1PfJm203Sf0WPUQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-logger": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.342.0.tgz", + "integrity": "sha512-wbkp85T7p9sHLNPMY6HAXHvLOp+vOubFT/XLIGtgRhYu5aRJSlVo9qlwtdZjyhEgIRQ6H/QUnqAN7Zgk5bCLSw==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.342.0.tgz", + "integrity": "sha512-KUDseSAz95kXCqnXEQxNObpviZ6F7eJ5lEgpi+ZehlzGDk/GyOVgjVuAyI7nNxWI5v0ZJ5nIDy+BH273dWbnmQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-retry": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.342.0.tgz", + "integrity": "sha512-Bfllrjqs0bXNG7A3ydLjTAE5zPEdigG+/lDuEsCfB35gywZnnxqi6BjTeQ9Ss6gbEWX+WyXP7/oVdNaUDQUr9Q==", + "dependencies": { + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/service-error-classification": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-middleware": "3.342.0", + "@aws-sdk/util-retry": "3.342.0", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-sdk-sts": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.342.0.tgz", + "integrity": "sha512-eGcGDC+6UWKC87mex3voBVRcZN3hzFN6GVzWkTS574hDqp/uJG3yPk3Dltw0qf8skikTGi3/ZE+yAxerq/f5rg==", + "dependencies": { + "@aws-sdk/middleware-signing": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-serde": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.342.0.tgz", + "integrity": "sha512-WRD+Cyu6+h1ymfPnAw4fI2q3zXjihJ55HFe1uRF8VPN4uBbJNfN3IqL38y/SMEdZ0gH9zNlRNxZLhR0q6SNZEQ==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-signing": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.342.0.tgz", + "integrity": "sha512-CFRQyPv4OjRGmFoB3OfKcQ0aHgS9VWC0YwoHnSWIcLt3Xltorug/Amk0obr/MFoIrktdlVtmvLEJ4Z+8cdsz8g==", + "dependencies": { + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/signature-v4": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-middleware": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-stack": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.342.0.tgz", + "integrity": "sha512-nDYtLAv9IZq8YFxtbyAiK/U1mtvtJS0DG6HiIPT5jpHcRpuWRHQ170EAW51zYts+21Ffj1VA6ZPkbup83+T6/w==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.342.0.tgz", + "integrity": "sha512-6iiFno+rq7W82mqM4KQKndIkZdGG1XZDlZIb77fcmQGYYlB1J2S/d0pIPdMk5ZQteuKJ5iorANUC0dKWw1mWTg==", + "dependencies": { + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-endpoints": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/node-config-provider": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.342.0.tgz", + "integrity": "sha512-Mwkj4+zt64w7a8QDrI9q4SrEt7XRO30Vk0a0xENqcOGrKIPfF5aeqlw85NYLoGys+KV1oatqQ+k0GzKx8qTIdQ==", + "dependencies": { + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/shared-ini-file-loader": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/node-http-handler": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.342.0.tgz", + "integrity": "sha512-ieNdrfJJMh46qY6rkV1azJBo3UfS9hc7d8CuHtkgHhCfH3BhxbtFqEiGilOdBmY5Sk69b//lFr4zHpUPYsXKaA==", + "dependencies": { + "@aws-sdk/abort-controller": "3.342.0", + "@aws-sdk/protocol-http": "3.342.0", + "@aws-sdk/querystring-builder": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/property-provider": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.342.0.tgz", + "integrity": "sha512-p4TR9yRakIpwupEH3BUijWMYThGG0q43n1ICcsBOcvWZpE636lIUw6nzFlOuBUwqyPfUyLbXzchvosYxfCl0jw==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/protocol-http": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.342.0.tgz", + "integrity": "sha512-zuF2urcTJBZ1tltPdTBQzRasuGB7+4Yfs9i5l0F7lE0luK5Azy6G+2r3WWENUNxFTYuP94GrrqaOhVyj8XXLPQ==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/querystring-builder": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.342.0.tgz", + "integrity": "sha512-tb3FbtC36a7XBYeupdKm60LeM0etp73I6/7pDAkzAlw7zJdvY0aQIvj1c0U6nZlwZF8sSSxC7vlamR+wCspdMw==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-uri-escape": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/querystring-parser": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.342.0.tgz", + "integrity": "sha512-6svvr/LZW1EPJaARnOpjf92FIiK25wuO7fRq05gLTcTRAfUMDvub+oDg3Ro9EjJERumrYQrYCem5Qi4X9w8K2g==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/service-error-classification": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.342.0.tgz", + "integrity": "sha512-MwHO5McbdAVKxfQj1yhleboAXqrzcGoi9ODS+bwCwRfe2lakGzBBhu8zaGDlKYOdv5rS+yAPP/5fZZUiuZY8Bw==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/shared-ini-file-loader": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.342.0.tgz", + "integrity": "sha512-kQG7TMQMhNp5+Y8vhGuO/+wU3K/dTx0xC0AKoDFiBf6EpDRmDfr2pPRnfJ9GwgS9haHxJ/3Uwc03swHMlsj20A==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/signature-v4": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.342.0.tgz", + "integrity": "sha512-OWrGO2UOa1ENpy0kYd2shK4sklQygWUqvWLx9FotDbjIeUIEfAnqoPq/QqcXVrNyT/UvPi4iIrjHJEO8JCNRmA==", + "dependencies": { + "@aws-sdk/eventstream-codec": "3.342.0", + "@aws-sdk/is-array-buffer": "3.310.0", + "@aws-sdk/types": "3.342.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "@aws-sdk/util-middleware": "3.342.0", + "@aws-sdk/util-uri-escape": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/smithy-client": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.342.0.tgz", + "integrity": "sha512-HQ4JejjHU2X7OAZPwixFG+EyPSjmoZqll7EvWjPSKyclWrM320haWWz1trVzjG/AgPfeDLfRkH/JoMr13lECew==", + "dependencies": { + "@aws-sdk/middleware-stack": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/token-providers": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.342.0.tgz", + "integrity": "sha512-gYShxImNQVx3FYOUKB7nzzowYiiP1joyx43KrduHwBDV7hiqg7QhtJHr6Ek+QLPqcFKP9rRvo7NhGxu+T7dEQg==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.342.0", + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/shared-ini-file-loader": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/types": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.342.0.tgz", + "integrity": "sha512-5uyXVda/AgUpdZNJ9JPHxwyxr08miPiZ/CKSMcRdQVjcNnrdzY9m/iM9LvnQT44sQO+IEEkF2IoZIWvZcq199A==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/url-parser": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.342.0.tgz", + "integrity": "sha512-r4s/FDK6iywl8l4TqEwIwtNvxWO0kZes03c/yCiRYqxlkjVmbXEOodn5IAAweAeS9yqC3sl/wKbsaoBiGFn45g==", + "dependencies": { + "@aws-sdk/querystring-parser": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-defaults-mode-browser": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.342.0.tgz", + "integrity": "sha512-N1ZRvCLbrt4Re9MKU3pLYR0iO+H7GU7RsXG4yAq6DtSWT9WCw6xhIUpeV2T5uxWKL92o3WHNiGjwcebq+N73Bg==", + "dependencies": { + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/types": "3.342.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-defaults-mode-node": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.342.0.tgz", + "integrity": "sha512-yNa/eX8sELnwM5NONOFR/PCJMHTNrUVklSo/QHy57CT/L3KOqosRNAMnDVMzH1QolGaVN/8jgtDI2xVsvlP+AA==", + "dependencies": { + "@aws-sdk/config-resolver": "3.342.0", + "@aws-sdk/credential-provider-imds": "3.342.0", + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/property-provider": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-endpoints": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.342.0.tgz", + "integrity": "sha512-ZsYF413hkVwSOjvZG6U0SshRtzSg6MtwzO+j90AjpaqgoHAxE5LjO5eVYFfPXTC2U8NhU7xkzASY6++e5bRRnw==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-middleware": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.342.0.tgz", + "integrity": "sha512-P2LYyMP4JUFZBy9DcMvCDxWU34mlShCyrqBZ1ouuGW7UMgRb1PTEvpLAVndIWn9H+1KGDFjMqOWp1FZHr4YZOA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-retry": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.342.0.tgz", + "integrity": "sha512-U1LXXtOMAQjU4H9gjYZng8auRponAH0t3vShHMKT8UQggT6Hwz1obdXUZgcLCtcjp/1aEK4MkDwk2JSjuUTaZw==", + "dependencies": { + "@aws-sdk/service-error-classification": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.342.0.tgz", + "integrity": "sha512-FWHiBi1xaebzmq3LJsizgd2LCix/bKHUTOjTeO6hEYny5DyrOl0liwIA0mqgvfgwIoMOF/l6FGg7kTfKtNgkEA==", + "dependencies": { + "@aws-sdk/types": "3.342.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-cost-and-usage-report-service/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.342.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.342.0.tgz", + "integrity": "sha512-YMAhUar4CAB6hfUR72FH0sRqMBhPajDIhiKrZEOy7+qaWFdfb/t9DYi6p3PYIUZWK2vkESiDoX9Ays2xsp9rOQ==", + "dependencies": { + "@aws-sdk/node-config-provider": "3.342.0", + "@aws-sdk/types": "3.342.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, "node_modules/@aws-sdk/client-cost-explorer": { "version": "3.332.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cost-explorer/-/client-cost-explorer-3.332.0.tgz", @@ -5473,6 +6247,43 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.48", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.48.tgz", + "integrity": "sha512-iL0PIMwejpmuVHgfibHpfDwOdsbmB50wr21X71VnF5d7SsBF7WK+ZvP/SCcFm7Iwb9iiYSap9rlrdhToNAWdxg==" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.48", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.48.tgz", + "integrity": "sha512-iL0PIMwejpmuVHgfibHpfDwOdsbmB50wr21X71VnF5d7SsBF7WK+ZvP/SCcFm7Iwb9iiYSap9rlrdhToNAWdxg==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -6523,6 +7334,29 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/protocol-http": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.0.1.tgz", + "integrity": "sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==", + "dependencies": { + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.0.0.tgz", + "integrity": "sha512-kc1m5wPBHQCTixwuaOh9vnak/iJm21DrSf9UK6yDE5S3mQQ4u11pqAUiKWnlrZnYkeLfAI9UEHj9OaMT1v5Umg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@tinystacks/ops-aws-core-widgets": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@tinystacks/ops-aws-core-widgets/-/ops-aws-core-widgets-0.0.5.tgz", @@ -9778,6 +10612,18 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -13192,16 +14038,41 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, "node_modules/lodash.isempty": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==" + }, "node_modules/lodash.isnil": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", @@ -13212,6 +14083,11 @@ "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", "integrity": "sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA==" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==" + }, "node_modules/lodash.join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.join/-/lodash.join-4.0.1.tgz", @@ -13238,6 +14114,11 @@ "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==" }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -17412,6 +18293,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zlib": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", + "integrity": "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w==", + "hasInstallScript": true, + "engines": { + "node": ">=0.2.0" + } } } } diff --git a/package.json b/package.json index 2d7bd94..271d6ce 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@aws-sdk/client-cloudformation": "^3.315.0", "@aws-sdk/client-cloudwatch": "^3.301.0", "@aws-sdk/client-cloudwatch-logs": "^3.296.0", + "@aws-sdk/client-cost-and-usage-report-service": "^3.342.0", "@aws-sdk/client-cost-explorer": "^3.319.0", "@aws-sdk/client-ec2": "^3.303.0", "@aws-sdk/client-ecs": "^3.315.0", @@ -85,6 +86,7 @@ "@tinystacks/ops-model": "^0.2.0", "cached": "^6.1.0", "dayjs": "^1.11.7", + "fast-csv": "^4.3.6", "http-errors": "^2.0.0", "lodash.chunk": "^4.2.0", "lodash.get": "^4.4.2", @@ -93,6 +95,7 @@ "lodash.startcase": "^4.4.0", "react": "^18.2.0", "react-icons": "^4.8.0", - "simple-statistics": "^7.8.3" + "simple-statistics": "^7.8.3", + "zlib": "^1.0.5" } } diff --git a/src/aws-utilization-provider.ts b/src/aws-utilization-provider.ts index f45f4c5..734c8bb 100644 --- a/src/aws-utilization-provider.ts +++ b/src/aws-utilization-provider.ts @@ -2,9 +2,22 @@ import cached from 'cached'; import { AwsCredentialsProvider } from '@tinystacks/ops-aws-core-widgets'; import { BaseProvider } from '@tinystacks/ops-core'; import { Provider } from '@tinystacks/ops-model'; -import { AwsResourceType, AwsServiceOverrides, AwsUtilizationOverrides, Utilization } from './types/types.js'; +import { + AwsResourceType, + AwsServiceOverrides, + AwsUtilizationOverrides, + CostPerResourceReport, + Utilization +} from './types/types.js'; import { AwsServiceUtilization } from './service-utilizations/aws-service-utilization.js'; import { AwsServiceUtilizationFactory } from './service-utilizations/aws-service-utilization-factory.js'; +import { ListObjectsV2CommandOutput, S3 } from '@aws-sdk/client-s3'; +import { CostAndUsageReportService, ReportDefinition } from '@aws-sdk/client-cost-and-usage-report-service'; +import { createUnzip } from 'zlib'; +import { Readable } from 'stream'; +import { getArnOrResourceId, parseStreamSync } from './utils/utils.js'; +import isEmpty from 'lodash.isempty'; +import { STS } from '@aws-sdk/client-sts'; const cache = cached>('utilization-cache', { backend: { @@ -130,6 +143,100 @@ class AwsUtilizationProvider extends BaseProvider { return this.utilization; } + + // if describeReportDefinitions is empty, we need to tell users to create a report + // if list objects is empty we need to tell users a report has not been generated yet + // do we want to show only resources with the accountId associated with the provided credentials? + async getCostPerResource (awsCredentialsProvider: AwsCredentialsProvider, region: string) { + const report: CostPerResourceReport = { + resourceCosts: {}, + hasCostReportDefinition: false, + hasCostReport: false + }; + const credentials = await awsCredentialsProvider.getCredentials(); + const costClient = new CostAndUsageReportService({ + credentials, + region + }); + const stsClient = new STS({ + credentials, + region + }); + + const accountId = (await stsClient.getCallerIdentity({})).Account; + + const reportsRes = await costClient.describeReportDefinitions({}); + if (isEmpty(reportsRes.ReportDefinitions)) return report; + else report.hasCostReportDefinition = true; + // prefer monthly report + let reportDefinition: ReportDefinition; + reportDefinition = reportsRes.ReportDefinitions.find((def) => { + return def.AdditionalSchemaElements.includes('RESOURCES') && def.TimeUnit === 'MONTHLY'; + }); + if (!reportDefinition) { + reportDefinition = reportsRes.ReportDefinitions.find((def) => { + return def.AdditionalSchemaElements.includes('RESOURCES'); + }); + } + + const s3Region = reportDefinition.S3Region; + const bucket = reportDefinition.S3Bucket; + const s3Client = new S3({ + credentials, + region: s3Region + }); + let listObjectsRes: ListObjectsV2CommandOutput; + do { + listObjectsRes = await s3Client.listObjectsV2({ + Bucket: bucket, + Prefix: reportsRes.ReportDefinitions[0].S3Prefix, + ContinuationToken: listObjectsRes?.NextContinuationToken + }); + } while (listObjectsRes?.NextContinuationToken); + + if (isEmpty(listObjectsRes?.Contents)) return report; + else report.hasCostReport = true; + + // ordered by serialized dates so most recent report should be last + const reportObjects = listObjectsRes.Contents.filter((reportObject) => { + return reportObject.Key.endsWith('.csv.gz'); + }); + const s3ReportObject = reportObjects.at(-1); + const key = s3ReportObject.Key; + const res = await s3Client.getObject({ + Bucket: bucket, + Key: key + }); + const costReportZip = res.Body as Readable; + const costReport = costReportZip.pipe(createUnzip()); + /* + * usage accountId index 9 + * resourceId index 17 + * blended cost index 25 + * region index 104 + */ + let factor = 1; + if (reportDefinition.TimeUnit === 'DAILY') factor = 30; + else if (reportDefinition.TimeUnit === 'HOURLY') factor = 24 * 30; + await parseStreamSync(costReport, { headers: true }, (row) => { + const resourceId = getArnOrResourceId( + row['lineItem/ProductCode'], + row['lineItem/ResourceId'], + region, + accountId + ); + const blendedCost = row['lineItem/BlendedCost']; + if (resourceId && row['product/region'] === region && row['lineItem/UsageAccountId'] === accountId) { + if (resourceId in report.resourceCosts) { + report.resourceCosts[resourceId] += (Number(blendedCost) * factor); + } else { + report.resourceCosts[resourceId] = (Number(blendedCost) * factor); + } + } + }); + + return report; + } } export { diff --git a/src/widgets/utilization-recommendations-ui/confirm-recommendations.tsx b/src/components/utilization-recommendations-ui/confirm-recommendations.tsx similarity index 100% rename from src/widgets/utilization-recommendations-ui/confirm-recommendations.tsx rename to src/components/utilization-recommendations-ui/confirm-recommendations.tsx diff --git a/src/widgets/utilization-recommendations-ui/confirm-single-recommendation.tsx b/src/components/utilization-recommendations-ui/confirm-single-recommendation.tsx similarity index 100% rename from src/widgets/utilization-recommendations-ui/confirm-single-recommendation.tsx rename to src/components/utilization-recommendations-ui/confirm-single-recommendation.tsx diff --git a/src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx b/src/components/utilization-recommendations-ui/recommendations-action-summary.tsx similarity index 100% rename from src/widgets/utilization-recommendations-ui/recommendations-action-summary.tsx rename to src/components/utilization-recommendations-ui/recommendations-action-summary.tsx diff --git a/src/widgets/utilization-recommendations-ui/recommendations-table.tsx b/src/components/utilization-recommendations-ui/recommendations-table.tsx similarity index 100% rename from src/widgets/utilization-recommendations-ui/recommendations-table.tsx rename to src/components/utilization-recommendations-ui/recommendations-table.tsx diff --git a/src/widgets/utilization-recommendations-ui/service-table-row.tsx b/src/components/utilization-recommendations-ui/service-table-row.tsx similarity index 100% rename from src/widgets/utilization-recommendations-ui/service-table-row.tsx rename to src/components/utilization-recommendations-ui/service-table-row.tsx diff --git a/src/widgets/utilization-recommendations-ui/utilization-recommendations-ui.tsx b/src/components/utilization-recommendations-ui/utilization-recommendations-ui.tsx similarity index 100% rename from src/widgets/utilization-recommendations-ui/utilization-recommendations-ui.tsx rename to src/components/utilization-recommendations-ui/utilization-recommendations-ui.tsx diff --git a/src/types/types.ts b/src/types/types.ts index 980fcb1..b251882 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -123,4 +123,10 @@ export type AwsResourceType = 'Account' | 'NatGateway' | 'S3Bucket' | 'EbsVolume' | - 'RdsInstance'; \ No newline at end of file + 'RdsInstance'; + +export type CostPerResourceReport = { + resourceCosts: { [ resourceId: string ]: number }; + hasCostReportDefinition: boolean, + hasCostReport: boolean +}; \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 12b340c..81c625e 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -3,8 +3,9 @@ import { STS } from '@aws-sdk/client-sts'; import { AwsCredentialsProvider } from '@tinystacks/ops-aws-core-widgets'; import { BaseProvider } from '@tinystacks/ops-core'; import isEmpty from 'lodash.isempty'; +import { ParserOptionsArgs, parseStream } from 'fast-csv'; import { AwsUtilizationProvider } from '../aws-utilization-provider.js'; - +import { Arns } from '../types/constants.js'; export function getAwsUtilizationProvider (providers?: BaseProvider[]): AwsUtilizationProvider { @@ -140,4 +141,40 @@ export function round (val: number, decimalPlace: number) { export function getHourlyCost (monthlyCost: number) { return (monthlyCost / 30) / 24; +} + +export function parseStreamSync ( + stream: NodeJS.ReadableStream, options: ParserOptionsArgs, rowProcessor: (row: any) => any +) { + return new Promise((resolve, reject) => { + const data: any[] = []; + + parseStream(stream, options) + .on('error', reject) + .on('data', (row) => { + const obj = rowProcessor(row); + if (obj) data.push(obj); + }) + .on('end', () => { + resolve(data); + }); + }); +} + +export function getArnOrResourceId (awsService: string, resourceId: string, region: string, accountId: string) { + if (resourceId.startsWith('arn')) { + return resourceId; + } + + if (awsService === 'AmazonS3') { + return Arns.S3(resourceId); + } + + if (resourceId.startsWith('i-')) { + return Arns.Ec2(region, accountId, resourceId); + } else if (resourceId.startsWith('vol-')) { + return Arns.Ebs(region, accountId, resourceId); + } + + return resourceId; } \ No newline at end of file diff --git a/src/widgets/aws-cost-by-service.tsx b/src/widgets/aws-cost-by-service.tsx new file mode 100644 index 0000000..0190df2 --- /dev/null +++ b/src/widgets/aws-cost-by-service.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { BaseProvider, BaseWidget } from '@tinystacks/ops-core'; +import { getAwsCredentialsProvider, getAwsUtilizationProvider } from '../utils/utils.js'; +import { Widget } from '@tinystacks/ops-model'; +import { CostPerResourceReport } from '../types/types.js'; +import { CostExplorer } from '@aws-sdk/client-cost-explorer'; +import dayjs from 'dayjs'; + + +// should the time period be from start of the month to now? Instead of a month ago + +type AwsCostByServiceType = Widget & { + costPerResourceReport?: CostPerResourceReport; + region?: string; +} + +export class AwsCostByService extends BaseWidget { + costPerResourceReport?: CostPerResourceReport; + region?: string; + + constructor (props: AwsCostByServiceType) { + super(props); + this.costPerResourceReport = props.costPerResourceReport; + this.region = props.region || 'us-east-1'; + } + + static fromJson (props: AwsCostByServiceType) { + return new AwsCostByService(props); + } + + toJson () { + return { + ...super.toJson(), + costPerResourceReport: this.costPerResourceReport, + region: this.region + }; + } + + async getData (providers?: BaseProvider[], _overrides?: any) { + const awsCredentialsProvider = getAwsCredentialsProvider(providers); + const utilProvider = getAwsUtilizationProvider(providers); + const costExplorerClient = new CostExplorer({ + credentials: await awsCredentialsProvider.getCredentials(), + region: this.region + }); + + const now = dayjs(); + // const startTime = now.subtract(1, 'month'); + console.log(dayjs().startOf('month').format('YYYY-MM-DD')); + const res = await costExplorerClient.getCostAndUsage({ + TimePeriod: { + Start: dayjs().startOf('month').format('YYYY-MM-DD'), + End: now.format('YYYY-MM-DD') + }, + Granularity: 'DAILY', + Metrics: ['UnblendedCost'], + GroupBy: [ + { + Type: 'DIMENSION', + Key: 'SERVICE' + } + ] + }); + console.log(res); + + this.costPerResourceReport = await utilProvider.getCostPerResource(awsCredentialsProvider, this.region); + + + } + + render () { + return <>; + } +} \ No newline at end of file diff --git a/src/widgets/aws-utilization-recommendations.tsx b/src/widgets/aws-utilization-recommendations.tsx index e80c163..de43943 100644 --- a/src/widgets/aws-utilization-recommendations.tsx +++ b/src/widgets/aws-utilization-recommendations.tsx @@ -5,7 +5,9 @@ import { RecommendationsOverrides, UtilizationRecommendationsWidget } from '../types/utilization-recommendations-types.js'; -import { UtilizationRecommendationsUi } from './utilization-recommendations-ui/utilization-recommendations-ui.js'; +import { + UtilizationRecommendationsUi +} from '../components/utilization-recommendations-ui/utilization-recommendations-ui.js'; import { filterUtilizationForActionType } from '../utils/utilization.js'; import get from 'lodash.get'; From f993631f68b92369d2b083b360634f2d5945bbfa Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Fri, 9 Jun 2023 16:22:16 -0700 Subject: [PATCH 08/12] cost by service widget --- package-lock.json | 46 +++++ package.json | 2 + src/aws-utilization-provider.ts | 162 ++++++++++-------- .../cost-by-service/report-breakdown.tsx | 69 ++++++++ .../cost-by-service/resources-table.tsx | 77 +++++++++ .../cost-by-service/service-row.tsx | 72 ++++++++ src/index.ts | 4 +- .../aws-ecs-utilization.ts | 1 - src/types/cost-and-usage-types.ts | 92 ++++++++++ src/types/types.ts | 8 +- src/utils/cost-and-usage-utils.ts | 141 +++++++++++++++ src/utils/utils.ts | 20 --- src/widgets/aws-cost-by-service.tsx | 59 +++---- 13 files changed, 618 insertions(+), 135 deletions(-) create mode 100644 src/components/cost-by-service/report-breakdown.tsx create mode 100644 src/components/cost-by-service/resources-table.tsx create mode 100644 src/components/cost-by-service/service-row.tsx create mode 100644 src/types/cost-and-usage-types.ts create mode 100644 src/utils/cost-and-usage-utils.ts diff --git a/package-lock.json b/package-lock.json index 41f7fa6..fae091e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,11 @@ "@aws-sdk/client-sts": "^3.312.0", "@chakra-ui/icons": "^2.0.19", "@chakra-ui/react": "^2.5.5", + "@tanstack/react-table": "^8.9.2", "@tinystacks/ops-aws-core-widgets": "^0.0.5", "@tinystacks/ops-core": "^0.0.3", "@tinystacks/ops-model": "^0.2.0", + "browserify-zlib": "^0.2.0", "cached": "^6.1.0", "dayjs": "^1.11.7", "fast-csv": "^4.3.6", @@ -7357,6 +7359,37 @@ "node": ">=14.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.9.2.tgz", + "integrity": "sha512-Irvw4wqVF9hhuYzmNrlae4IKdlmgSyoRWnApSLebvYzqHoi5tEsYzBj6YPd0hX78aB/L+4w/jgK2eBQVpGfThQ==", + "dependencies": { + "@tanstack/table-core": "8.9.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.9.2.tgz", + "integrity": "sha512-ajc0OF+karBAdaSz7OK09rCoAHB1XI1+wEhu+tDNMPc+XcO+dTlXXN/Vc0a8vym4kElvEjXEDd9c8Zfgt4bekA==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tinystacks/ops-aws-core-widgets": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@tinystacks/ops-aws-core-widgets/-/ops-aws-core-widgets-0.0.5.tgz", @@ -8873,6 +8906,14 @@ "node": ">=8" } }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.21.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", @@ -15275,6 +15316,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index 271d6ce..84c70d1 100644 --- a/package.json +++ b/package.json @@ -81,9 +81,11 @@ "@aws-sdk/client-sts": "^3.312.0", "@chakra-ui/icons": "^2.0.19", "@chakra-ui/react": "^2.5.5", + "@tanstack/react-table": "^8.9.2", "@tinystacks/ops-aws-core-widgets": "^0.0.5", "@tinystacks/ops-core": "^0.0.3", "@tinystacks/ops-model": "^0.2.0", + "browserify-zlib": "^0.2.0", "cached": "^6.1.0", "dayjs": "^1.11.7", "fast-csv": "^4.3.6", diff --git a/src/aws-utilization-provider.ts b/src/aws-utilization-provider.ts index 734c8bb..abcfc74 100644 --- a/src/aws-utilization-provider.ts +++ b/src/aws-utilization-provider.ts @@ -6,18 +6,28 @@ import { AwsResourceType, AwsServiceOverrides, AwsUtilizationOverrides, - CostPerResourceReport, Utilization } from './types/types.js'; import { AwsServiceUtilization } from './service-utilizations/aws-service-utilization.js'; import { AwsServiceUtilizationFactory } from './service-utilizations/aws-service-utilization-factory.js'; -import { ListObjectsV2CommandOutput, S3 } from '@aws-sdk/client-s3'; -import { CostAndUsageReportService, ReportDefinition } from '@aws-sdk/client-cost-and-usage-report-service'; -import { createUnzip } from 'zlib'; -import { Readable } from 'stream'; -import { getArnOrResourceId, parseStreamSync } from './utils/utils.js'; -import isEmpty from 'lodash.isempty'; +import { CostAndUsageReportService } from '@aws-sdk/client-cost-and-usage-report-service'; +import { parseStreamSync } from './utils/utils.js'; import { STS } from '@aws-sdk/client-sts'; +import { CostReport } from './types/cost-and-usage-types.js'; +import { + auditCostReport, + fillServiceCosts, + getArnOrResourceId, + getReadableResourceReportFromS3, + getReportDefinition, + getServiceForResource +} from './utils/cost-and-usage-utils.js'; +import { CostExplorer } from '@aws-sdk/client-cost-explorer'; +import { S3 } from '@aws-sdk/client-s3'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { createUnzip } from 'browserify-zlib'; +import dayjs from 'dayjs'; const cache = cached>('utilization-cache', { backend: { @@ -125,7 +135,6 @@ class AwsUtilizationProvider extends BaseProvider { async getUtilization ( credentialsProvider: AwsCredentialsProvider, region: string, overrides: AwsUtilizationOverrides = {} ) { - console.log(this.utilization); for (const service of this.services) { const serviceOverrides = overrides[service]; if (serviceOverrides?.forceRefesh) { @@ -147,95 +156,112 @@ class AwsUtilizationProvider extends BaseProvider { // if describeReportDefinitions is empty, we need to tell users to create a report // if list objects is empty we need to tell users a report has not been generated yet // do we want to show only resources with the accountId associated with the provided credentials? - async getCostPerResource (awsCredentialsProvider: AwsCredentialsProvider, region: string) { - const report: CostPerResourceReport = { - resourceCosts: {}, + // async getCostPerResource (awsCredentialsProvider: AwsCredentialsProvider, region: string) { + + // TODO: continue to audit that productName matches service returned by getCostAndUsage + async getCostReport (awsCredentialsProvider: AwsCredentialsProvider, region: string) { + // const depMap = { + // zlib: 'zlib' + // }; + // const { createUnzip } = await import(depMap.zlib); + + const costReport: CostReport = { + report: {}, hasCostReportDefinition: false, hasCostReport: false }; const credentials = await awsCredentialsProvider.getCredentials(); - const costClient = new CostAndUsageReportService({ + const curClient = new CostAndUsageReportService({ credentials, - region + region: 'us-east-1' }); const stsClient = new STS({ credentials, region }); + const costExplorerClient = new CostExplorer({ + credentials, + region + }); const accountId = (await stsClient.getCallerIdentity({})).Account; - const reportsRes = await costClient.describeReportDefinitions({}); - if (isEmpty(reportsRes.ReportDefinitions)) return report; - else report.hasCostReportDefinition = true; - // prefer monthly report - let reportDefinition: ReportDefinition; - reportDefinition = reportsRes.ReportDefinitions.find((def) => { - return def.AdditionalSchemaElements.includes('RESOURCES') && def.TimeUnit === 'MONTHLY'; - }); - if (!reportDefinition) { - reportDefinition = reportsRes.ReportDefinitions.find((def) => { - return def.AdditionalSchemaElements.includes('RESOURCES'); - }); + const now = dayjs(); + + await fillServiceCosts(costExplorerClient, costReport, accountId, region, now); + const costExplorerServices = new Set(Object.keys(costReport.report)); + + const reportDefinition = await getReportDefinition(curClient); + if (!reportDefinition) return costReport; + else costReport.hasCostReportDefinition = true; + + const { + S3Region, + S3Bucket, + S3Prefix, + TimeUnit + } = reportDefinition; + + // DAILY + let toMonthlyFactor = 30; + if (TimeUnit === 'HOURLY') { + toMonthlyFactor = 24 * 30; + } else if (TimeUnit === 'MONTHLY') { + const mtdDays = now.diff(now.startOf('month'), 'days'); + toMonthlyFactor = 30 / mtdDays; } - const s3Region = reportDefinition.S3Region; - const bucket = reportDefinition.S3Bucket; const s3Client = new S3({ credentials, - region: s3Region - }); - let listObjectsRes: ListObjectsV2CommandOutput; - do { - listObjectsRes = await s3Client.listObjectsV2({ - Bucket: bucket, - Prefix: reportsRes.ReportDefinitions[0].S3Prefix, - ContinuationToken: listObjectsRes?.NextContinuationToken - }); - } while (listObjectsRes?.NextContinuationToken); - - if (isEmpty(listObjectsRes?.Contents)) return report; - else report.hasCostReport = true; - - // ordered by serialized dates so most recent report should be last - const reportObjects = listObjectsRes.Contents.filter((reportObject) => { - return reportObject.Key.endsWith('.csv.gz'); + region: S3Region }); - const s3ReportObject = reportObjects.at(-1); - const key = s3ReportObject.Key; - const res = await s3Client.getObject({ - Bucket: bucket, - Key: key - }); - const costReportZip = res.Body as Readable; - const costReport = costReportZip.pipe(createUnzip()); - /* - * usage accountId index 9 - * resourceId index 17 - * blended cost index 25 - * region index 104 - */ - let factor = 1; - if (reportDefinition.TimeUnit === 'DAILY') factor = 30; - else if (reportDefinition.TimeUnit === 'HOURLY') factor = 24 * 30; - await parseStreamSync(costReport, { headers: true }, (row) => { + const resourceReportZip = await getReadableResourceReportFromS3(s3Client, S3Bucket, S3Prefix); + if (!resourceReportZip) return costReport; + else costReport.hasCostReport = true; + + const resourceReport = resourceReportZip.pipe(createUnzip()); + await parseStreamSync(resourceReport, { headers: true }, (row) => { + // if (row['lineItem/ResourceId'].includes('secretsmanager')) { + // console.log(row['lineItem/UsageType']) + // } const resourceId = getArnOrResourceId( row['lineItem/ProductCode'], row['lineItem/ResourceId'], region, accountId ); - const blendedCost = row['lineItem/BlendedCost']; - if (resourceId && row['product/region'] === region && row['lineItem/UsageAccountId'] === accountId) { - if (resourceId in report.resourceCosts) { - report.resourceCosts[resourceId] += (Number(blendedCost) * factor); + if ( + resourceId && + (row['product/region'] === region || row['product/region'] === 'global') && + row['lineItem/UsageAccountId'] === accountId + ) { + const service = getServiceForResource(resourceId, row['product/ProductName']); + const cost = + row['reservation/EffectiveCost'] ? + Number(row['reservation/EffectiveCost']) + Number(row['lineItem/BlendedCost']) : + Number(row['lineItem/BlendedCost']); + const monthlyCostEstimate = cost * toMonthlyFactor; + if (service in costReport.report) { + if (resourceId in costReport.report[service].resourceCosts) { + costReport.report[service].resourceCosts[resourceId] += monthlyCostEstimate; + } else { + costReport.report[service].resourceCosts[resourceId] = monthlyCostEstimate; + } + if (!costExplorerServices.has(service)) { + costReport.report[service].serviceCost += monthlyCostEstimate; + } } else { - report.resourceCosts[resourceId] = (Number(blendedCost) * factor); + costReport.report[service] = { + serviceCost: 0, + resourceCosts: {} + }; } } }); - return report; + auditCostReport(costReport); + + return costReport; } } diff --git a/src/components/cost-by-service/report-breakdown.tsx b/src/components/cost-by-service/report-breakdown.tsx new file mode 100644 index 0000000..925e0c9 --- /dev/null +++ b/src/components/cost-by-service/report-breakdown.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Heading, Stack, Table, TableContainer, Tbody, Th, Thead } from '@chakra-ui/react'; +import isEmpty from 'lodash.isempty'; +import { CostReport } from '../../types/cost-and-usage-types.js'; +import ServiceRow from './service-row.js'; + +export default function ReportBreakdown ( + props: { costReport: CostReport } +) { + const { costReport } = props; + + function serviceRow (service: string) { + return ( + + ); + } + + function reportTable () { + // const [ sorting, setSorting ] = React.useState([]); + // const table = useReactTable({ + + // }) + + if (isEmpty(costReport.report) && costReport.hasCostReportDefinition) { + return ( + + No cost report available! + If you have recently created an AWS Cost and Usage Report, it will be available in 24 hours. + + ); + } else if (!costReport.hasCostReportDefinition) { + return ( + + You need to set up an AWS Cost and Usage Report under Billing in the AWS Console. + It only takes a couple minutes, and a report will be available in 24 hours! + + ); + } + return ( + + + All cost values are monthly estimates based on current usage costs provided by AWS + + + + + + + + + + {Object.keys(costReport.report).sort().map(serviceRow)} + +
Service# ResourcesCost/Mo +
+
+
+ ); + } + + return ( + + {reportTable()} + + ); +} \ No newline at end of file diff --git a/src/components/cost-by-service/resources-table.tsx b/src/components/cost-by-service/resources-table.tsx new file mode 100644 index 0000000..7a86906 --- /dev/null +++ b/src/components/cost-by-service/resources-table.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { + Stack, + TableContainer, + Table, + Thead, + Tr, + Th, + Tbody, + Td, + Tooltip, + Box +} from '@chakra-ui/react'; + +type ResourcesTableProps = { + service: string; + resourceCosts: { [resourceId: string]: number }; +}; + +export default function ResourcesTable (props: ResourcesTableProps) { + const { service, resourceCosts } = props; + + const usd = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }); + + return ( + + + + + + + + + + + {Object.keys(resourceCosts) + .sort() + .map((resourceId) => { + return ( + + + + + ); + })} + +
Resource IDCost/Mo +
+ + + {resourceId} + + + {usd.format(resourceCosts[resourceId])}
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/cost-by-service/service-row.tsx b/src/components/cost-by-service/service-row.tsx new file mode 100644 index 0000000..a43d2b2 --- /dev/null +++ b/src/components/cost-by-service/service-row.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ChevronUpIcon, ChevronDownIcon, InfoIcon } from '@chakra-ui/icons'; +import { useDisclosure, Tr, Td, Button, Box, Tooltip } from '@chakra-ui/react'; +import { ServiceInformation } from '../../types/cost-and-usage-types.js'; +import isEmpty from 'lodash.isempty'; +import ResourcesTable from './resources-table.js'; + +type ServiceRowProps = { + service: string; + serviceInformation: ServiceInformation; +}; + +export default function ServiceRow (props: ServiceRowProps) { + const { + service, + serviceInformation: { serviceCost, resourceCosts, details } + } = props; + const { isOpen, onToggle } = useDisclosure(); + + const usd = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }); + + return ( + + + + {details ? ( + + + {service} + {} + + + ) : ( + service + )} + + {Object.keys(resourceCosts).length} + {usd.format(serviceCost)} + + + + + + + {isEmpty(resourceCosts) ? ( + 'No resources found for this service' + ) : ( + + )} + + + + ); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8e385af..bcf8b43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ import { AwsUtilization } from './widgets/aws-utilization.js'; import { AwsUtilizationProvider } from './aws-utilization-provider.js'; import { AwsUtilizationRecommendations } from './widgets/aws-utilization-recommendations.js'; +import { AwsCostByService } from './widgets/aws-cost-by-service.js'; export { AwsUtilization, AwsUtilizationProvider, - AwsUtilizationRecommendations + AwsUtilizationRecommendations, + AwsCostByService }; \ No newline at end of file diff --git a/src/service-utilizations/aws-ecs-utilization.ts b/src/service-utilizations/aws-ecs-utilization.ts index 78b09c1..95849f5 100644 --- a/src/service-utilizations/aws-ecs-utilization.ts +++ b/src/service-utilizations/aws-ecs-utilization.ts @@ -948,7 +948,6 @@ export class AwsEcsUtilization extends AwsServiceUtilization { + const cost = Number(group.Metrics['UnblendedCost'].Amount) * (30 / mtdDays); + costReport.report[group.Keys[0]] = { + serviceCost: cost, + resourceCosts: {} + }; + }); +} + +export async function getReportDefinition (curClient: CostAndUsageReportService): Promise { + const reportsRes = await curClient.describeReportDefinitions({}); + const reportDefinition = reportsRes.ReportDefinitions.find((def) => { + return def.AdditionalSchemaElements.includes('RESOURCES'); + }); + + return reportDefinition; +} + +export async function getReadableResourceReportFromS3 (s3Client: S3, bucket: string, prefix: string) { + let listObjectsRes: ListObjectsV2CommandOutput; + do { + listObjectsRes = await s3Client.listObjectsV2({ + Bucket: bucket, + Prefix: prefix, + ContinuationToken: listObjectsRes?.NextContinuationToken + }); + } while (listObjectsRes?.NextContinuationToken); + + if (isEmpty(listObjectsRes?.Contents)) return undefined; + + // ordered by serialized dates so most recent report should be last + const reportObjects = listObjectsRes.Contents.filter((reportObject) => { + return reportObject.Key.endsWith('.csv.gz'); + }); + const s3ReportObject = reportObjects.at(-1); + const key = s3ReportObject.Key; + const res = await s3Client.getObject({ + Bucket: bucket, + Key: key + }); + const costReportZip = res.Body as Readable; + + return costReportZip; +} + +export function getServiceForResource (resourceId: string, productName: string) { + let parsedService = ''; + if (productName === 'Elastic Load Balancing') { + parsedService = 'Amazon Elastic Load Balancing'; + } else if (resourceId.startsWith('arn')) { + const parts = resourceId.split(':'); + if (parts[2] === 'ec2') { + if (parts[5].startsWith('natgateway')) { + parsedService = 'EC2 - Other'; + } else if (parts[5].startsWith('volume')) { + parsedService = 'EC2 - Other'; + } else { + parsedService = 'Amazon Elastic Compute Cloud - Compute'; + } + } + } + + return parsedService || productName || 'Other'; +} + +export function auditCostReport (costReport: CostReport) { + if ('AWS Secrets Manager' in costReport.report) { + // 0.40/month secret fee is not included in cost report + Object.keys(costReport.report['AWS Secrets Manager'].resourceCosts).forEach((resourceId) => { + costReport.report['AWS Secrets Manager'].resourceCosts[resourceId] += 0.40; + }); + } + + if ('Amazon Elastic Compute Cloud - Compute' in costReport.report) { + costReport.report['Amazon Elastic Compute Cloud - Compute'].details = 'Includes IP and data transfer costs'; + } + + if ('AmazonCloudWatch' in costReport.report) { + costReport.report['AmazonCloudWatch'].details = + 'Includes CloudWatch metrics costs for resources not displayed here'; + } +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 81c625e..888d048 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -5,8 +5,6 @@ import { BaseProvider } from '@tinystacks/ops-core'; import isEmpty from 'lodash.isempty'; import { ParserOptionsArgs, parseStream } from 'fast-csv'; import { AwsUtilizationProvider } from '../aws-utilization-provider.js'; -import { Arns } from '../types/constants.js'; - export function getAwsUtilizationProvider (providers?: BaseProvider[]): AwsUtilizationProvider { if (!providers || isEmpty(providers)) { @@ -159,22 +157,4 @@ export function parseStreamSync ( resolve(data); }); }); -} - -export function getArnOrResourceId (awsService: string, resourceId: string, region: string, accountId: string) { - if (resourceId.startsWith('arn')) { - return resourceId; - } - - if (awsService === 'AmazonS3') { - return Arns.S3(resourceId); - } - - if (resourceId.startsWith('i-')) { - return Arns.Ec2(region, accountId, resourceId); - } else if (resourceId.startsWith('vol-')) { - return Arns.Ebs(region, accountId, resourceId); - } - - return resourceId; } \ No newline at end of file diff --git a/src/widgets/aws-cost-by-service.tsx b/src/widgets/aws-cost-by-service.tsx index 0190df2..1494818 100644 --- a/src/widgets/aws-cost-by-service.tsx +++ b/src/widgets/aws-cost-by-service.tsx @@ -1,26 +1,29 @@ import React from 'react'; import { BaseProvider, BaseWidget } from '@tinystacks/ops-core'; +import { Stack } from '@chakra-ui/react'; import { getAwsCredentialsProvider, getAwsUtilizationProvider } from '../utils/utils.js'; import { Widget } from '@tinystacks/ops-model'; -import { CostPerResourceReport } from '../types/types.js'; -import { CostExplorer } from '@aws-sdk/client-cost-explorer'; -import dayjs from 'dayjs'; - +import { CostReport } from '../types/cost-and-usage-types.js'; +import ReportBreakdown from '../components/cost-by-service/report-breakdown.js'; // should the time period be from start of the month to now? Instead of a month ago type AwsCostByServiceType = Widget & { - costPerResourceReport?: CostPerResourceReport; - region?: string; + costReport?: CostReport; + region: string; } export class AwsCostByService extends BaseWidget { - costPerResourceReport?: CostPerResourceReport; - region?: string; + costReport?: CostReport; + region: string; constructor (props: AwsCostByServiceType) { super(props); - this.costPerResourceReport = props.costPerResourceReport; + this.costReport = props.costReport || { + report: {}, + hasCostReport: true, + hasCostReportDefinition: true + }; this.region = props.region || 'us-east-1'; } @@ -31,7 +34,7 @@ export class AwsCostByService extends BaseWidget { toJson () { return { ...super.toJson(), - costPerResourceReport: this.costPerResourceReport, + costReport: this.costReport, region: this.region }; } @@ -39,36 +42,16 @@ export class AwsCostByService extends BaseWidget { async getData (providers?: BaseProvider[], _overrides?: any) { const awsCredentialsProvider = getAwsCredentialsProvider(providers); const utilProvider = getAwsUtilizationProvider(providers); - const costExplorerClient = new CostExplorer({ - credentials: await awsCredentialsProvider.getCredentials(), - region: this.region - }); - - const now = dayjs(); - // const startTime = now.subtract(1, 'month'); - console.log(dayjs().startOf('month').format('YYYY-MM-DD')); - const res = await costExplorerClient.getCostAndUsage({ - TimePeriod: { - Start: dayjs().startOf('month').format('YYYY-MM-DD'), - End: now.format('YYYY-MM-DD') - }, - Granularity: 'DAILY', - Metrics: ['UnblendedCost'], - GroupBy: [ - { - Type: 'DIMENSION', - Key: 'SERVICE' - } - ] - }); - console.log(res); - - this.costPerResourceReport = await utilProvider.getCostPerResource(awsCredentialsProvider, this.region); - + this.costReport = await utilProvider.getCostReport(awsCredentialsProvider, this.region); + console.log(JSON.stringify(this.costReport, null, 2)); } - render () { - return <>; + render (_children: any, _overrides: any) { + return ( + + + + ); } } \ No newline at end of file From fd878b30477db5108271bcda414e049d8014cfc7 Mon Sep 17 00:00:00 2001 From: Akash Kedia Date: Fri, 16 Jun 2023 11:39:50 -0700 Subject: [PATCH 09/12] table header sorting --- package-lock.json | 837 +++++++++++++++++- package.json | 5 +- src/aws-utilization-provider.ts | 25 +- .../cost-by-service/report-breakdown.tsx | 123 ++- .../cost-by-service/resources-table.tsx | 73 +- .../cost-by-service/service-row.tsx | 30 +- .../cost-by-service/table-sorting.tsx | 39 + src/types/cost-and-usage-types.ts | 16 +- src/widgets/aws-cost-by-service.tsx | 59 +- 9 files changed, 1052 insertions(+), 155 deletions(-) create mode 100644 src/components/cost-by-service/table-sorting.tsx diff --git a/package-lock.json b/package-lock.json index fae091e..d3ba81e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,13 +20,13 @@ "@aws-sdk/client-ec2": "^3.303.0", "@aws-sdk/client-ecs": "^3.315.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.315.0", + "@aws-sdk/client-organizations": "^3.352.0", "@aws-sdk/client-pricing": "^3.306.0", "@aws-sdk/client-rds": "^3.312.0", "@aws-sdk/client-s3": "^3.301.0", "@aws-sdk/client-sts": "^3.312.0", "@chakra-ui/icons": "^2.0.19", "@chakra-ui/react": "^2.5.5", - "@tanstack/react-table": "^8.9.2", "@tinystacks/ops-aws-core-widgets": "^0.0.5", "@tinystacks/ops-core": "^0.0.3", "@tinystacks/ops-model": "^0.2.0", @@ -42,8 +42,7 @@ "lodash.startcase": "^4.4.0", "react": "^18.2.0", "react-icons": "^4.8.0", - "simple-statistics": "^7.8.3", - "zlib": "^1.0.5" + "simple-statistics": "^7.8.3" }, "devDependencies": { "@babel/cli": "^7.20.7", @@ -1823,6 +1822,798 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-organizations": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-organizations/-/client-organizations-3.352.0.tgz", + "integrity": "sha512-2KRjgfVN3JCTBj5TbSP0RTzfIxIihASZnX8/3shOtIAxT4qExzGtl7FahtPXGetzpvOAjwsxhwEDzPS8+B1ABw==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.352.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-node": "3.352.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.352.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.350.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.352.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/abort-controller": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.347.0.tgz", + "integrity": "sha512-P/2qE6ntYEmYG4Ez535nJWZbXqgbkJx8CMz7ChEuEg3Gp3dvVYEKg+iEUEvlqQ2U5dWP5J3ehw5po9t86IsVPQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/client-sso": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.352.0.tgz", + "integrity": "sha512-oeO36rvRvYbUlsgzYtLI2/BPwXdUK4KtYw+OFmirYeONUyX5uYx8kWXD66r3oXViIYMqhyHKN3fhkiFmFcVluQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.352.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.350.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.352.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.352.0.tgz", + "integrity": "sha512-PQdp0KOr478CaJNohASTgtt03W8Y/qINwsalLNguK01tWIGzellg2N3bA+IdyYXU8Oz3+Ab1oIJMKkUxtuNiGg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.352.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.350.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.352.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/client-sts": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.352.0.tgz", + "integrity": "sha512-Lt7uSdwgOrwYx8S6Bhz76ewOeoJNFiPD+Q7v8S/mJK8T7HUE/houjomXC3UnFaJjcecjWv273zEqV67FgP5l5g==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-node": "3.352.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-sdk-sts": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.352.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.350.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.352.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "fast-xml-parser": "4.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/config-resolver": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.347.0.tgz", + "integrity": "sha512-2ja+Sf/VnUO7IQ3nKbDQ5aumYKKJUaTm/BuVJ29wNho8wYHfuf7wHZV0pDTkB8RF5SH7IpHap7zpZAj39Iq+EA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-config-provider": "3.310.0", + "@aws-sdk/util-middleware": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.347.0.tgz", + "integrity": "sha512-UnEM+LKGpXKzw/1WvYEQsC6Wj9PupYZdQOE+e2Dgy2dqk/pVFy4WueRtFXYDT2B41ppv3drdXUuKZRIDVqIgNQ==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-imds": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.347.0.tgz", + "integrity": "sha512-7scCy/DCDRLIhlqTxff97LQWDnRwRXji3bxxMg+xWOTTaJe7PWx+etGSbBWaL42vsBHFShQjSLvJryEgoBktpw==", + "dependencies": { + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.352.0.tgz", + "integrity": "sha512-lnQUJznvOhI2er1u/OVf99/2JIyDH7W+6tfWNXEoVgEi4WXtdyZ+GpPNoZsmCtHB2Jwlsh51IxmYdCj6b6SdwQ==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.347.0", + "@aws-sdk/credential-provider-imds": "3.347.0", + "@aws-sdk/credential-provider-process": "3.347.0", + "@aws-sdk/credential-provider-sso": "3.352.0", + "@aws-sdk/credential-provider-web-identity": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.352.0.tgz", + "integrity": "sha512-8UZ5EQpoqHCh+XSGq2CdhzHZyKLOwF1taDw5A/gmV4O5lAWL0AGs0cPIEUORJyggU6Hv43zZOpLgK6dMgWOLgA==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.347.0", + "@aws-sdk/credential-provider-imds": "3.347.0", + "@aws-sdk/credential-provider-ini": "3.352.0", + "@aws-sdk/credential-provider-process": "3.347.0", + "@aws-sdk/credential-provider-sso": "3.352.0", + "@aws-sdk/credential-provider-web-identity": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.347.0.tgz", + "integrity": "sha512-yl1z4MsaBdXd4GQ2halIvYds23S67kElyOwz7g8kaQ4kHj+UoYWxz3JVW/DGusM6XmQ9/F67utBrUVA0uhQYyw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.352.0.tgz", + "integrity": "sha512-YiooGNy9LYN1bFqKwO2wHC++1pYReiSqQDWBeluJfC3uZWpCyIUMdeYBR1X3XZDVtK6bl5KmhxldxJ3ntt/Q4w==", + "dependencies": { + "@aws-sdk/client-sso": "3.352.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/token-providers": "3.352.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.347.0.tgz", + "integrity": "sha512-DxoTlVK8lXjS1zVphtz/Ab+jkN/IZor9d6pP2GjJHNoAIIzXfRwwj5C8vr4eTayx/5VJ7GRP91J8GJ2cKly8Qw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/eventstream-codec": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-codec/-/eventstream-codec-3.347.0.tgz", + "integrity": "sha512-61q+SyspjsaQ4sdgjizMyRgVph2CiW4aAtfpoH69EJFJfTxTR/OqnZ9Jx/3YiYi0ksrvDenJddYodfWWJqD8/w==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/fetch-http-handler": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.347.0.tgz", + "integrity": "sha512-sQ5P7ivY8//7wdxfA76LT1sF6V2Tyyz1qF6xXf9sihPN5Q1Y65c+SKpMzXyFSPqWZ82+SQQuDliYZouVyS6kQQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/querystring-builder": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/hash-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.347.0.tgz", + "integrity": "sha512-96+ml/4EaUaVpzBdOLGOxdoXOjkPgkoJp/0i1fxOJEvl8wdAQSwc3IugVK9wZkCxy2DlENtgOe6DfIOhfffm/g==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-buffer-from": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/invalid-dependency": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.347.0.tgz", + "integrity": "sha512-8imQcwLwqZ/wTJXZqzXT9pGLIksTRckhGLZaXT60tiBOPKuerTsus2L59UstLs5LP8TKaVZKFFSsjRIn9dQdmQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-content-length": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.347.0.tgz", + "integrity": "sha512-i4qtWTDImMaDUtwKQPbaZpXsReiwiBomM1cWymCU4bhz81HL01oIxOxOBuiM+3NlDoCSPr3KI6txZSz/8cqXCQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-endpoint": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.347.0.tgz", + "integrity": "sha512-unF0c6dMaUL1ffU+37Ugty43DgMnzPWXr/Jup/8GbK5fzzWT5NQq6dj9KHPubMbWeEjQbmczvhv25JuJdK8gNQ==", + "dependencies": { + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-middleware": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.347.0.tgz", + "integrity": "sha512-kpKmR9OvMlnReqp5sKcJkozbj1wmlblbVSbnQAIkzeQj2xD5dnVR3Nn2ogQKxSmU1Fv7dEroBtrruJ1o3fY38A==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-logger": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.347.0.tgz", + "integrity": "sha512-NYC+Id5UCkVn+3P1t/YtmHt75uED06vwaKyxDy0UmB2K66PZLVtwWbLpVWrhbroaw1bvUHYcRyQ9NIfnVcXQjA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.347.0.tgz", + "integrity": "sha512-qfnSvkFKCAMjMHR31NdsT0gv5Sq/ZHTUD4yQsSLpbVQ6iYAS834lrzXt41iyEHt57Y514uG7F/Xfvude3u4icQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-retry": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.347.0.tgz", + "integrity": "sha512-CpdM+8dCSbX96agy4FCzOfzDmhNnGBM/pxrgIVLm5nkYTLuXp/d7ubpFEUHULr+4hCd5wakHotMt7yO29NFaVw==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/service-error-classification": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-middleware": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-sdk-sts": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.347.0.tgz", + "integrity": "sha512-38LJ0bkIoVF3W97x6Jyyou72YV9Cfbml4OaDEdnrCOo0EssNZM5d7RhjMvQDwww7/3OBY/BzeOcZKfJlkYUXGw==", + "dependencies": { + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-serde": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.347.0.tgz", + "integrity": "sha512-x5Foi7jRbVJXDu9bHfyCbhYDH5pKK+31MmsSJ3k8rY8keXLBxm2XEEg/AIoV9/TUF9EeVvZ7F1/RmMpJnWQsEg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-signing": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.347.0.tgz", + "integrity": "sha512-zVBF/4MGKnvhAE/J+oAL/VAehiyv+trs2dqSQXwHou9j8eA8Vm8HS2NdOwpkZQchIxTuwFlqSusDuPEdYFbvGw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/signature-v4": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-middleware": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-stack": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.347.0.tgz", + "integrity": "sha512-Izidg4rqtYMcKuvn2UzgEpPLSmyd8ub9+LQ2oIzG3mpIzCBITq7wp40jN1iNkMg+X6KEnX9vdMJIYZsPYMCYuQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.352.0.tgz", + "integrity": "sha512-QGqblMTsVDqeomy22KPm9LUW8PHZXBA2Hjk9Hcw8U1uFS8IKYJrewInG3ae2+9FAcTyug4LFWDf8CRr9YH2B3Q==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-endpoints": "3.352.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/node-config-provider": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.347.0.tgz", + "integrity": "sha512-faU93d3+5uTTUcotGgMXF+sJVFjrKh+ufW+CzYKT4yUHammyaIab/IbTPWy2hIolcEGtuPeVoxXw8TXbkh/tuw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/node-http-handler": { + "version": "3.350.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.350.0.tgz", + "integrity": "sha512-oD96GAlmpzYilCdC8wwyURM5lNfNHZCjm/kxBkQulHKa2kRbIrnD9GfDqdCkWA5cTpjh1NzGLT4D6e6UFDjt9w==", + "dependencies": { + "@aws-sdk/abort-controller": "3.347.0", + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/querystring-builder": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/property-provider": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.347.0.tgz", + "integrity": "sha512-t3nJ8CYPLKAF2v9nIHOHOlF0CviQbTvbFc2L4a+A+EVd/rM4PzL3+3n8ZJsr0h7f6uD04+b5YRFgKgnaqLXlEg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/protocol-http": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.347.0.tgz", + "integrity": "sha512-2YdBhc02Wvy03YjhGwUxF0UQgrPWEy8Iq75pfS42N+/0B/+eWX1aQgfjFxIpLg7YSjT5eKtYOQGlYd4MFTgj9g==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/querystring-builder": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.347.0.tgz", + "integrity": "sha512-phtKTe6FXoV02MoPkIVV6owXI8Mwr5IBN3bPoxhcPvJG2AjEmnetSIrhb8kwc4oNhlwfZwH6Jo5ARW/VEWbZtg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-uri-escape": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/querystring-parser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.347.0.tgz", + "integrity": "sha512-5VXOhfZz78T2W7SuXf2avfjKglx1VZgZgp9Zfhrt/Rq+MTu2D+PZc5zmJHhYigD7x83jLSLogpuInQpFMA9LgA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/service-error-classification": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.347.0.tgz", + "integrity": "sha512-xZ3MqSY81Oy2gh5g0fCtooAbahqh9VhsF8vcKjVX8+XPbGC8y+kej82+MsMg4gYL8gRFB9u4hgYbNgIS6JTAvg==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/shared-ini-file-loader": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.347.0.tgz", + "integrity": "sha512-Xw+zAZQVLb+xMNHChXQ29tzzLqm3AEHsD8JJnlkeFjeMnWQtXdUfOARl5s8NzAppcKQNlVe2gPzjaKjoy2jz1Q==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/signature-v4": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.347.0.tgz", + "integrity": "sha512-58Uq1do+VsTHYkP11dTK+DF53fguoNNJL9rHRWhzP+OcYv3/mBMLoS2WPz/x9FO5mBg4ESFsug0I6mXbd36tjw==", + "dependencies": { + "@aws-sdk/eventstream-codec": "3.347.0", + "@aws-sdk/is-array-buffer": "3.310.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "@aws-sdk/util-middleware": "3.347.0", + "@aws-sdk/util-uri-escape": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/smithy-client": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.347.0.tgz", + "integrity": "sha512-PaGTDsJLGK0sTjA6YdYQzILRlPRN3uVFyqeBUkfltXssvUzkm8z2t1lz2H4VyJLAhwnG5ZuZTNEV/2mcWrU7JQ==", + "dependencies": { + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/token-providers": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.352.0.tgz", + "integrity": "sha512-cmmAgieLP/aAl9WdPiBoaC0Abd6KncSLig/ElLPoNsADR10l3QgxQcVF3YMtdX0U0d917+/SeE1PdrPD2x15cw==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.352.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/types": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.347.0.tgz", + "integrity": "sha512-GkCMy79mdjU9OTIe5KT58fI/6uqdf8UmMdWqVHmFJ+UpEzOci7L/uw4sOXWo7xpPzLs6cJ7s5ouGZW4GRPmHFA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/url-parser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.347.0.tgz", + "integrity": "sha512-lhrnVjxdV7hl+yCnJfDZOaVLSqKjxN20MIOiijRiqaWGLGEAiSqBreMhL89X1WKCifxAs4zZf9YB9SbdziRpAA==", + "dependencies": { + "@aws-sdk/querystring-parser": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-defaults-mode-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.347.0.tgz", + "integrity": "sha512-+JHFA4reWnW/nMWwrLKqL2Lm/biw/Dzi/Ix54DAkRZ08C462jMKVnUlzAI+TfxQE3YLm99EIa0G7jiEA+p81Qw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-defaults-mode-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.347.0.tgz", + "integrity": "sha512-A8BzIVhAAZE5WEukoAN2kYebzTc99ZgncbwOmgCCbvdaYlk5tzguR/s+uoT4G0JgQGol/4hAMuJEl7elNgU6RQ==", + "dependencies": { + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-imds": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-endpoints": { + "version": "3.352.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.352.0.tgz", + "integrity": "sha512-PjWMPdoIUWfBPgAWLyOrWFbdSS/3DJtc0OmFb/JrE8C8rKFYl+VGW5f1p0cVdRWiDR0xCGr0s67p8itAakVqjw==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-middleware": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.347.0.tgz", + "integrity": "sha512-8owqUA3ePufeYTUvlzdJ7Z0miLorTwx+rNol5lourGQZ9JXsVMo23+yGA7nOlFuXSGkoKpMOtn6S0BT2bcfeiw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-retry": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.347.0.tgz", + "integrity": "sha512-NxnQA0/FHFxriQAeEgBonA43Q9/VPFQa8cfJDuT2A1YZruMasgjcltoZszi1dvoIRWSZsFTW42eY2gdOd0nffQ==", + "dependencies": { + "@aws-sdk/service-error-classification": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.347.0.tgz", + "integrity": "sha512-ydxtsKVtQefgbk1Dku1q7pMkjDYThauG9/8mQkZUAVik55OUZw71Zzr3XO8J8RKvQG8lmhPXuAQ0FKAyycc0RA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.347.0.tgz", + "integrity": "sha512-6X0b9qGsbD1s80PmbaB6v1/ZtLfSx6fjRX8caM7NN0y/ObuLoX8LhYnW6WlB2f1+xb4EjaCNgpP/zCf98MXosw==", + "dependencies": { + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-organizations/node_modules/fast-xml-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.4.tgz", + "integrity": "sha512-fbfMDvgBNIdDJLdLOwacjFAPYt67tr31H9ZhWSm45CDAxvd0I6WTlSOUo7K2P/K5sA5JgMKG64PI3DMcaFdWpQ==", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws-sdk/client-pricing": { "version": "3.332.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-pricing/-/client-pricing-3.332.0.tgz", @@ -7359,37 +8150,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tanstack/react-table": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.9.2.tgz", - "integrity": "sha512-Irvw4wqVF9hhuYzmNrlae4IKdlmgSyoRWnApSLebvYzqHoi5tEsYzBj6YPd0hX78aB/L+4w/jgK2eBQVpGfThQ==", - "dependencies": { - "@tanstack/table-core": "8.9.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.9.2.tgz", - "integrity": "sha512-ajc0OF+karBAdaSz7OK09rCoAHB1XI1+wEhu+tDNMPc+XcO+dTlXXN/Vc0a8vym4kElvEjXEDd9c8Zfgt4bekA==", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tinystacks/ops-aws-core-widgets": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@tinystacks/ops-aws-core-widgets/-/ops-aws-core-widgets-0.0.5.tgz", @@ -18339,15 +19099,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zlib": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", - "integrity": "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w==", - "hasInstallScript": true, - "engines": { - "node": ">=0.2.0" - } } } } diff --git a/package.json b/package.json index 84c70d1..fe70bf9 100644 --- a/package.json +++ b/package.json @@ -75,13 +75,13 @@ "@aws-sdk/client-ec2": "^3.303.0", "@aws-sdk/client-ecs": "^3.315.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.315.0", + "@aws-sdk/client-organizations": "^3.352.0", "@aws-sdk/client-pricing": "^3.306.0", "@aws-sdk/client-rds": "^3.312.0", "@aws-sdk/client-s3": "^3.301.0", "@aws-sdk/client-sts": "^3.312.0", "@chakra-ui/icons": "^2.0.19", "@chakra-ui/react": "^2.5.5", - "@tanstack/react-table": "^8.9.2", "@tinystacks/ops-aws-core-widgets": "^0.0.5", "@tinystacks/ops-core": "^0.0.3", "@tinystacks/ops-model": "^0.2.0", @@ -97,7 +97,6 @@ "lodash.startcase": "^4.4.0", "react": "^18.2.0", "react-icons": "^4.8.0", - "simple-statistics": "^7.8.3", - "zlib": "^1.0.5" + "simple-statistics": "^7.8.3" } } diff --git a/src/aws-utilization-provider.ts b/src/aws-utilization-provider.ts index abcfc74..9ad1d90 100644 --- a/src/aws-utilization-provider.ts +++ b/src/aws-utilization-provider.ts @@ -12,7 +12,6 @@ import { AwsServiceUtilization } from './service-utilizations/aws-service-utiliz import { AwsServiceUtilizationFactory } from './service-utilizations/aws-service-utilization-factory.js'; import { CostAndUsageReportService } from '@aws-sdk/client-cost-and-usage-report-service'; import { parseStreamSync } from './utils/utils.js'; -import { STS } from '@aws-sdk/client-sts'; import { CostReport } from './types/cost-and-usage-types.js'; import { auditCostReport, @@ -153,18 +152,9 @@ class AwsUtilizationProvider extends BaseProvider { return this.utilization; } - // if describeReportDefinitions is empty, we need to tell users to create a report - // if list objects is empty we need to tell users a report has not been generated yet - // do we want to show only resources with the accountId associated with the provided credentials? - // async getCostPerResource (awsCredentialsProvider: AwsCredentialsProvider, region: string) { - + // TODO: continue to audit that productName matches service returned by getCostAndUsage - async getCostReport (awsCredentialsProvider: AwsCredentialsProvider, region: string) { - // const depMap = { - // zlib: 'zlib' - // }; - // const { createUnzip } = await import(depMap.zlib); - + async getCostReport (awsCredentialsProvider: AwsCredentialsProvider, region: string, accountId: string) { const costReport: CostReport = { report: {}, hasCostReportDefinition: false, @@ -175,17 +165,11 @@ class AwsUtilizationProvider extends BaseProvider { credentials, region: 'us-east-1' }); - const stsClient = new STS({ - credentials, - region - }); const costExplorerClient = new CostExplorer({ credentials, region }); - const accountId = (await stsClient.getCallerIdentity({})).Account; - const now = dayjs(); await fillServiceCosts(costExplorerClient, costReport, accountId, region, now); @@ -202,7 +186,7 @@ class AwsUtilizationProvider extends BaseProvider { TimeUnit } = reportDefinition; - // DAILY + // init is DAILY let toMonthlyFactor = 30; if (TimeUnit === 'HOURLY') { toMonthlyFactor = 24 * 30; @@ -221,9 +205,6 @@ class AwsUtilizationProvider extends BaseProvider { const resourceReport = resourceReportZip.pipe(createUnzip()); await parseStreamSync(resourceReport, { headers: true }, (row) => { - // if (row['lineItem/ResourceId'].includes('secretsmanager')) { - // console.log(row['lineItem/UsageType']) - // } const resourceId = getArnOrResourceId( row['lineItem/ProductCode'], row['lineItem/ResourceId'], diff --git a/src/components/cost-by-service/report-breakdown.tsx b/src/components/cost-by-service/report-breakdown.tsx index 925e0c9..87baf20 100644 --- a/src/components/cost-by-service/report-breakdown.tsx +++ b/src/components/cost-by-service/report-breakdown.tsx @@ -1,59 +1,116 @@ import React from 'react'; -import { Heading, Stack, Table, TableContainer, Tbody, Th, Thead } from '@chakra-ui/react'; +import { + Button, + Heading, + Menu, + MenuButton, + MenuItem, + MenuList, + Stack, + Table, + TableContainer, + Tbody, + Th, + Thead +} from '@chakra-ui/react'; import isEmpty from 'lodash.isempty'; -import { CostReport } from '../../types/cost-and-usage-types.js'; -import ServiceRow from './service-row.js'; +import { AccountIdSelector, CostReport, ServiceCostTableRow } from '../../types/cost-and-usage-types.js'; +import { ServiceRow } from './service-row.js'; +import { useTableHeaderSorting } from './table-sorting.js'; +import { ChevronDownIcon } from '@chakra-ui/icons'; -export default function ReportBreakdown ( - props: { costReport: CostReport } -) { - const { costReport } = props; +export default function ReportBreakdown (props: { + costReport: CostReport, + useAccountIdSelector: AccountIdSelector +}) { + const { + costReport, + useAccountIdSelector: { + accountId: currentAccountId, + allAccountIds, + onAccountIdChange + } + } = props; - function serviceRow (service: string) { + function serviceRow (row: ServiceCostTableRow) { return ( - ); } function reportTable () { - // const [ sorting, setSorting ] = React.useState([]); - // const table = useReactTable({ - - // }) - + let headerMessage = ''; if (isEmpty(costReport.report) && costReport.hasCostReportDefinition) { - return ( - - No cost report available! - If you have recently created an AWS Cost and Usage Report, it will be available in 24 hours. - - ); + if (costReport.hasCostReport) { + headerMessage = + 'No cost report available for this account! ' + + 'This account may not be utilizing any AWS services this month ' + + 'or you may not have permissions to access it\'s cost details.'; + } else { + headerMessage = + 'No cost report available! ' + + 'If you have recently created an AWS Cost and Usage Report, it will be available in 24 hours.'; + } } else if (!costReport.hasCostReportDefinition) { - return ( - - You need to set up an AWS Cost and Usage Report under Billing in the AWS Console. - It only takes a couple minutes, and a report will be available in 24 hours! - - ); + headerMessage = + 'You need to set up an AWS Cost and Usage Report under Billing in the AWS Console. ' + + 'It only takes a couple minutes, and a report will be available in 24 hours!'; + } else { + headerMessage = 'All cost values are monthly estimates based on current usage costs provided by AWS.'; } + + const tableData = Object.keys(costReport.report).map(service => ( + { + service, + numResources: Object.keys(costReport.report[service].resourceCosts).length, + cost: costReport.report[service].serviceCost + } + )); + + const { handleHeaderClick, sortDataTable } = useTableHeaderSorting(tableData, { column: 'cost', order: 'desc' }); + return ( - All cost values are monthly estimates based on current usage costs provided by AWS + {headerMessage} + + + }> + Account ID + + + {allAccountIds.map((accountId) => { + if (accountId === currentAccountId) { + return ( + onAccountIdChange(accountId)} + > + {accountId} + + ); + } else { + return onAccountIdChange(accountId)}>{accountId}; + } + })} + + + - - - + + + - {Object.keys(costReport.report).sort().map(serviceRow)} + {sortDataTable().map((serviceRow))}
Service# ResourcesCost/Mo handleHeaderClick('service')}>Service handleHeaderClick('numResources')}># Resources handleHeaderClick('cost')}>Cost/Mo
diff --git a/src/components/cost-by-service/resources-table.tsx b/src/components/cost-by-service/resources-table.tsx index 7a86906..1aad71f 100644 --- a/src/components/cost-by-service/resources-table.tsx +++ b/src/components/cost-by-service/resources-table.tsx @@ -11,20 +11,35 @@ import { Tooltip, Box } from '@chakra-ui/react'; +import { ResourceCosts } from '../../types/cost-and-usage-types.js'; +import { useTableHeaderSorting } from './table-sorting.js'; + +type ResourceCostTableRow = { + resourceId: string; + cost: number; +} type ResourcesTableProps = { service: string; - resourceCosts: { [resourceId: string]: number }; + resourceCosts: ResourceCosts; }; -export default function ResourcesTable (props: ResourcesTableProps) { +export function ResourcesTable (props: ResourcesTableProps) { const { service, resourceCosts } = props; - const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); + const tableData: ResourceCostTableRow[] = Object.keys(resourceCosts).map(resourceId => ( + { + resourceId, + cost: resourceCosts[resourceId] + } + )); + + const { handleHeaderClick, sortDataTable } = useTableHeaderSorting(tableData, { column: 'cost', order: 'desc' }); + return ( - Resource ID - Cost/Mo + handleHeaderClick('resourceId')}>Resource ID + handleHeaderClick('cost')}>Cost/Mo - {Object.keys(resourceCosts) - .sort() - .map((resourceId) => { - return ( - - ( + + + + - - - {resourceId} - - - - {usd.format(resourceCosts[resourceId])} - - ); - })} + {row['resourceId']} + + + + {usd.format(row['cost'])} + + ))} diff --git a/src/components/cost-by-service/service-row.tsx b/src/components/cost-by-service/service-row.tsx index a43d2b2..a24d1f4 100644 --- a/src/components/cost-by-service/service-row.tsx +++ b/src/components/cost-by-service/service-row.tsx @@ -1,19 +1,25 @@ import React from 'react'; import { ChevronUpIcon, ChevronDownIcon, InfoIcon } from '@chakra-ui/icons'; import { useDisclosure, Tr, Td, Button, Box, Tooltip } from '@chakra-ui/react'; -import { ServiceInformation } from '../../types/cost-and-usage-types.js'; +import { ResourceCosts, ServiceCostTableRow } from '../../types/cost-and-usage-types.js'; import isEmpty from 'lodash.isempty'; -import ResourcesTable from './resources-table.js'; +import { ResourcesTable } from './resources-table.js'; type ServiceRowProps = { - service: string; - serviceInformation: ServiceInformation; + row: ServiceCostTableRow; + resourceCosts: ResourceCosts; + details?: string; }; -export default function ServiceRow (props: ServiceRowProps) { +export function ServiceRow (props: ServiceRowProps) { const { - service, - serviceInformation: { serviceCost, resourceCosts, details } + row: { + service, + numResources, + cost + }, + resourceCosts, + details } = props; const { isOpen, onToggle } = useDisclosure(); @@ -42,8 +48,8 @@ export default function ServiceRow (props: ServiceRowProps) { service )} - {Object.keys(resourceCosts).length} - {usd.format(serviceCost)} + {numResources.toString()} + {usd.format(cost)}