From 71858c81d68f9c4724a74f2167806399960ba2d8 Mon Sep 17 00:00:00 2001 From: Michelle Bergquist Date: Wed, 27 Nov 2024 14:28:55 -0500 Subject: [PATCH] Show aws summary stats on dashboard --- .../status/AwsOidc/AwsOidcDashboard.story.tsx | 173 ++++++++++++++++-- .../status/AwsOidc/AwsOidcDashboard.test.tsx | 85 ++++++++- .../status/AwsOidc/AwsOidcDashboard.tsx | 33 +++- .../Integrations/status/AwsOidc/StatCard.tsx | 107 +++++++++++ .../status/AwsOidc/useAwsOidcStatus.tsx | 14 +- web/packages/teleport/src/config.ts | 10 + .../src/services/integrations/integrations.ts | 7 + .../src/services/integrations/types.ts | 35 ++++ 8 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 web/packages/teleport/src/Integrations/status/AwsOidc/StatCard.tsx diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.story.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.story.tsx index 662b5b5206af5..2daebc87073fe 100644 --- a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.story.tsx +++ b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.story.tsx @@ -18,34 +18,171 @@ import React from 'react'; +import { addHours } from 'date-fns'; + import { AwsOidcDashboard } from 'teleport/Integrations/status/AwsOidc/AwsOidcDashboard'; import { MockAwsOidcStatusProvider } from 'teleport/Integrations/status/AwsOidc/testHelpers/mockAwsOidcStatusProvider'; -import { IntegrationKind } from 'teleport/services/integrations'; +import { + IntegrationKind, + ResourceTypeSummary, +} from 'teleport/services/integrations'; +import { AwsOidcStatusContextState } from 'teleport/Integrations/status/AwsOidc/useAwsOidcStatus'; export default { title: 'Teleport/Integrations/AwsOidc', }; +// Loaded dashboard with data for each aws resource and a navigation header export function Dashboard() { return ( - + ); } + +// Loading screen +export function StatsProcessing() { + const props = makeAwsOidcStatusContextState({ + statsAttempt: { status: 'processing', data: null, statusText: '' }, + }); + return ( + + + + ); +} + +// No header, no loading indicator +export function IntegrationProcessing() { + const props = makeAwsOidcStatusContextState({ + integrationAttempt: { + status: 'processing', + data: null, + statusText: '', + }, + }); + return ( + + + + ); +} + +// Loaded error message +export function StatsFailed() { + const props = makeAwsOidcStatusContextState({ + statsAttempt: { + status: 'error', + data: null, + statusText: 'failed to get stats', + error: {}, + }, + }); + return ( + + + + ); +} + +// Loaded dashboard with data for each aws resource but no navigation header +export function IntegrationFailed() { + const props = makeAwsOidcStatusContextState({ + integrationAttempt: { + status: 'error', + data: null, + statusText: 'failed to get integration', + error: {}, + }, + }); + return ( + + + + ); +} + +// Blank screen +export function StatsNoData() { + const props = makeAwsOidcStatusContextState({ + statsAttempt: { status: 'success', data: null, statusText: '' }, + }); + return ( + + + + ); +} + +// No header, no loading indicator +export function IntegrationNoData() { + const props = makeAwsOidcStatusContextState({ + integrationAttempt: { + status: 'success', + data: null, + statusText: '', + }, + }); + return ( + + + + ); +} + +function makeAwsOidcStatusContextState( + overrides: Partial = {} +): AwsOidcStatusContextState { + return Object.assign( + { + integrationAttempt: { + status: 'success', + statusText: '', + data: { + resourceType: 'integration', + name: 'integration-one', + kind: IntegrationKind.AwsOidc, + spec: { + roleArn: 'arn:aws:iam::111456789011:role/bar', + }, + statusCode: 1, + }, + }, + statsAttempt: { + status: 'success', + statusText: '', + data: { + name: 'integration-one', + subKind: IntegrationKind.AwsOidc, + awsoidc: { + roleArn: 'arn:aws:iam::111456789011:role/bar', + }, + awsec2: makeResourceTypeSummary(), + awsrds: makeResourceTypeSummary(), + awseks: makeResourceTypeSummary(), + }, + }, + }, + overrides + ); +} + +function makeResourceTypeSummary( + overrides: Partial = {} +): ResourceTypeSummary { + return Object.assign( + { + rulesCount: Math.floor(Math.random() * 100), + resourcesFound: Math.floor(Math.random() * 100), + resourcesEnrollmentFailed: Math.floor(Math.random() * 100), + resourcesEnrollmentSuccess: Math.floor(Math.random() * 100), + discoverLastSync: addHours( + new Date().getTime(), + -Math.floor(Math.random() * 100) + ), + ecsDatabaseServiceCount: Math.floor(Math.random() * 100), + }, + overrides + ); +} diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.test.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.test.tsx index 3a1a20866a359..d45eded03c7f5 100644 --- a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.test.tsx +++ b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.test.tsx @@ -19,16 +19,20 @@ import React from 'react'; import { render, screen } from 'design/utils/testing'; +import { within } from '@testing-library/react'; + import { AwsOidcDashboard } from 'teleport/Integrations/status/AwsOidc/AwsOidcDashboard'; import { MockAwsOidcStatusProvider } from 'teleport/Integrations/status/AwsOidc/testHelpers/mockAwsOidcStatusProvider'; import { IntegrationKind } from 'teleport/services/integrations'; +import { addHours } from 'teleport/components/BannerList/useAlerts'; test('renders header', () => { render( { }, statusCode: 1, }, + }, + statsAttempt: { + status: 'success', statusText: '', + data: { + name: 'integration-one', + subKind: IntegrationKind.AwsOidc, + awsoidc: { + roleArn: 'arn:aws:iam::111456789011:role/bar', + }, + awsec2: { + rulesCount: 24, + resourcesFound: 12, + resourcesEnrollmentFailed: 3, + resourcesEnrollmentSuccess: 9, + discoverLastSync: new Date().getTime(), + ecsDatabaseServiceCount: 0, // irrelevant + }, + awsrds: { + rulesCount: 14, + resourcesFound: 5, + resourcesEnrollmentFailed: 5, + resourcesEnrollmentSuccess: 0, + discoverLastSync: addHours(new Date().getTime(), -4), + ecsDatabaseServiceCount: 8, // relevant + }, + awseks: { + rulesCount: 33, + resourcesFound: 3, + resourcesEnrollmentFailed: 0, + resourcesEnrollmentSuccess: 3, + discoverLastSync: addHours(new Date().getTime(), -48), + ecsDatabaseServiceCount: 0, // irrelevant + }, + }, }, }} > @@ -53,4 +91,49 @@ test('renders header', () => { expect(screen.getByText('integration-one')).toBeInTheDocument(); expect(screen.getByLabelText('status')).toHaveAttribute('kind', 'success'); expect(screen.getByLabelText('status')).toHaveTextContent('Running'); + + const ec2 = screen.getByTestId('ec2-stats'); + expect(within(ec2).getByTestId('sync')).toHaveTextContent( + 'Last Sync: 0 seconds ago' + ); + expect(within(ec2).getByTestId('rules')).toHaveTextContent( + 'Enrollment Rules 24' + ); + expect(within(ec2).queryByTestId('rds-agents')).not.toBeInTheDocument(); + expect(within(ec2).getByTestId('enrolled')).toHaveTextContent( + 'Enrolled Instances 9' + ); + expect(within(ec2).getByTestId('failed')).toHaveTextContent( + 'Failed Instances 3' + ); + + const rds = screen.getByTestId('rds-stats'); + expect(within(rds).getByTestId('sync')).toHaveTextContent( + 'Last Sync: 4 hours ago' + ); + expect(within(rds).getByTestId('rules')).toHaveTextContent( + 'Enrollment Rules 14' + ); + expect(within(rds).getByTestId('rds-agents')).toHaveTextContent('Agents 8'); + expect(within(rds).getByTestId('enrolled')).toHaveTextContent( + 'Enrolled Databases 0' + ); + expect(within(rds).getByTestId('failed')).toHaveTextContent( + 'Failed Databases 5' + ); + + const eks = screen.getByTestId('eks-stats'); + expect(within(eks).getByTestId('sync')).toHaveTextContent( + 'Last Sync: 2 days ago' + ); + expect(within(eks).getByTestId('rules')).toHaveTextContent( + 'Enrollment Rules 33' + ); + expect(within(eks).queryByTestId('rds-agents')).not.toBeInTheDocument(); + expect(within(eks).getByTestId('enrolled')).toHaveTextContent( + 'Enrolled Clusters 3' + ); + expect(within(eks).getByTestId('failed')).toHaveTextContent( + 'Failed Clusters 0' + ); }); diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.tsx index 98f5faa122bd4..a440f03ec4b6c 100644 --- a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.tsx +++ b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcDashboard.tsx @@ -18,18 +18,43 @@ import React from 'react'; +import { Flex, H2, Indicator } from 'design'; + +import { Danger } from 'design/Alert'; + import { AwsOidcHeader } from 'teleport/Integrations/status/AwsOidc/AwsOidcHeader'; import { useAwsOidcStatus } from 'teleport/Integrations/status/AwsOidc/useAwsOidcStatus'; import { FeatureBox } from 'teleport/components/Layout'; +import { + AwsResource, + StatCard, +} from 'teleport/Integrations/status/AwsOidc/StatCard'; -// todo (michellescripts) after routing, ensure this view can be sticky export function AwsOidcDashboard() { - const { attempt } = useAwsOidcStatus(); + const { statsAttempt, integrationAttempt } = useAwsOidcStatus(); + + if (statsAttempt.status == 'processing') { + return ; + } + if (statsAttempt.status == 'error') { + return {statsAttempt.statusText}; + } + if (!statsAttempt.data) { + return; + } + // todo (michellescripts) after routing, ensure this view can be sticky + const { awsec2, awseks, awsrds } = statsAttempt.data; + const { data: integration } = integrationAttempt; return ( - {attempt.data && } - Status for integration type aws-oidc is not supported + {integration && } +

