From ee97e9e34e966a700036ad707654836a35066377 Mon Sep 17 00:00:00 2001 From: Jonathan Langlois <37274633+jlangy@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:35:47 -0800 Subject: [PATCH 1/7] feat: api route for client logs (#1095) * chore: backend create backend route for logs * fix: sonar add recomended sonar fixes * fix: sonar more sonarcloud fixes * chore: refactor move validation logic to controller * chore: sonar remove unused import --- .github/workflows/terraform.yml | 6 + lambda/__tests__/15.sso-logs.test.ts | 169 +++++++++++++++++++++++++++ lambda/__tests__/helpers/fixtures.ts | 4 +- lambda/app/src/controllers/logs.ts | 55 +++++++++ lambda/app/src/grafana.ts | 20 ++++ lambda/app/src/routes.ts | 14 +++ terraform/lambda-app.tf | 2 + terraform/variables.tf | 10 ++ 8 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 lambda/__tests__/15.sso-logs.test.ts create mode 100644 lambda/app/src/controllers/logs.ts create mode 100644 lambda/app/src/grafana.ts diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index e06280b48..8aa699335 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -33,6 +33,8 @@ jobs: API_URL=https://gcp9dahm4c.execute-api.ca-central-1.amazonaws.com/test/app API_AUTH_SECRET=${{ secrets.API_AUTH_SECRET }} APP_ENV=development + SSO_LOGS_URL=${{ secrets.DEV_SSO_LOGS_URL }} + GRAFANA_API_TOKEN=${{ secrets.DEV_GRAFANA_API_TOKEN }} TF_STATE_BUCKET=xgr00q-dev-sso-requests TF_STATE_BUCKET_KEY=css-tf-dev-state TF_STATE_DYNAMODB_TABLE=xgr00q-dev-sso-requests-state-locking @@ -69,6 +71,8 @@ jobs: API_URL=https://kgodz1zmk2.execute-api.ca-central-1.amazonaws.com/test/app API_AUTH_SECRET=${{ secrets.API_AUTH_SECRET }} APP_ENV=production + SSO_LOGS_URL=${{ secrets.PROD_SSO_LOGS_URL }} + GRAFANA_API_TOKEN=${{ secrets.PROD_GRAFANA_API_TOKEN }} TF_STATE_BUCKET=xgr00q-prod-sso-requests TF_STATE_BUCKET_KEY=css-tf-prod-state TF_STATE_DYNAMODB_TABLE=xgr00q-prod-sso-requests-state-locking @@ -209,6 +213,8 @@ jobs: custom_domain_name="${{ env.CUSTOM_DOMAIN_NAME }}" uptime_status_domain_name="${{ env.UPTIME_STATUS_DOMAIN_NAME }}" aws_ecr_uri="${{ env.AWS_ECR_URI }}" + grafana_api_token="${{ env.GRAFANA_API_TOKEN }}" + sso_logs_url="${{ env.SSO_LOGS_URL }}" include_digital_credential="${{ env.INCLUDE_DIGITAL_CREDENTIAL }}" EOF diff --git a/lambda/__tests__/15.sso-logs.test.ts b/lambda/__tests__/15.sso-logs.test.ts new file mode 100644 index 000000000..3b1593dfd --- /dev/null +++ b/lambda/__tests__/15.sso-logs.test.ts @@ -0,0 +1,169 @@ +import app from './helpers/server'; +import supertest from 'supertest'; +import { APP_BASE_PATH } from './helpers/constants'; +import { cleanUpDatabaseTables, createMockAuth } from './helpers/utils'; +import { getUpdateIntegrationData } from './helpers/fixtures'; +import { models } from '@lambda-shared/sequelize/models/models'; +import { queryGrafana } from '../app/src/grafana'; + +jest.mock('../app/src/authenticate'); +jest.mock('../app/src/grafana', () => { + return { + queryGrafana: jest.fn(() => Promise.resolve(['log', 'log'])), + }; +}); + +describe('Fetch SSO Logs', () => { + const integration = getUpdateIntegrationData({ integration: { projectName: 'test_project' } }); + + afterEach(async () => { + await cleanUpDatabaseTables(); + }); + + const MOCK_USER_ID = -1; + const MOCK_USER_EMAIL = 'test@user.com'; + const INTEGRATION_ID = -1; + + // Create integration owned by mock user to test against + const setupIntegrationAndUser = async () => { + await models.user.create({ + id: MOCK_USER_ID, + idirEmail: MOCK_USER_EMAIL, + }); + await models.request.create({ + ...integration, + id: INTEGRATION_ID, + usesTeam: false, + userId: MOCK_USER_ID, + }); + }; + + // A valid query string to use + const queryString = `env=dev&start=2020-10-10&end=2020-10-12&eventType=LOGIN`; + + it('Handles validation correctly for directly owned requests', async () => { + await setupIntegrationAndUser(); + // Create session with a different user login, expect 401 + createMockAuth('1', 'wrong@user.com'); + let response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${queryString}`) + .set('Accept', 'application/json'); + + expect(response.status).toBe(401); + + // Create session with actual user, expect 200 + createMockAuth('2', MOCK_USER_EMAIL); + response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${queryString}`) + .set('Accept', 'application/json'); + + expect(response.status).toBe(200); + }); + + it('Handles authentication correctly for team ownership', async () => { + const TEAM_MEMBER_EMAIL = 'team@member.com'; + const NOT_A_TEAM_EMAIL = 'not_a_team@member.com'; + const INTEGRATION_ID = -1; + + await models.team.create({ + id: -1, + name: 'test_team', + }); + await models.user.create({ + id: -2, + idirEmail: TEAM_MEMBER_EMAIL, + }); + await models.request.create({ + ...integration, + id: INTEGRATION_ID, + usesTeam: true, + teamId: -1, + }); + await models.usersTeam.create({ + userId: -2, + teamId: -1, + role: 'member', + pending: false, + }); + + // Create session as team member, expect 200 + createMockAuth('1', TEAM_MEMBER_EMAIL); + let response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${queryString}`) + .set('Accept', 'application/json'); + + expect(response.status).toBe(200); + + // Create session not on the team. Expect 401 + createMockAuth('2', NOT_A_TEAM_EMAIL); + response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${queryString}`) + .set('Accept', 'application/json'); + + expect(response.status).toBe(401); + }); + + it('Responds with 400 if supplied query parameters are wrong', async () => { + await setupIntegrationAndUser(); + + // Covering some common invalid cases + const invalidParams = [ + // Missing params + '', + 'env=dev', + 'env=dev&eventType=LOGIN', + 'env=dev&eventType=LOGIN&start=2022-10-10', + // Invalid values + 'env=development&eventType=LOGIN&start=2022-10-10&end=2022-10-10', + 'env=dev&eventType=MISSING&start=2022-10-10&end=2022-10-10', + 'env=dev&eventType=LOGIN&start=somedate&end=2022-10-10', + 'env=dev&eventType=LOGIN&start=2022-10-10&end=anotherDate', + 'env=dev&eventType=LOGIN&start=2022-14-10&end=2022-10-42', + ]; + + const validParams = [ + 'env=dev&eventType=LOGIN&start=2022-10-10&end=2022-10-10', + 'env=test&eventType=LOGIN&start=2022-10-10&end=2022-10-10', + 'env=prod&eventType=LOGIN&start=2022-10-10&end=2022-10-10', + 'env=dev&eventType=REFRESH_TOKEN&start=2022-10-10&end=2022-10-10', + 'env=dev&eventType=CODE_TO_TOKEN&start=2022-12-10&end=2022-10-10', + ]; + + createMockAuth('2', MOCK_USER_EMAIL); + for (const params of invalidParams) { + const response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${params}`) + .set('Accept', 'application/json'); + expect(response.status).toBe(400); + } + + for (const params of validParams) { + const response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${params}`) + .set('Accept', 'application/json'); + expect(response.status).toBe(200); + } + }); + + it('Returns 500 if unexpected error when fetching logs', async () => { + await setupIntegrationAndUser(); + (queryGrafana as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('err'))); + + const response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${queryString}`) + .set('Accept', 'application/json'); + + expect(response.status).toBe(500); + }); + + it('Returns the expected logs in JSON array if successful', async () => { + await setupIntegrationAndUser(); + + const response = await supertest(app) + .get(`${APP_BASE_PATH}/requests/${INTEGRATION_ID}/logs?${queryString}`) + .set('Accept', 'application/json'); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual(['log', 'log']); + }); +}); diff --git a/lambda/__tests__/helpers/fixtures.ts b/lambda/__tests__/helpers/fixtures.ts index 2a3617244..ca96c702d 100644 --- a/lambda/__tests__/helpers/fixtures.ts +++ b/lambda/__tests__/helpers/fixtures.ts @@ -128,8 +128,8 @@ export const getUpdateIntegrationData = (args: { }) => { const { projectName = args.integration.projectName, - envs = args.integration.environments.length > 1 ? args.integration.environments : ['dev'], - identityProviders = args.integration.devIdps.length > 1 ? args.integration.devIdps : ['idir'], + envs = args.integration.environments?.length > 1 ? args.integration.environments : ['dev'], + identityProviders = args.integration.devIdps?.length > 1 ? args.integration.devIdps : ['idir'], protocol = args.integration.protocol || 'oidc', authType = args.integration.authType || 'browser-login', publicAccess = args.integration.publicAccess || true, diff --git a/lambda/app/src/controllers/logs.ts b/lambda/app/src/controllers/logs.ts new file mode 100644 index 000000000..34b2d17dc --- /dev/null +++ b/lambda/app/src/controllers/logs.ts @@ -0,0 +1,55 @@ +import { getAllowedRequest } from '@lambda-app/queries/request'; +import { Session } from '@lambda-shared/interfaces'; +import { queryGrafana } from '@lambda-app/grafana'; + +const LOG_SIZE_LIMIT = 2000; + +export const fetchLogs = async ( + session: Session, + env: string, + id: number, + start: string, + end: string, + eventType: string, +) => { + // Check user owns requested logs + const userRequest = await getAllowedRequest(session, id); + if (!userRequest) return { status: 401, message: "You are not authorized to view this request's logs" }; + + const { clientId } = userRequest; + // Validate user supplied inputs + const hasRequiredQueryParams = start && end && env && eventType; + const allowedEnvs = ['dev', 'test', 'prod']; + const allowedEvents = ['LOGIN', 'CODE_TO_TOKEN', 'REFRESH_TOKEN']; + + if (!hasRequiredQueryParams) { + return { status: 400, message: 'Not all query params sent. Please include start, end, env, and eventType.' }; + } + if (!allowedEnvs.includes(env)) { + return { status: 400, message: `The env query param must be one of ${allowedEnvs.join(', ')}.` }; + } + if (!allowedEvents.includes(eventType)) { + return { status: 400, message: "The eventType query param must be one of ${allowedEvents.join(', ')}" }; + } + + const unixStartTime = new Date(start).getTime() / 1000; + const unixEndTime = new Date(end).getTime() / 1000; + + const validTime = (time) => !Number.isNaN(time) && time > 0; + + if (!validTime(unixStartTime) || !validTime(unixEndTime)) { + return { status: 400, message: 'Include parsable dates for the start and end parameters, e.g YYYY-MM-DD.' }; + } + + try { + const query = `{environment="${env}", client_id="${clientId}", event_type="${eventType}"}`; + const allLogs = await queryGrafana(query, unixStartTime, unixEndTime, LOG_SIZE_LIMIT); + let message = 'All logs retrieved'; + if (allLogs.length === LOG_SIZE_LIMIT) + message = 'Log limit reached. There may be more logs available, try a more restricted date range.'; + return { status: 200, message, data: allLogs }; + } catch (err) { + console.info(`Error while fetching logs from loki: ${err}`); + return { status: 500, message: 'Unexpected error fetching logs.' }; + } +}; diff --git a/lambda/app/src/grafana.ts b/lambda/app/src/grafana.ts new file mode 100644 index 000000000..5a1de57f2 --- /dev/null +++ b/lambda/app/src/grafana.ts @@ -0,0 +1,20 @@ +import axios from 'axios'; + +export const queryGrafana = async (query: string, start: number, end: number, limit: number) => { + const results = await axios.get(`${process.env.SSO_LOGS_URL}/v1/query_range`, { + params: { + query, + start, + end, + limit, + }, + headers: { + Authorization: `Bearer ${process.env.GRAFANA_API_TOKEN}`, + }, + }); + return results.data.data.result.reduce((acc, curr) => { + // Returning just the log, id in first position + const currentLogs = curr.values.map((val) => val[1]); + return acc.concat(currentLogs); + }, []); +}; diff --git a/lambda/app/src/routes.ts b/lambda/app/src/routes.ts index 2a6e08d51..6fc45f70b 100644 --- a/lambda/app/src/routes.ts +++ b/lambda/app/src/routes.ts @@ -64,6 +64,7 @@ import { assertSessionRole } from './helpers/permissions'; import { fetchDiscussions } from './graphql'; import { sendTemplate } from '@lambda-shared/templates'; import { EMAILS } from '@lambda-shared/enums'; +import { fetchLogs } from '@lambda-app/controllers/logs'; const APP_URL = process.env.APP_URL || ''; @@ -270,6 +271,19 @@ export const setRoutes = (app: any) => { } }); + app.get(`/requests/:id/logs`, async (req, res) => { + try { + const { id } = req.params || {}; + const { start, end, env, eventType } = req.query || {}; + const { status, message, data } = await fetchLogs(req.session, env, id, start, end, eventType); + + if (status === 200) res.status(status).send({ message, data }); + else res.status(status).send({ message }); + } catch (err) { + handleError(res, err); + } + }); + app.delete(`/requests`, async (req, res) => { try { const { id } = req.query || {}; diff --git a/terraform/lambda-app.tf b/terraform/lambda-app.tf index 956903ba2..aa10ae81d 100644 --- a/terraform/lambda-app.tf +++ b/terraform/lambda-app.tf @@ -51,6 +51,8 @@ resource "aws_lambda_function" "app" { CHES_PASSWORD = var.ches_password CHES_USERNAME = var.ches_username INCLUDE_DIGITAL_CREDENTIAL = var.include_digital_credential + SSO_LOGS_URL = var.sso_logs_url + GRAFANA_API_TOKEN = var.grafana_api_token } } diff --git a/terraform/variables.tf b/terraform/variables.tf index 806e8cb49..53ead70f7 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -28,6 +28,16 @@ variable "include_digital_credential" { default = "false" } +variable "grafana_api_token" { + description = "API token for the grafana service account." + type = string +} + +variable "sso_logs_url" { + description = "Base url to call loki for logs" + type = string +} + variable "sso_client_id" { description = "The required audience for authentication" type = string From 64b35ee36b4d7c8a0a4b7d2093ee7e8b3948193e Mon Sep 17 00:00:00 2001 From: Jonathan Langlois <37274633+jlangy@users.noreply.github.com> Date: Tue, 12 Dec 2023 14:59:25 -0800 Subject: [PATCH 2/7] chore: change grafana api route (#1096) * chore: backend create backend route for logs * fix: sonar add recomended sonar fixes * fix: sonar more sonarcloud fixes * chore: refactor move validation logic to controller * chore: sonar remove unused import * chore: api route change grafana endpoint * chore: remove uid fetch uid on all requests instead of env var * chore: refactor factor out uid fetch to a function --- .github/workflows/terraform.yml | 6 ++-- lambda/app/src/controllers/logs.ts | 4 +-- lambda/app/src/grafana.ts | 56 +++++++++++++++++++++++------- terraform/lambda-app.tf | 2 +- terraform/variables.tf | 4 +-- 5 files changed, 51 insertions(+), 21 deletions(-) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 8aa699335..91ef62be0 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -33,7 +33,7 @@ jobs: API_URL=https://gcp9dahm4c.execute-api.ca-central-1.amazonaws.com/test/app API_AUTH_SECRET=${{ secrets.API_AUTH_SECRET }} APP_ENV=development - SSO_LOGS_URL=${{ secrets.DEV_SSO_LOGS_URL }} + GRAFANA_API_URL=https://sso-grafana-sandbox.apps.gold.devops.gov.bc.ca/api GRAFANA_API_TOKEN=${{ secrets.DEV_GRAFANA_API_TOKEN }} TF_STATE_BUCKET=xgr00q-dev-sso-requests TF_STATE_BUCKET_KEY=css-tf-dev-state @@ -71,7 +71,7 @@ jobs: API_URL=https://kgodz1zmk2.execute-api.ca-central-1.amazonaws.com/test/app API_AUTH_SECRET=${{ secrets.API_AUTH_SECRET }} APP_ENV=production - SSO_LOGS_URL=${{ secrets.PROD_SSO_LOGS_URL }} + GRAFANA_API_URL=https://sso-grafana.apps.gold.devops.gov.bc.ca/api GRAFANA_API_TOKEN=${{ secrets.PROD_GRAFANA_API_TOKEN }} TF_STATE_BUCKET=xgr00q-prod-sso-requests TF_STATE_BUCKET_KEY=css-tf-prod-state @@ -214,7 +214,7 @@ jobs: uptime_status_domain_name="${{ env.UPTIME_STATUS_DOMAIN_NAME }}" aws_ecr_uri="${{ env.AWS_ECR_URI }}" grafana_api_token="${{ env.GRAFANA_API_TOKEN }}" - sso_logs_url="${{ env.SSO_LOGS_URL }}" + grafana_api_url="${{ env.GRAFANA_API_URL }}" include_digital_credential="${{ env.INCLUDE_DIGITAL_CREDENTIAL }}" EOF diff --git a/lambda/app/src/controllers/logs.ts b/lambda/app/src/controllers/logs.ts index 34b2d17dc..11739857a 100644 --- a/lambda/app/src/controllers/logs.ts +++ b/lambda/app/src/controllers/logs.ts @@ -32,8 +32,8 @@ export const fetchLogs = async ( return { status: 400, message: "The eventType query param must be one of ${allowedEvents.join(', ')}" }; } - const unixStartTime = new Date(start).getTime() / 1000; - const unixEndTime = new Date(end).getTime() / 1000; + const unixStartTime = new Date(start).getTime(); + const unixEndTime = new Date(end).getTime(); const validTime = (time) => !Number.isNaN(time) && time > 0; diff --git a/lambda/app/src/grafana.ts b/lambda/app/src/grafana.ts index 5a1de57f2..62172519c 100644 --- a/lambda/app/src/grafana.ts +++ b/lambda/app/src/grafana.ts @@ -1,20 +1,50 @@ import axios from 'axios'; +export const fetchDatasourceUID = async (datasourceName) => { + return axios + .get(`${process.env.GRAFANA_API_URL}/datasources`, { + headers: { + Authorization: `Bearer ${process.env.GRAFANA_API_TOKEN}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + .then((res) => res.data.find((source) => source.name === datasourceName)?.uid); +}; + export const queryGrafana = async (query: string, start: number, end: number, limit: number) => { - const results = await axios.get(`${process.env.SSO_LOGS_URL}/v1/query_range`, { - params: { - query, - start, - end, - limit, + const lokiUID = await fetchDatasourceUID('SSO Loki'); + const response = await axios.post( + `${process.env.GRAFANA_API_URL}/ds/query`, + { + queries: [ + { + refId: 'logs', + expr: query, + queryType: 'range', + datasource: { + type: 'loki', + uid: lokiUID, + }, + maxLines: limit, + }, + ], + from: String(start), + to: String(end), }, - headers: { - Authorization: `Bearer ${process.env.GRAFANA_API_TOKEN}`, + { + params: { + ds_type: 'loki', + }, + headers: { + Authorization: `Bearer ${process.env.GRAFANA_API_TOKEN}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, }, - }); - return results.data.data.result.reduce((acc, curr) => { - // Returning just the log, id in first position - const currentLogs = curr.values.map((val) => val[1]); - return acc.concat(currentLogs); + ); + return response.data.results.logs.frames.reduce((acc, curr) => { + // Raw logs are in third array position. First entries are filenames and IDs + return acc.concat(curr.data.values[2]); }, []); }; diff --git a/terraform/lambda-app.tf b/terraform/lambda-app.tf index aa10ae81d..66d37ffc8 100644 --- a/terraform/lambda-app.tf +++ b/terraform/lambda-app.tf @@ -51,8 +51,8 @@ resource "aws_lambda_function" "app" { CHES_PASSWORD = var.ches_password CHES_USERNAME = var.ches_username INCLUDE_DIGITAL_CREDENTIAL = var.include_digital_credential - SSO_LOGS_URL = var.sso_logs_url GRAFANA_API_TOKEN = var.grafana_api_token + GRAFANA_API_URL = var.grafana_api_url } } diff --git a/terraform/variables.tf b/terraform/variables.tf index 53ead70f7..d9e3c5f70 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -33,8 +33,8 @@ variable "grafana_api_token" { type = string } -variable "sso_logs_url" { - description = "Base url to call loki for logs" +variable "grafana_api_url" { + description = "Base url to call the grafana api" type = string } From aaa594618f6526bba81bc5dab341934ddc744a33 Mon Sep 17 00:00:00 2001 From: Nithin Shekar Kuruba <81444731+NithinKuruba@users.noreply.github.com> Date: Wed, 13 Dec 2023 20:58:47 -0800 Subject: [PATCH 3/7] feat: metrics tab implementation to query aggregated logs from grafana (#1097) * feat: metrics tab initial commit * feat: metrics tab minor tweaks * feat: metrics tab minor tweaks 02 * feat: metrics tab unit tests for endpoint * feat: updated metric types --- app/components/DateTimePicker.tsx | 36 +++ app/interfaces/Request.ts | 5 + app/package.json | 4 + .../IntegrationInfoTabs/MetricsPanel.tsx | 177 +++++++++++ .../IntegrationInfoTabs/index.tsx | 15 + app/services/grafana.ts | 14 + app/yarn.lock | 300 +++++++++++++++++- .../__tests__/16.integration-metrics.test.ts | 92 ++++++ .../__tests__/helpers/modules/integrations.ts | 6 + lambda/app/src/controllers/logs.ts | 44 ++- lambda/app/src/grafana.ts | 38 ++- lambda/app/src/routes.ts | 14 +- localserver/.env.example | 2 + 13 files changed, 739 insertions(+), 8 deletions(-) create mode 100644 app/components/DateTimePicker.tsx create mode 100644 app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx create mode 100644 app/services/grafana.ts create mode 100644 lambda/__tests__/16.integration-metrics.test.ts diff --git a/app/components/DateTimePicker.tsx b/app/components/DateTimePicker.tsx new file mode 100644 index 000000000..e6bb61100 --- /dev/null +++ b/app/components/DateTimePicker.tsx @@ -0,0 +1,36 @@ +import DatePicker, { ReactDatePickerProps } from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCalendar } from '@fortawesome/free-solid-svg-icons'; +import { FormLabel } from 'react-bootstrap'; + +export default function DateTimePicker(props: ReactDatePickerProps & { label: string }) { + return ( + <> + } + enableTabLoop={false} + popperPlacement="bottom-start" + maxDate={new Date()} + {...props} + /> + + ); +} + +function DateTimeInput({ value, onClick, label }: any) { + return ( + <> + {label} +
+ +
+ + + +
+
+ + ); +} diff --git a/app/interfaces/Request.ts b/app/interfaces/Request.ts index 3ef1f68c9..84bed60f9 100644 --- a/app/interfaces/Request.ts +++ b/app/interfaces/Request.ts @@ -103,3 +103,8 @@ export interface ClientRole { name: string; composites: string[]; } + +export interface EventCountMetric { + event: string; + count: number; +} diff --git a/app/package.json b/app/package.json index 7e4bcb596..f2a4efe7e 100644 --- a/app/package.json +++ b/app/package.json @@ -49,10 +49,12 @@ "lodash.tolower": "^4.1.2", "lodash.trim": "^4.5.1", "lodash.uniq": "^4.5.0", + "moment": "^2.29.4", "next": "13.2.1", "re-resizable": "6.9.9", "react": "18.2.0", "react-bootstrap": "2.5.0", + "react-datepicker": "^4.24.0", "react-dom": "18.2.0", "react-idle-timer": "^5.7.2", "react-infinite-scroller": "1.2.6", @@ -63,6 +65,7 @@ "react-select": "^5.7.2", "react-table": "^7.8.0", "react-typography": "0.16.20", + "recharts": "^2.10.3", "styled-components": "5.3.5", "validator": "13.7.0", "xlsx": "^0.18.5" @@ -99,6 +102,7 @@ "@types/lodash.trim": "^4.5.7", "@types/lodash.uniq": "^4.5.7", "@types/react": "18.0.21", + "@types/react-datepicker": "^4.19.4", "@types/react-infinite-scroller": "1.2.3", "@types/react-jsonschema-form": "1.7.8", "@types/react-select": "^5.0.1", diff --git a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx new file mode 100644 index 000000000..a7886c460 --- /dev/null +++ b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { EventCountMetric, Integration } from 'interfaces/Request'; +import { withTopAlert } from 'layout/TopAlert'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tabs, Tab } from '@bcgov-sso/common-react-components'; +import startCase from 'lodash.startcase'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Text, Legend } from 'recharts'; +import { getMetrics } from '@app/services/grafana'; +import throttle from 'lodash.throttle'; +import moment from 'moment'; +import DateTimePicker from '@app/components/DateTimePicker'; +import { InfoMessage } from '@app/components/MessageBox'; +import { Link } from '@button-inc/bcgov-theme'; + +export const DatePickerContainer = styled.div` + height: 100%; + display: flex; + align-items: center; + padding-right: 15px; + & > * { + margin-left: 15px; + } +`; + +const Label = styled.label` + margin-bottom: 2px; +`; + +const TopMargin = styled.div` + height: var(--field-top-spacing); +`; + +const LeftTitle = styled.span` + color: #000; + font-size: 1.1rem; + font-weight: bold; +`; + +const PaddedIcon = styled(FontAwesomeIcon)` + margin-right: 20px; +`; + +const StyledP = styled.div` + margin-bottom: 5px; + display: flex; + align-items: center; +`; + +const StyledHr = styled.hr` + background-color: black; +`; + +interface Props { + integration: Integration; +} + +const metricsStartDate = 'December 01, 2023'; +const MetricsPanel = ({ integration }: Props) => { + const [environment, setEnvironment] = useState('dev'); + const environments = integration?.environments || []; + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(false); + const [fromDate, setFromDate] = useState(moment().subtract(14, 'days').format('YYYY-MM-DD')); + const [toDate, setToDate] = useState(moment().format('YYYY-MM-DD')); + const handleTabSelect = (key: string) => { + setEnvironment(key); + }; + + const getFormattedDate = (val: Date) => { + return moment.utc(val).format('YYYY-MM-DD'); + }; + + const handleFromDateChange = (val: Date) => { + setFromDate(getFormattedDate(val)); + }; + + const handleToDateChange = (val: Date) => { + setToDate(getFormattedDate(val)); + }; + + const fetchMetrics = useCallback( + throttle(async (environment: string) => { + const [metricsData, err] = await getMetrics(integration?.id as number, environment, fromDate, toDate); + + if (err || metricsData.length === 0) { + setMetrics([]); + } else { + setMetrics(metricsData); + } + }), + [integration?.clientId, environment, fromDate, toDate], + ); + + useEffect(() => { + fetchMetrics(environment); + }, [integration?.clientId, environment, fromDate, toDate]); + + return ( + <> + + +
+ + handleFromDateChange(date)} + minDate={new Date(metricsStartDate)} + label="Start Date" + /> + handleToDateChange(date)} + minDate={new Date(metricsStartDate)} + label="End Date" + /> + +
+ + +
+ + {environments.map((env) => ( + +
+ {metrics?.length > 0 ? ( + + + + + + + + + + + ) : ( +
+ No data available yet! +
+ )} +
+
+ ))} +
+ + This tab was released {metricsStartDate}. Please refer to{' '} + + here + {' '} + for event type details. + + + ); +}; + +export default withTopAlert(MetricsPanel); diff --git a/app/page-partials/my-dashboard/IntegrationInfoTabs/index.tsx b/app/page-partials/my-dashboard/IntegrationInfoTabs/index.tsx index 8b86be6bd..cdb6da20a 100644 --- a/app/page-partials/my-dashboard/IntegrationInfoTabs/index.tsx +++ b/app/page-partials/my-dashboard/IntegrationInfoTabs/index.tsx @@ -24,6 +24,7 @@ import BceidStatusPanel from './BceidStatusPanel'; import GithubStatusPanel from './GithubStatusPanel'; import ServiceAccountRoles from 'page-partials/my-dashboard/ServiceAccountRoles'; import DigitalCredentialPanel from './DigitalCredentialPanel'; +import MetricsPanel from './MetricsPanel'; const TabWrapper = styled.div<{ short?: boolean }>` padding-left: 1rem; @@ -44,6 +45,7 @@ const TAB_USER_ROLE_MANAGEMENT = 'user-role-management'; const TAB_SERVICE_ACCOUNT_ROLE_MANAGEMENT = 'service-account-role-management'; const TAB_SECRET = 'secret'; const TAB_HISTORY = 'history'; +const TAB_METRICS = 'metrics'; const joinEnvs = (integration: Integration) => { if (!integration?.environments) return ''; @@ -210,6 +212,16 @@ const getSecretsTab = ({ integration }: { integration: Integration }) => { ); }; +const getMetricsTab = ({ integration }: { integration: Integration }) => { + return ( + + + + + + ); +}; + const getHistoryTab = ({ integration }: { integration: Integration }) => { return ( @@ -327,6 +339,9 @@ function IntegrationInfoTabs({ integration }: Props) { tabs.push(getHistoryTab({ integration })); allowedTabs.push(TAB_HISTORY); + + tabs.push(getMetricsTab({ integration })); + allowedTabs.push(TAB_METRICS); } let activeKey = activeTab; diff --git a/app/services/grafana.ts b/app/services/grafana.ts new file mode 100644 index 000000000..4f1b44fba --- /dev/null +++ b/app/services/grafana.ts @@ -0,0 +1,14 @@ +import { instance } from './axios'; + +export const getMetrics = async (id: number, env: string, fromDate?: string, toDate?: string) => { + try { + const result = await instance + .get(`requests/${id}/metrics?env=${env}&fromDate=${fromDate}&toDate=${toDate}`) + .then((res: any) => res?.data); + + return [result, null]; + } catch (err) { + console.error(err); + return [null, err]; + } +}; diff --git a/app/yarn.lock b/app/yarn.lock index 0732fa3cd..abf4b6dd0 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1054,6 +1054,13 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" +"@babel/runtime@^7.1.2": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.5.tgz#11edb98f8aeec529b82b211028177679144242db" + integrity sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.14.6", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.4.2", "@babel/runtime@^7.7.6": version "7.22.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.6.tgz#57d64b9ae3cff1d67eb067ae117dac087f5bd438" @@ -1068,6 +1075,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.21.0": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" + integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.3.3": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" @@ -1874,6 +1888,11 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== +"@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + "@react-aria/ssr@^3.2.0": version "3.3.0" resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.3.0.tgz#25e81daf0c7a270a4a891159d8d984578e4512d8" @@ -2203,6 +2222,57 @@ dependencies: "@babel/types" "^7.3.0" +"@types/d3-array@^3.0.3": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5" + integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-ease@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-interpolate@^3.0.1": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.2.tgz#4327f4a05d475cf9be46a93fc2e0f8d23380805a" + integrity sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA== + +"@types/d3-scale@^4.0.2": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" + integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0": + version "3.1.6" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72" + integrity sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*", "@types/d3-time@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" + integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw== + +"@types/d3-timer@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + "@types/graceful-fs@^4.1.3": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" @@ -2490,6 +2560,16 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== +"@types/react-datepicker@^4.19.4": + version "4.19.4" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.19.4.tgz#07807187ead4c7025bc0039fe05eb5713298f3e4" + integrity sha512-HRD0LHTxBVe61LRJgTdPscbapLQl7+jI/7bxnPGpvzdJ/iXN9q7ucYv8HKULeIAN84O5LzFhwTMOkO4QnIUJaQ== + dependencies: + "@popperjs/core" "^2.9.2" + "@types/react" "*" + date-fns "^2.0.1" + react-popper "^2.2.5" + "@types/react-dom@^18.0.0": version "18.0.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" @@ -3187,6 +3267,11 @@ clsx@1.2.1, clsx@^1.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== +clsx@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" + integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -3413,6 +3498,77 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-ease@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-format@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== + +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -3427,6 +3583,13 @@ data-urls@^3.0.2: whatwg-mimetype "^3.0.0" whatwg-url "^11.0.0" +date-fns@^2.0.1, date-fns@^2.30.0: + version "2.30.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" + integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== + dependencies: + "@babel/runtime" "^7.21.0" + debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -3453,6 +3616,11 @@ decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decimal.js-light@^2.4.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + decimal.js@^10.3.1: version "10.3.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" @@ -3542,6 +3710,13 @@ dom-align@^1.7.0: resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.12.4.tgz#3503992eb2a7cfcb2ed3b2a6d21e0b9c00d54511" integrity sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw== +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + dom-helpers@^5.0.1, dom-helpers@^5.2.0, dom-helpers@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -3946,6 +4121,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +eventemitter3@^4.0.1: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -3982,6 +4162,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-equals@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.0.1.tgz#a4eefe3c5d1c0d021aeed0bc10ba5e0c12ee405d" + integrity sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ== + fast-glob@^3.2.11: version "3.2.12" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" @@ -4502,6 +4687,11 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -5508,7 +5698,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.13.1, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4: +lodash@^4.13.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -5645,6 +5835,11 @@ modularscale@^1.0.2: dependencies: lodash.isnumber "^3.0.0" +moment@^2.29.4: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -6263,6 +6458,18 @@ react-bootstrap@2.5.0: uncontrollable "^7.2.1" warning "^4.0.3" +react-datepicker@^4.24.0: + version "4.24.0" + resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.24.0.tgz#dfb12e277993f1ae2d350b7ba4dd6bba7d21bfb1" + integrity sha512-2QUC2pP+x4v3Jp06gnFllxKsJR0yoT/K6y86ItxEsveTXUpsx+NBkChWXjU0JsGx/PL8EQnsxN0wHl4zdA1m/g== + dependencies: + "@popperjs/core" "^2.11.8" + classnames "^2.2.6" + date-fns "^2.30.0" + prop-types "^15.7.2" + react-onclickoutside "^6.13.0" + react-popper "^2.3.0" + react-dom@18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -6271,6 +6478,11 @@ react-dom@18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-fast-compare@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" + integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== + react-idle-timer@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/react-idle-timer/-/react-idle-timer-5.7.2.tgz#f506db28a86645dd1b87987116501703e512142b" @@ -6283,7 +6495,7 @@ react-infinite-scroller@1.2.6: dependencies: prop-types "^15.5.8" -react-is@^16.12.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.4, react-is@^16.9.0: +react-is@^16.10.2, react-is@^16.12.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.4, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -6333,11 +6545,24 @@ react-multi-select-component@4.3.3: resolved "https://registry.yarnpkg.com/react-multi-select-component/-/react-multi-select-component-4.3.3.tgz#142c0c9b51a3dbe99d117e90eaa14f48041174b6" integrity sha512-V8cDJC3M7F27PWv1baV8FpJReHa/SbpJGL80CmXwnlMkDK2KMlQSRDmDzBnmCjcbROIgoztdW+gYBpqo9BIF4g== +react-onclickoutside@^6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz#e165ea4e5157f3da94f4376a3ab3e22a565f4ffc" + integrity sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A== + react-placeholder@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/react-placeholder/-/react-placeholder-4.1.0.tgz#943128820b3b0a6f94371655aadec18d306e05e3" integrity sha512-z1HGD86NWJTYTQumHsmGH9jkozv4QHa9dju/vHVUd4f1svu23pf5v7QoBLBfs3kA1S9GLJaCeRMHLbO2SCdz5A== +react-popper@^2.2.5, react-popper@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba" + integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-property@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.0.tgz#2156ba9d85fa4741faf1918b38efc1eae3c6a136" @@ -6368,6 +6593,14 @@ react-sizeme@^3.0.1: shallowequal "^1.1.0" throttle-debounce "^3.0.1" +react-smooth@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-2.0.5.tgz#d153b7dffc7143d0c99e82db1532f8cf93f20ecd" + integrity sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA== + dependencies: + fast-equals "^5.0.0" + react-transition-group "2.9.0" + react-syntax-highlighter@^15.4.3: version "15.5.0" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz#4b3eccc2325fa2ec8eff1e2d6c18fa4a9e07ab20" @@ -6384,6 +6617,16 @@ react-table@^7.8.0: resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2" integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA== +react-transition-group@2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react-transition-group@^4.3.0, react-transition-group@^4.4.2: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" @@ -6419,6 +6662,27 @@ readable-stream@^2.2.2: string_decoder "~1.1.1" util-deprecate "~1.0.1" +recharts-scale@^0.4.4: + version "0.4.5" + resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9" + integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w== + dependencies: + decimal.js-light "^2.4.1" + +recharts@^2.10.3: + version "2.10.3" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.10.3.tgz#a5dbe219354d744701e8bbd116fe42393af92f6b" + integrity sha512-G4J96fKTZdfFQd6aQnZjo2nVNdXhp+uuLb00+cBTGLo85pChvm1+E67K3wBOHDE/77spcYb2Cy9gYWVqiZvQCg== + dependencies: + clsx "^2.0.0" + eventemitter3 "^4.0.1" + lodash "^4.17.19" + react-is "^16.10.2" + react-smooth "^2.0.5" + recharts-scale "^0.4.4" + tiny-invariant "^1.3.1" + victory-vendor "^36.6.8" + redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -6458,6 +6722,11 @@ regenerator-runtime@^0.13.7: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== +regenerator-runtime@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45" + integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA== + regenerator-transform@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" @@ -6971,6 +7240,11 @@ tiny-glob@^0.2.9: globalyzer "0.1.0" globrex "^0.1.2" +tiny-invariant@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" + integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -7228,6 +7502,26 @@ validator@13.7.0: resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw== +victory-vendor@^36.6.8: + version "36.7.0" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.7.0.tgz#e02af33e249e74e659fa65c6d5936042c42e7aa8" + integrity sha512-nqYuTkLSdTTeACyXcCLbL7rl0y6jpzLPtTNGOtSnajdR+xxMxBdjMxDjfNJNlhR+ZU8vbXz+QejntcbY7h9/ZA== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -7249,7 +7543,7 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" -warning@^4.0.0, warning@^4.0.3: +warning@^4.0.0, warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== diff --git a/lambda/__tests__/16.integration-metrics.test.ts b/lambda/__tests__/16.integration-metrics.test.ts new file mode 100644 index 000000000..39bffee01 --- /dev/null +++ b/lambda/__tests__/16.integration-metrics.test.ts @@ -0,0 +1,92 @@ +import { TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01, postTeam } from './helpers/fixtures'; +import { cleanUpDatabaseTables, createMockAuth } from './helpers/utils'; +import { createTeam } from './helpers/modules/teams'; +import supertest from 'supertest'; +import app from './helpers/server'; +import { API_BASE_PATH } from './helpers/constants'; +import { buildIntegration } from './helpers/modules/common'; +import { findClientRole } from '@lambda-app/keycloak/users'; +import { Integration } from 'app/interfaces/Request'; +import { + createIntegration, + fetchMetrics, + getIntegration, + getIntegrations, + updateIntegration, +} from './helpers/modules/integrations'; + +jest.mock('../app/src/grafana', () => { + return { + clientEventsAggregationQuery: jest.fn(() => Promise.resolve([{ event: 'LOGIN', count: 10 }])), + }; +}); + +jest.mock('@lambda-app/authenticate'); +jest.mock('@lambda-app/github', () => { + return { + dispatchRequestWorkflow: jest.fn(() => ({ status: 204 })), + closeOpenPullRequests: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('../actions/src/github', () => { + return { + mergePR: jest.fn(), + }; +}); + +jest.mock('@lambda-shared/utils/ches'); + +jest.mock('@lambda-app/authenticate'); + +jest.mock('@lambda-actions/authenticate', () => { + return { + authenticate: jest.fn(() => { + return Promise.resolve(true); + }), + }; +}); + +describe('create/manage integration by authenticated user', () => { + let integration: Integration; + try { + beforeAll(async () => { + jest.clearAllMocks(); + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + + const integrationRes = await buildIntegration({ + projectName: 'TEST CSS Metrics Tab', + prodEnv: true, + submitted: true, + planned: true, + applied: true, + }); + integration = integrationRes.body; + }); + + afterAll(async () => { + await cleanUpDatabaseTables(); + }); + + it('should reject if date inputs are not valid', async () => { + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + const result = await fetchMetrics(integration?.id, '', '', 'dev'); + expect(result.status).toEqual(400); + }); + + it('should reject if env input is not valid', async () => { + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + const result = await fetchMetrics(integration?.id, '2023-01-01', '2023-01-10', 'fake'); + expect(result.status).toEqual(400); + }); + + it('should fetch metrics if all the inputs are valid', async () => { + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + const result = await fetchMetrics(integration?.id, '2023-01-01', '2023-01-10', 'dev'); + expect(result.status).toEqual(200); + expect(result.body.length).toEqual(1); + }); + } catch (err) { + console.error('EXCEPTION: ', err); + } +}); diff --git a/lambda/__tests__/helpers/modules/integrations.ts b/lambda/__tests__/helpers/modules/integrations.ts index 1b1da4fd1..628d086aa 100644 --- a/lambda/__tests__/helpers/modules/integrations.ts +++ b/lambda/__tests__/helpers/modules/integrations.ts @@ -52,3 +52,9 @@ export const createCompositeRoles = async (data: { export const deleteIntegration = async (integrationId: number) => { return await supertest(app).del(`${APP_BASE_PATH}/requests?id=${integrationId}`); }; + +export const fetchMetrics = async (integrationId: number, fromDate: string, toDate: string, env: string) => { + return await supertest(app).get( + `${APP_BASE_PATH}/requests/${integrationId}/metrics?fromDate=${fromDate}&toDate=${toDate}&env=${env}`, + ); +}; diff --git a/lambda/app/src/controllers/logs.ts b/lambda/app/src/controllers/logs.ts index 11739857a..e7cb6379e 100644 --- a/lambda/app/src/controllers/logs.ts +++ b/lambda/app/src/controllers/logs.ts @@ -1,9 +1,12 @@ import { getAllowedRequest } from '@lambda-app/queries/request'; import { Session } from '@lambda-shared/interfaces'; -import { queryGrafana } from '@lambda-app/grafana'; +import { clientEventsAggregationQuery, queryGrafana } from '@lambda-app/grafana'; +import axios from 'axios'; const LOG_SIZE_LIMIT = 2000; +const allowedEnvs = ['dev', 'test', 'prod']; + export const fetchLogs = async ( session: Session, env: string, @@ -14,12 +17,12 @@ export const fetchLogs = async ( ) => { // Check user owns requested logs const userRequest = await getAllowedRequest(session, id); - if (!userRequest) return { status: 401, message: "You are not authorized to view this request's logs" }; + if (!userRequest) return { status: 401, message: "You are not authorized to view this integration's logs" }; const { clientId } = userRequest; // Validate user supplied inputs const hasRequiredQueryParams = start && end && env && eventType; - const allowedEnvs = ['dev', 'test', 'prod']; + const allowedEvents = ['LOGIN', 'CODE_TO_TOKEN', 'REFRESH_TOKEN']; if (!hasRequiredQueryParams) { @@ -53,3 +56,38 @@ export const fetchLogs = async ( return { status: 500, message: 'Unexpected error fetching logs.' }; } }; + +export const fetchMetrics = async ( + session: Session, + id: number, + environment: string, + fromDate: string, + toDate: string, +) => { + try { + let result = []; + // Check user owns requested logs + const userRequest = await getAllowedRequest(session, id); + if (!userRequest) return { status: 401, message: "You are not authorized to view this integration's metrics" }; + + const { clientId } = userRequest; + + if (!allowedEnvs.includes(environment)) { + return { status: 400, message: `The env query param must be one of ${allowedEnvs.join(', ')}.` }; + } + + const startDate = Date.parse(fromDate); + const endDate = Date.parse(toDate); + + if (isNaN(startDate) || isNaN(endDate)) { + return { status: 400, message: 'Include parsable dates for the start and end dates, e.g YYYY-MM-DD.' }; + } + + result = await clientEventsAggregationQuery(clientId, environment, fromDate, toDate); + + return { status: 200, message: null, data: result }; + } catch (err) { + console.error(err); + return { status: 500, message: 'Unable to fetch metrics at this moment!', data: null }; + } +}; diff --git a/lambda/app/src/grafana.ts b/lambda/app/src/grafana.ts index 62172519c..d9bf92cbb 100644 --- a/lambda/app/src/grafana.ts +++ b/lambda/app/src/grafana.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -export const fetchDatasourceUID = async (datasourceName) => { +export const fetchDatasourceUID = async (datasourceName: string) => { return axios .get(`${process.env.GRAFANA_API_URL}/datasources`, { headers: { @@ -48,3 +48,39 @@ export const queryGrafana = async (query: string, start: number, end: number, li return acc.concat(curr.data.values[2]); }, []); }; + +export const clientEventsAggregationQuery = async ( + clientId: string, + environment: string, + fromDate: string, + toDate: string, +) => { + let result = []; + + const aggregatorUID = await fetchDatasourceUID('SSO Aggregator'); + + const query = { + queries: [ + { + datasource: { type: 'postgres', uid: aggregatorUID }, + rawSql: `select json_build_object('event', event_type, 'count', count) from (select distinct event_type, SUM(\"count\") OVER (PARTITION BY \"event_type\") as count from client_events where client_id = '${clientId}' and environment = '${environment}' and date >= '${fromDate}' and date <= '${toDate}') client_event_data;`, + format: 'table', + }, + ], + }; + + const headers = { + Authorization: `Bearer ${process.env.GRAFANA_API_TOKEN}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + const res: any = await axios.post(`${process.env.GRAFANA_API_URL}/ds/query`, query, { headers }); + + const values = res?.data?.results?.A?.frames[0]?.data?.values; + + if (values.length > 0) { + result = values[0].map((item) => JSON.parse(item)); + } + + return result; +}; diff --git a/lambda/app/src/routes.ts b/lambda/app/src/routes.ts index 6fc45f70b..1e6602fe4 100644 --- a/lambda/app/src/routes.ts +++ b/lambda/app/src/routes.ts @@ -64,7 +64,7 @@ import { assertSessionRole } from './helpers/permissions'; import { fetchDiscussions } from './graphql'; import { sendTemplate } from '@lambda-shared/templates'; import { EMAILS } from '@lambda-shared/enums'; -import { fetchLogs } from '@lambda-app/controllers/logs'; +import { fetchLogs, fetchMetrics } from '@lambda-app/controllers/logs'; const APP_URL = process.env.APP_URL || ''; @@ -284,6 +284,18 @@ export const setRoutes = (app: any) => { } }); + app.get(`/requests/:id/metrics`, async (req, res) => { + try { + const { id } = req.params || {}; + const { fromDate, toDate, env } = req.query || {}; + const { status, message, data } = await fetchMetrics(req.session, id, env, fromDate, toDate); + if (status === 200) res.status(status).send(data); + else res.status(status).send({ message }); + } catch (err) { + handleError(res, err); + } + }); + app.delete(`/requests`, async (req, res) => { try { const { id } = req.query || {}; diff --git a/localserver/.env.example b/localserver/.env.example index 329d3ce3e..9d9a669a9 100644 --- a/localserver/.env.example +++ b/localserver/.env.example @@ -26,3 +26,5 @@ CHES_PASSWORD= VERIFY_USER_SECRET=asdf API_URL=http://localhost:8080 REALM_REGISTRY_API=http://localhost:3000/api +GRAFANA_API_TOKEN= +GRAFANA_API_URL= From 449c476b13d6b3fc0c8893f09ad2aa73b1c103fa Mon Sep 17 00:00:00 2001 From: Nithin Shekar Kuruba <81444731+NithinKuruba@users.noreply.github.com> Date: Thu, 14 Dec 2023 15:28:47 -0800 Subject: [PATCH 4/7] fix: validations for start and end dates (#1098) --- .../IntegrationInfoTabs/MetricsPanel.tsx | 28 +++++++++++-------- app/utils/helpers.tsx | 6 ++++ .../__tests__/16.integration-metrics.test.ts | 12 ++++++++ lambda/app/src/controllers/logs.ts | 6 ++++ lambda/app/src/grafana.ts | 2 +- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx index a7886c460..913819d77 100644 --- a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx +++ b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx @@ -12,6 +12,7 @@ import moment from 'moment'; import DateTimePicker from '@app/components/DateTimePicker'; import { InfoMessage } from '@app/components/MessageBox'; import { Link } from '@button-inc/bcgov-theme'; +import { subtractDaysFromDate } from '@app/utils/helpers'; export const DatePickerContainer = styled.div` height: 100%; @@ -55,32 +56,34 @@ interface Props { integration: Integration; } -const metricsStartDate = 'December 01, 2023'; +const getFormattedDateString = (d: Date) => { + return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; +}; + +const metricsStartDate = 'Nov 01, 2023'; const MetricsPanel = ({ integration }: Props) => { const [environment, setEnvironment] = useState('dev'); const environments = integration?.environments || []; const [metrics, setMetrics] = useState([]); const [loading, setLoading] = useState(false); - const [fromDate, setFromDate] = useState(moment().subtract(14, 'days').format('YYYY-MM-DD')); - const [toDate, setToDate] = useState(moment().format('YYYY-MM-DD')); + const [fromDate, setFromDate] = useState(subtractDaysFromDate(14)); + const [toDate, setToDate] = useState(new Date()); + let selectedFromDate: Date = new Date(); + const handleTabSelect = (key: string) => { setEnvironment(key); }; - const getFormattedDate = (val: Date) => { - return moment.utc(val).format('YYYY-MM-DD'); - }; - const handleFromDateChange = (val: Date) => { - setFromDate(getFormattedDate(val)); + setFromDate(val); }; const handleToDateChange = (val: Date) => { - setToDate(getFormattedDate(val)); + setToDate(val); }; const fetchMetrics = useCallback( - throttle(async (environment: string) => { + throttle(async (fromDate: string, toDate: string, environment: string) => { const [metricsData, err] = await getMetrics(integration?.id as number, environment, fromDate, toDate); if (err || metricsData.length === 0) { @@ -93,7 +96,7 @@ const MetricsPanel = ({ integration }: Props) => { ); useEffect(() => { - fetchMetrics(environment); + fetchMetrics(getFormattedDateString(fromDate), getFormattedDateString(toDate), environment); }, [integration?.clientId, environment, fromDate, toDate]); return ( @@ -107,13 +110,14 @@ const MetricsPanel = ({ integration }: Props) => { selected={new Date(fromDate)} onChange={(date: Date) => handleFromDateChange(date)} minDate={new Date(metricsStartDate)} + maxDate={subtractDaysFromDate(1)} label="Start Date" /> handleToDateChange(date)} - minDate={new Date(metricsStartDate)} + minDate={fromDate} label="End Date" /> diff --git a/app/utils/helpers.tsx b/app/utils/helpers.tsx index ad1d28b88..be477ea2e 100644 --- a/app/utils/helpers.tsx +++ b/app/utils/helpers.tsx @@ -321,3 +321,9 @@ export const checkIfDigitalCredentialProdApplying = (integration: Integration) = const prodApplying = checkIfProdApplying(integration, 'digitalCredentialApproved'); return prodApplying; }; + +export const subtractDaysFromDate = (days: number) => { + const d = new Date(); + d.setDate(d.getDate() - days); + return d; +}; diff --git a/lambda/__tests__/16.integration-metrics.test.ts b/lambda/__tests__/16.integration-metrics.test.ts index 39bffee01..af67618c1 100644 --- a/lambda/__tests__/16.integration-metrics.test.ts +++ b/lambda/__tests__/16.integration-metrics.test.ts @@ -74,6 +74,18 @@ describe('create/manage integration by authenticated user', () => { expect(result.status).toEqual(400); }); + it('should reject if start date is greater than end date', async () => { + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + const result = await fetchMetrics(integration?.id, '2023-01-20', '2023-01-10', 'dev'); + expect(result.status).toEqual(400); + }); + + it('should reject if end date is a future date', async () => { + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + const result = await fetchMetrics(integration?.id, '2023-01-20', '9999-12-01', 'dev'); + expect(result.status).toEqual(400); + }); + it('should reject if env input is not valid', async () => { createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); const result = await fetchMetrics(integration?.id, '2023-01-01', '2023-01-10', 'fake'); diff --git a/lambda/app/src/controllers/logs.ts b/lambda/app/src/controllers/logs.ts index e7cb6379e..7155170a1 100644 --- a/lambda/app/src/controllers/logs.ts +++ b/lambda/app/src/controllers/logs.ts @@ -83,6 +83,12 @@ export const fetchMetrics = async ( return { status: 400, message: 'Include parsable dates for the start and end dates, e.g YYYY-MM-DD.' }; } + if (startDate > endDate) { + return { status: 400, message: 'Start date cannot be greater than end date.' }; + } else if (endDate > new Date().getTime()) { + return { status: 400, message: 'End date cannot be a future date.' }; + } + result = await clientEventsAggregationQuery(clientId, environment, fromDate, toDate); return { status: 200, message: null, data: result }; diff --git a/lambda/app/src/grafana.ts b/lambda/app/src/grafana.ts index d9bf92cbb..c2e9ff32a 100644 --- a/lambda/app/src/grafana.ts +++ b/lambda/app/src/grafana.ts @@ -63,7 +63,7 @@ export const clientEventsAggregationQuery = async ( queries: [ { datasource: { type: 'postgres', uid: aggregatorUID }, - rawSql: `select json_build_object('event', event_type, 'count', count) from (select distinct event_type, SUM(\"count\") OVER (PARTITION BY \"event_type\") as count from client_events where client_id = '${clientId}' and environment = '${environment}' and date >= '${fromDate}' and date <= '${toDate}') client_event_data;`, + rawSql: `select json_build_object('event', event_type, 'count', count) from (select distinct event_type, SUM(\"count\") OVER (PARTITION BY \"event_type\") as count from client_events where client_id = '${clientId}' and environment = '${environment}' and date(date) >= '${fromDate}' and date(date) <= '${toDate}') client_event_data;`, format: 'table', }, ], From 1969d5dded38e8a4bc2f85a60d7c3ec1305cd6cc Mon Sep 17 00:00:00 2001 From: Nithin Shekar Kuruba Date: Thu, 14 Dec 2023 15:30:29 -0800 Subject: [PATCH 5/7] fix: metrics start date would be Dec 01 2023 --- .../my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx index 913819d77..69c9cd783 100644 --- a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx +++ b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx @@ -60,7 +60,7 @@ const getFormattedDateString = (d: Date) => { return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; }; -const metricsStartDate = 'Nov 01, 2023'; +const metricsStartDate = 'December 01, 2023'; const MetricsPanel = ({ integration }: Props) => { const [environment, setEnvironment] = useState('dev'); const environments = integration?.environments || []; From c553d5a664076f3418f16dc47d715bb787b6a91b Mon Sep 17 00:00:00 2001 From: Nithin Shekar Kuruba Date: Thu, 14 Dec 2023 21:26:07 -0800 Subject: [PATCH 6/7] fix: set toDate as the max date for fromDate --- .../my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx index 69c9cd783..e7dd818f8 100644 --- a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx +++ b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx @@ -110,7 +110,7 @@ const MetricsPanel = ({ integration }: Props) => { selected={new Date(fromDate)} onChange={(date: Date) => handleFromDateChange(date)} minDate={new Date(metricsStartDate)} - maxDate={subtractDaysFromDate(1)} + maxDate={toDate} label="Start Date" /> Date: Fri, 15 Dec 2023 11:22:44 -0800 Subject: [PATCH 7/7] fix: update metrics tab link at the bottom --- .../my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx index e7dd818f8..e03b89b9b 100644 --- a/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx +++ b/app/page-partials/my-dashboard/IntegrationInfoTabs/MetricsPanel.tsx @@ -169,7 +169,10 @@ const MetricsPanel = ({ integration }: Props) => { This tab was released {metricsStartDate}. Please refer to{' '} - + here {' '} for event type details.