Auto-Enrollment

+ + {awsec2 && } + {awsrds && } + {awseks && } +
); } diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/StatCard.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/StatCard.tsx new file mode 100644 index 0000000000000..b5df27ef44938 --- /dev/null +++ b/web/packages/teleport/src/Integrations/status/AwsOidc/StatCard.tsx @@ -0,0 +1,107 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; + +import { Card, Flex, H2, Text } from 'design'; +import { ResourceIcon } from 'design/ResourceIcon'; + +import * as Icons from 'design/Icon'; + +import { getDurationText } from 'shared/utils/getDurationText'; + +import { formatDistanceStrict } from 'date-fns'; + +import { ResourceTypeSummary } from 'teleport/services/integrations'; + +export enum AwsResource { + ec2 = 'ec2', + eks = 'eks', + rds = 'rds', +} + +type StatCardProps = { + resource: AwsResource; + summary: ResourceTypeSummary; +}; + +export function StatCard({ resource, summary }: StatCardProps) { + const updated = new Date(summary.discoverLastSync); + + const term = + resource == AwsResource.rds + ? 'Databases' + : resource == AwsResource.eks + ? 'Clusters' + : 'Instances'; + + return ( + + + + + +

{resource.toUpperCase()}

+
+ + Enrollment Rules + {summary.rulesCount || 0} + + {resource == AwsResource.rds && ( + + Agents + {summary.ecsDatabaseServiceCount || 0} + + )} + + Enrolled {term} + {summary.resourcesEnrollmentSuccess || 0} + + + Failed {term} + + {summary.resourcesEnrollmentFailed || 0} + {summary.resourcesEnrollmentFailed > 0 && ( + + )} + + +
+ + Last Sync:{' '} + {formatDistanceStrict(new Date(updated), new Date(), { + addSuffix: true, + })} + +
+
+ ); +} diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/useAwsOidcStatus.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/useAwsOidcStatus.tsx index fc060328adc0b..f517f785595d9 100644 --- a/web/packages/teleport/src/Integrations/status/AwsOidc/useAwsOidcStatus.tsx +++ b/web/packages/teleport/src/Integrations/status/AwsOidc/useAwsOidcStatus.tsx @@ -24,12 +24,14 @@ import { Integration, IntegrationKind, integrationService, + IntegrationWithSummary, } from 'teleport/services/integrations'; import useTeleport from 'teleport/useTeleport'; export interface AwsOidcStatusContextState { - attempt: Attempt; + statsAttempt: Attempt; + integrationAttempt: Attempt; } export const awsOidcStatusContext = @@ -44,18 +46,24 @@ export function AwsOidcStatusProvider({ children }: React.PropsWithChildren) { const integrationAccess = ctx.storeUser.getIntegrationsAccess(); const hasIntegrationReadAccess = integrationAccess.read; - const [attempt, fetchIntegration] = useAsync(() => + const [stats, fetchIntegrationStats] = useAsync(() => + integrationService.fetchIntegrationStats(name) + ); + + const [integration, fetchIntegration] = useAsync(() => integrationService.fetchIntegration(name) ); useEffect(() => { if (hasIntegrationReadAccess) { + fetchIntegrationStats(); fetchIntegration(); } }, []); const value: AwsOidcStatusContextState = { - attempt, + statsAttempt: stats, + integrationAttempt: integration, }; return ( diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 887d62c875ff5..817f1a090c7e2 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -319,6 +319,8 @@ const cfg = { headlessLogin: '/v1/webapi/headless/:headless_authentication_id', integrationsPath: '/v1/webapi/sites/:clusterId/integrations/:name?', + integrationStatsPath: + '/v1/webapi/sites/:clusterId/integrations/:name/stats', thumbprintPath: '/v1/webapi/thumbprint', pingAwsOidcIntegrationPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/ping', @@ -966,6 +968,14 @@ const cfg = { }); }, + getIntegrationStatsUrl(name: string) { + const clusterId = cfg.proxyCluster; + return generatePath(cfg.api.integrationStatsPath, { + clusterId, + name, + }); + }, + getPingAwsOidcIntegrationUrl({ integrationName, clusterId, diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index dfdb2e0ad347b..214b07f39efc1 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -59,6 +59,7 @@ import { AwsDatabaseVpcsResponse, AwsOidcPingResponse, AwsOidcPingRequest, + IntegrationWithSummary, } from './types'; export const integrationService = { @@ -413,6 +414,12 @@ export const integrationService = { }; }); }, + + fetchIntegrationStats(name: string): Promise { + return api.get(cfg.getIntegrationStatsUrl(name)).then(resp => { + return resp; + }); + }, }; export function makeIntegrations(json: any): Integration[] { diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index ccc795fe887cd..ff8f4347b985c 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -285,6 +285,41 @@ export type IntegrationListResponse = { nextKey?: string; }; +// IntegrationWithSummary describes Integration fields and the fields required to return the summary. +export type IntegrationWithSummary = { + name: string; + subKind: string; + awsoidc: IntegrationSpecAwsOidc; + // AWSEC2 contains the summary for the AWS EC2 resources for this integration. + awsec2: ResourceTypeSummary; + // AWSRDS contains the summary for the AWS RDS resources and agents for this integration. + awsrds: ResourceTypeSummary; + // AWSEKS contains the summary for the AWS EKS resources for this integration. + awseks: ResourceTypeSummary; +}; + +// ResourceTypeSummary contains the summary of the enrollment rules and found resources by the integration. +export type ResourceTypeSummary = { + // rulesCount is the number of enrollment rules that are using this integration. + // A rule is a matcher in a DiscoveryConfig that is being processed by a DiscoveryService. + // If the DiscoveryService is not reporting any Status, it means it is not being processed and it doesn't count for the number of rules. + // Example 1: a DiscoveryConfig with a matcher whose Type is "EC2" for two regions count as two EC2 rules. + // Example 2: a DiscoveryConfig with a matcher whose Types is "EC2,RDS" for one regions count as one EC2 rule. + // Example 3: a DiscoveryConfig with a matcher whose Types is "EC2,RDS", but has no DiscoveryService using it, it counts as 0 rules. + rulesCount: number; + // resourcesFound contains the count of resources found by this integration. + resourcesFound: number; + // resourcesEnrollmentFailed contains the count of resources that failed to enroll into the cluster. + resourcesEnrollmentFailed: number; + // resourcesEnrollmentSuccess contains the count of resources that succeeded to enroll into the cluster. + resourcesEnrollmentSuccess: number; + // discoverLastSync contains the time when this integration tried to auto-enroll resources. + discoverLastSync: number; + // ecsDatabaseServiceCount is the total number of DatabaseServices that were deployed into Amazon ECS. + // Only applicable for AWS RDS resource summary. + ecsDatabaseServiceCount: number; +}; + // awsRegionMap maps the AWS regions to it's region name // as defined in (omitted gov cloud regions): // https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html