diff --git a/examples/catalog-harness-srm.yaml b/examples/catalog-harness-srm.yaml new file mode 100644 index 0000000..b540a08 --- /dev/null +++ b/examples/catalog-harness-srm.yaml @@ -0,0 +1,38 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: demo + description: demo + annotations: + harness.io/services: | + Service: + + tags: + - java + + links: + - url: https://example.com/user + title: Examples Users + icon: user + - url: https://example.com/group + title: Example Group + icon: group + - url: https://example.com/cloud + title: Link with Cloud Icon + icon: cloud + - url: https://example.com/dashboard + title: Dashboard + icon: dashboard + - url: https://example.com/help + title: Support + icon: help + - url: https://example.com/web + title: Website + icon: web + - url: https://example.com/alert + title: Alerts + icon: alert +spec: + type: service + lifecycle: experimental + owner: team-a diff --git a/package.json b/package.json index a32682c..65d591f 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "@spotify/prettier-config": "^12.0.0", "concurrently": "^6.0.0", "lerna": "^4.0.0", + "node-gyp": "^9.0.0", "prettier": "^2.3.2", - "typescript": "~4.6.4", - "node-gyp": "^9.0.0" + "typescript": "~4.6.4" }, "resolutions": { "@types/react": "^17", @@ -50,5 +50,6 @@ "*.{json,md}": [ "prettier --write" ] - } + }, + "dependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 5f857d8..7224237 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -45,6 +45,7 @@ "@backstage/theme": "^0.2.16", "@harnessio/backstage-plugin-ci-cd": "^0.6.0", "@harnessio/backstage-plugin-feature-flags": "^0.2.0", + "@harnessio/backstage-plugin-harness-srm": "^0.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "history": "^5.0.0", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 2cfacb2..bc7c7b3 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -65,6 +65,11 @@ import { EntityHarnessFeatureFlagContent, } from '@harnessio/backstage-plugin-feature-flags'; +import { + EntityHarnessSrmContent, + isHarnessSRMAvailable, +} from '@harnessio/backstage-plugin-harness-srm'; + const techdocsContent = ( @@ -148,6 +153,31 @@ const entityWarningContent = ( ); +const srmContent = ( + + + + + + + + Read more + + } + /> + + +); + const overviewContent = ( {entityWarningContent} @@ -177,6 +207,10 @@ const serviceEntityPage = ( {cicdContent} + + {srmContent} + + {featureFlagList} diff --git a/plugins/harness-srm/.eslintrc.js b/plugins/harness-srm/.eslintrc.js new file mode 100644 index 0000000..e2a53a6 --- /dev/null +++ b/plugins/harness-srm/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/harness-srm/README.md b/plugins/harness-srm/README.md new file mode 100644 index 0000000..8c9b095 --- /dev/null +++ b/plugins/harness-srm/README.md @@ -0,0 +1,131 @@ +# Harness Service Reliability Management Plugin + +Website: [https://harness.io/](https://harness.io/) + +Welcome to the Harness Service Reliability Management plugin for Backstage! + +## Screenshots + + + +## Setup steps + +1. Open terminal and navigate to the _root of your Backstage app_. Then run + +``` +yarn add --cwd packages/app @harnessio/backstage-plugin-srm + +yarn install +``` + +If you are looking to get started with Backstage, check out [backstage.io/docs](https://backstage.io/docs/getting-started/). + +For testing purposes, you can also clone this repository to try out the plugin. It contains an example Backstage app setup which is pre-installed with Harness plugins. However, you must create a new Backstage app if you are looking to get started with Backstage. + +2. Configure proxy for harness in your `app-config.yaml` under the `proxy` config. Add your Harness Personal Access Token or Service Account Token for `x-api-key`. See the [Harness docs](https://docs.harness.io/article/tdoad7xrh9-add-and-manage-api-keys) for generating an API Key. + +```yaml +# In app-config.yaml + +proxy: + # ... existing proxy settings + '/harness': + target: 'https://app.harness.io/' + headers: + 'x-api-key': '' +# ... +``` + +Notes: + +- Plugin uses token configured here to make Harness API calls. Make sure this token has the necessary permissions + +- Set the value of target to your on-prem URL if you are using the Harness on-prem offering + +3. Inside your Backstage's `EntityPage.tsx`, update the `srmContent` component to render `` whenever the service is using Harness SRM. Something like this - + +```tsx +// In packages/app/src/components/catalog/EntityPage.tsx + +import { + EntityHarnessSrmContent, + isHarnessSRMAvailable +} from '@harnessio/backstage-plugin-harness-srm'; + +... + +const srmContent = ( + + + + + + + + Read more + + } + /> + + +); + +... + +const serviceEntityPage = ( + + + {srmContent} + + +); + +... + +``` + +4. Add required harness specific annotations to your software component's respective `catalog-info.yaml` file. + +Here is an example: [catalog-info.yaml](../../examples/catalog-harness-srm.yaml) + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + harness.io/services: | + labelA: + +spec: + type: service + # ... +``` + +## Other configurations + +- (Optional) Harness URL + +If you have a separate Harness hosted URL other than `https://app.harness.io`, you can configure `baseUrl` for `harness` in `app-config.yaml` This step is optional. The default value of `harness.baseUrl` is https://app.harness.io/ + +```yaml +# In app-config.yaml + +harness: + baseUrl: https://app.harness.io/ +``` + +## Features + +- Connect a Backstage service with a Harness project and view the Monitored Services associated with that service. +- See details about Monitored Service - the changes, health score, and the SLOs asscoiated with it. +- See details about the SLOs like their status, burn rate, target, error budget remaining for a given Monitored Service on clicking the dropdown. diff --git a/plugins/harness-srm/dev/index.tsx b/plugins/harness-srm/dev/index.tsx new file mode 100644 index 0000000..8be165d --- /dev/null +++ b/plugins/harness-srm/dev/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { harnessSrmPlugin, HarnessSrmPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(harnessSrmPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/harness-srm', + }) + .render(); diff --git a/plugins/harness-srm/package.json b/plugins/harness-srm/package.json new file mode 100644 index 0000000..f5e0b5c --- /dev/null +++ b/plugins/harness-srm/package.json @@ -0,0 +1,57 @@ +{ + "name": "@harnessio/backstage-plugin-harness-srm", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "^0.11.2", + "@backstage/core-plugin-api": "^1.0.7", + "@backstage/theme": "^0.2.16", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react-use": "^17.2.4", + "path-to-regexp": "^6.2.1", + "@mui/material": "^5.10.13", + "@backstage/plugin-catalog-react": "^1.3.0", + "@backstage/catalog-model": "^1.1.3" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0", + "react-router": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "^0.20.0", + "@backstage/core-app-api": "^1.1.1", + "@backstage/dev-utils": "^1.0.6", + "@backstage/test-utils": "^1.2.1", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/node": "*", + "msw": "^0.47.0", + "cross-fetch": "^3.1.5" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/harness-srm/src/assets/MonitoredServiceList.png b/plugins/harness-srm/src/assets/MonitoredServiceList.png new file mode 100644 index 0000000..74f664f Binary files /dev/null and b/plugins/harness-srm/src/assets/MonitoredServiceList.png differ diff --git a/plugins/harness-srm/src/assets/MonitoredServicesList.png b/plugins/harness-srm/src/assets/MonitoredServicesList.png new file mode 100644 index 0000000..1d796c9 Binary files /dev/null and b/plugins/harness-srm/src/assets/MonitoredServicesList.png differ diff --git a/plugins/harness-srm/src/assets/harness-srm-service-url.png b/plugins/harness-srm/src/assets/harness-srm-service-url.png new file mode 100644 index 0000000..5d8bcf9 Binary files /dev/null and b/plugins/harness-srm/src/assets/harness-srm-service-url.png differ diff --git a/plugins/harness-srm/src/components/Icons.tsx b/plugins/harness-srm/src/components/Icons.tsx new file mode 100644 index 0000000..cac9e7f --- /dev/null +++ b/plugins/harness-srm/src/components/Icons.tsx @@ -0,0 +1,211 @@ +import React from 'react'; + +export const HeartIcon = () => ( + + heart + + +); + +export const ExhaustedIcon = () => ( + + + + +); + +export const AttentionIcon = () => ( + + warning-sign + + +); + +export const ObserveIcon = () => ( + + + + +); + +export const UnhealthyIcon = () => ( + + heart-broken + + +); + +export const UpIcon = () => ( + + chevron-down + + +); + +export const DownIcon = () => ( + + chevron-right + + +); + +export const DeploymentIcon = () => ( + + + +); + +export const InfraIcon = () => ( + + + + + + + + + + + +); + +export const AlertIcon = () => ( + + + +); + +export const ChaosIcon = () => ( + + + + + + + + + +); + +export const FFIcon = () => ( + + + + + + + + + + +); diff --git a/plugins/harness-srm/src/components/MonitoredServiceList/CollapsibleTable.tsx b/plugins/harness-srm/src/components/MonitoredServiceList/CollapsibleTable.tsx new file mode 100644 index 0000000..9358314 --- /dev/null +++ b/plugins/harness-srm/src/components/MonitoredServiceList/CollapsibleTable.tsx @@ -0,0 +1,476 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import RenderChangeComponent from '../RenderChangeComponent'; +import useSLOList from '../../hooks/useSLOList'; +import { CircularProgress, Link } from '@material-ui/core'; +import { AsyncStatus } from '../types'; +import { + EvaluationType, + getEvaluationType, + getStatusBackgroundColor, + getTextColor, + objectLength, + getRiskColorLogo, +} from '../../util/SloUtils'; +import { DownIcon, UpIcon } from '../Icons'; +import { + getRiskColorValue, + getRiskLabelStringId, +} from '../../util/MonitoredServiceUtils'; + +export interface TableData { + id: string; + name: string; + identifier: string; + environmentName: string; + environmentRef: string; + serviceName: string; + serviceRef: string; + changeSummary: object; + currentHealthScore: object; + sloHealthIndicators: object; + evaluationType: string; + sloTargetPercentage: string; + userJourneyName: string; + burnRate: string; + errorBudgetRisk: string; + errorBudgetRemainingPercentage: string; + errorBudgetRemaining: string; + sloIdentifier: string; +} + +const RenderErrorBudgetRemainingPercentage: React.FC = ({ row }) => { + const { + evaluationType = ' ', + errorBudgetRemainingPercentage = ' ', + errorBudgetRemaining, + } = row; + const isRequest = evaluationType === EvaluationType.REQUEST; + + return ( +
+
+ + {isRequest + ? 'NA' + : ` ${Number(errorBudgetRemainingPercentage || 0).toFixed(2)}%`} + +
+ {!isRequest && ( + + {`${errorBudgetRemaining} m`} + + )} +
+ ); +}; + +const RenderHealthScore = ({ + riskStatus, + healthScore, +}: { + riskStatus: String; + healthScore: Number; +}) => { + return ( +
+ + {healthScore || healthScore === 0 ? healthScore : '-'} + +
{getRiskLabelStringId(riskStatus)}
+
+ ); +}; + +const RenderSloStatus: React.FC = ({ errorBudgetRisk }) => { + const IconComponent = getRiskColorLogo(errorBudgetRisk); + const bgColor = getStatusBackgroundColor(errorBudgetRisk); + const textColor = getTextColor(errorBudgetRisk); + + return ( +
+

+ + {IconComponent && React.createElement(IconComponent)} + + + {errorBudgetRisk} + +

+
+ ); +}; + +const DropdownRow: React.FC = ({ + accountId, + orgId, + projectId, + monitoredServiceId, + baseUrl1, + backendBaseUrl, + env, +}) => { + const { + status: state, + currTableData, + flag, + } = useSLOList({ + accountId, + orgId, + projectId, + monitoredServiceId, + env: env, + backendBaseUrl, + }); + + if ( + state === AsyncStatus.Init || + state === AsyncStatus.Loading || + (state === AsyncStatus.Success && !flag) + ) { + return ( +
+ +
+ ); + } + + const link2 = `${baseUrl1}/ng/account/${accountId}/cv/orgs/${orgId}/projects/${projectId}/slos/`; + return ( + <> + {currTableData.map(row => ( + + + + {row.name} + + + {getEvaluationType(row.evaluationType)} + + {' '} + + + + + + {` ${Number((Number(row.sloTargetPercentage) || 0).toFixed(2))}%`} + + + {` ${Number((Number(row.burnRate) || 0).toFixed(2))}%`} + + + {row.userJourneyName !== 'undefined' ? row.userJourneyName : ''} + + + ))} + + ); +}; + +const Row: React.FC = ({ + row, + accountId, + projectId, + orgId, + backendBaseUrl, + baseUrl1, + env, +}) => { + const [open, setOpen] = React.useState(false); + const link = `${baseUrl1}/ng/account/${accountId}/cv/orgs/${orgId}/projects/${projectId}/monitoringservices/edit/${row.identifier}`; + if (objectLength(row.sloHealthIndicators) > 0) { + return ( + + *': { borderBottom: 'unset' } }}> + + + + + <> + +
+
+ {row.serviceRef} +
+
{row.environmentRef}
+
+ + +
+ + {objectLength(row.sloHealthIndicators)} + + + + + + + +
+ + + + + + + SLOs Configured + + + + + + + + + + + + + + + SLO NAME + + + EVALUATION + + + STATUS + + + ERROR BUDGET REMAINING + + + TARGET + + + BURN RATE/DAY + + + USER JOURNEY + + + + + + +
+
+
+
+
+
+ ); + } + return ( + + *': { borderBottom: 'unset' } }}> + + + + + + <> + +
+
+ {row.serviceRef} +
+
{row.environmentRef}
+
+ + +
+ + {objectLength(row.sloHealthIndicators)} + + + + + + + +
+ + + + + + No SLOs have been configured for this Monitored Service + + + + + +
+ ); +}; + +const CollapsibleTable: React.FC = ({ + baseUrl1, + accountId, + orgId, + currProject, + backendBaseUrl, + data, + env, +}) => { + return ( + + + + + + + + + + + + + +

Monitored Service Name

+
+ +

SLO Count

+
+ +

Changes

+
+ +

Health Score

+
+
+
+ + {data.map((row: any) => ( + + ))} + +
+
+ ); +}; + +export default CollapsibleTable; diff --git a/plugins/harness-srm/src/components/MonitoredServiceList/MonitoredServiceList.tsx b/plugins/harness-srm/src/components/MonitoredServiceList/MonitoredServiceList.tsx new file mode 100644 index 0000000..f9fdca7 --- /dev/null +++ b/plugins/harness-srm/src/components/MonitoredServiceList/MonitoredServiceList.tsx @@ -0,0 +1,137 @@ +import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; +import React from 'react'; +import useGetMonitoredServiceList from '../../hooks/useGetMonitoredServiceList'; +import { useProjectSlugFromEntity } from '../../hooks/useProjectSlugFromEntity'; +import useServiceSlugEntity from '../../hooks/useServiceSlugEntity'; +import { CircularProgress, makeStyles } from '@material-ui/core'; +import { AsyncStatus } from '../types'; +import CollapsibleTable from './CollapsibleTable'; +import useGetLicencse from '../../hooks/useGetLicense'; +import { EmptyState } from '@backstage/core-components'; + +const useStyles = makeStyles(theme => ({ + container: { + width: '100%', + }, + label: { + marginBottom: '2px', + fontSize: '14px !important', + }, + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +function MonitoredServiceList() { + const discoveryApi = useApi(discoveryApiRef); + const classes = useStyles(); + const backendBaseUrl = discoveryApi.getBaseUrl('proxy'); + const { harnessServicesObject } = useServiceSlugEntity(); + + const selectedServiceUrl = + harnessServicesObject[Object.keys(harnessServicesObject)[0]]; + + const { + orgId, + accountId, + serviceId, + baseUrl1, + projectIds, + envFromUrl, + urlParams, + } = useProjectSlugFromEntity(selectedServiceUrl); + + const allProjects = projectIds?.split(',').map(item => item.trim()); + const envToUse = envFromUrl; + const projectToUse = allProjects?.[0]; + + const { + status: state, + currTableData, + flag, + } = useGetMonitoredServiceList({ + accountId, + orgId, + currProject: projectToUse, + serviceId, + env: envToUse, + backendBaseUrl, + }); + + const { licenses } = useGetLicencse({ + backendBaseUrl, + env: envToUse, + accountId, + }); + + if (licenses === 'NA') { + return ( + + ); + } + + if ( + state === AsyncStatus.Init || + state === AsyncStatus.Loading || + (state === AsyncStatus.Success && !flag) + ) { + return ( +
+ +
+ ); + } + + if ( + !urlParams || + state === AsyncStatus.Error || + state === AsyncStatus.Unauthorized || + (state === AsyncStatus.Success && currTableData.length === 0 && flag) + ) { + let description = ''; + if (state === AsyncStatus.Unauthorized) + description = + 'Could not find the Monitored Services, the x-api-key is either missing or incorrect in app-config.yaml under proxy settings.'; + else if (!urlParams) + description = + 'Could not find the Monitored Service, please check your service-url configuration in catalog-info.yaml.'; + else if (state === AsyncStatus.Success && currTableData.length === 0) + description = 'No Monitored Services found for the given service.'; + else + description = + 'Could not find the Monitored Services, please check your configurations in catalog-info.yaml or check your permissions.'; + return ( + <> + + + ); + } + + return ( + <> +
+ +
+ + ); +} + +export default MonitoredServiceList; diff --git a/plugins/harness-srm/src/components/RenderChangeComponent.tsx b/plugins/harness-srm/src/components/RenderChangeComponent.tsx new file mode 100644 index 0000000..7cf427d --- /dev/null +++ b/plugins/harness-srm/src/components/RenderChangeComponent.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { + ChangeTypes, + calculateTotalChangePercentage, + getChangeIcon, + getChangeTooltipText, + numberFormatter, +} from '../util/MonitoredServiceUtils'; +import { CategoryCountDetails } from './types'; +import { Tooltip } from '@material-ui/core'; + +const RenderChangePercentage: React.FC = ({ color, icon, text }) => { + const iconStyle = { + fill: color, + marginRight: '4px', + marginTop: '4px', + }; + if (icon === 'symbol-triangle-up') { + return ( +
+
+ + symbol-triangle-up + + +
+
{text}
+
+ ); + } + return ( +
+
+ + symbol-triangle-down + + +
+
{text}
+
+ ); +}; + +const TextWithIcon = ({ icon, text }: { icon: ChangeTypes; text: Number }) => { + return ( +
+
+ {getChangeTooltipText(icon)} + } + placement="top" + > +
+ {React.createElement(getChangeIcon(icon))} +
+
+
{text}
+
+
+ ); +}; + +const RenderChangeComponent: React.FC = props => { + const { categoryCountMap, total } = props.row.changeSummary; + + const { color, percentage, icon } = calculateTotalChangePercentage(total); + + const totalPercentage = numberFormatter(Math.abs(percentage), { + truncate: false, + }); + const percentageText = + Math.abs(percentage) > 100 ? `100+ %` : `${totalPercentage}%`; + return ( +
+ {Object.entries(categoryCountMap).map( + ([changeCategory, categoryCountDetails]) => ( + + ), + )} +
+ +
+
+ ); +}; + +export default RenderChangeComponent; diff --git a/plugins/harness-srm/src/components/Router.tsx b/plugins/harness-srm/src/components/Router.tsx new file mode 100644 index 0000000..7451665 --- /dev/null +++ b/plugins/harness-srm/src/components/Router.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Routes, Route } from 'react-router'; +import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { MissingAnnotationEmptyState } from '@backstage/core-components'; +import MonitoredServiceList from './MonitoredServiceList/MonitoredServiceList'; +/** @public */ +export const isHarnessSRMAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.['harness.io/services']); + +/** @public */ + +export const Router = () => { + const { entity } = useEntity(); + if (!isHarnessSRMAvailable(entity)) { + return ( + <> + + + ); + } + + return ( + + } /> + + ); +}; diff --git a/plugins/harness-srm/src/components/types.ts b/plugins/harness-srm/src/components/types.ts new file mode 100644 index 0000000..7776af7 --- /dev/null +++ b/plugins/harness-srm/src/components/types.ts @@ -0,0 +1,96 @@ +export type MonitoredServiceListItemDTO = { + changeSummary?: ChangeSummaryDTO; + currentHealthScore?: RiskData; + dependentHealthScore?: RiskData[]; + environmentName?: string; + environmentRef?: string; + environmentRefList?: string[]; + healthMonitoringEnabled?: boolean; + historicalTrend?: HistoricalTrend; + identifier?: string; + name?: string; + serviceMonitoringEnabled?: boolean; + serviceName?: string; + serviceRef?: string; + sloHealthIndicators?: SloHealthIndicatorDTO[]; + tags?: { + [key: string]: string; + }; + type?: 'Application' | 'Infrastructure'; +}; + +export interface ChangeSummaryDTO { + categoryCountMap?: { + [key: string]: CategoryCountDetails; + }; + total?: CategoryCountDetails; +} + +export interface CategoryCountDetails { + count?: number; + countInPrecedingWindow?: number; + percentageChange?: number; +} + +export interface RiskData { + endTime?: number; + healthScore?: number; + riskStatus?: + | 'NO_DATA' + | 'NO_ANALYSIS' + | 'HEALTHY' + | 'OBSERVE' + | 'NEED_ATTENTION' + | 'UNHEALTHY' + | 'CUSTOMER_DEFINED_UNHEALTHY'; + startTime?: number; + timeRangeParams?: TimeRangeParams; +} + +export interface TimeRangeParams { + endTime?: number; + startTime?: number; +} + +export interface HistoricalTrend { + healthScores?: RiskData[]; +} + +export interface SloHealthIndicatorDTO { + errorBudgetBurnRate?: number; + errorBudgetRemainingMinutes?: number; + errorBudgetRemainingPercentage?: number; + errorBudgetRisk?: + | 'EXHAUSTED' + | 'UNHEALTHY' + | 'NEED_ATTENTION' + | 'OBSERVE' + | 'HEALTHY'; + monitoredServiceIdentifier?: string; + serviceLevelObjectiveIdentifier?: string; +} + +export enum AsyncStatus { + Init, + Loading, + Success, + Error, + Unauthorized, +} + +export type TableProps = { + monitoredService: MonitoredServiceListItemDTO[]; +}; + +export interface TableData { + id: string; + name: string; + identifier: string; + environmentName: string; + environmentRef: string; + serviceName: string; + serviceRef: string; + changeSummary: object; + currentHealthScore: object; + sloHealthIndicators: object; +} diff --git a/plugins/harness-srm/src/hooks/useGetLicense.ts b/plugins/harness-srm/src/hooks/useGetLicense.ts new file mode 100644 index 0000000..f1b7b63 --- /dev/null +++ b/plugins/harness-srm/src/hooks/useGetLicense.ts @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { getSecureHarnessKey } from '../util/getHarnessToken'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; + +interface useGetLicenseWithAuthProps { + env: string; + accountId: string; + backendBaseUrl: Promise; +} +const useGetLicense = ({ + backendBaseUrl, + env, + accountId, +}: useGetLicenseWithAuthProps) => { + const [licenses, setLicenses] = useState('cv'); + + useAsyncRetry(async () => { + const token = getSecureHarnessKey('token'); + const value = token ? `${token}` : ''; + + const headers = new Headers({ + Authorization: value, + }); + + const response = await fetch( + `${await backendBaseUrl}/harness/${env}/gateway/ng/api/licenses/account?routingId=${accountId}&accountIdentifier=${accountId}`, + { + headers, + }, + ); + + if (response.status === 200) { + const data = await response.json(); + if (data?.data?.allModuleLicenses?.CV?.length === 0) { + setLicenses('NA'); + } + } + }, [env, accountId]); + + return { licenses }; +}; + +export default useGetLicense; diff --git a/plugins/harness-srm/src/hooks/useGetMonitoredServiceList.ts b/plugins/harness-srm/src/hooks/useGetMonitoredServiceList.ts new file mode 100644 index 0000000..f0ce86b --- /dev/null +++ b/plugins/harness-srm/src/hooks/useGetMonitoredServiceList.ts @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; +import { AsyncStatus } from '../components/types'; +import { getSecureHarnessKey } from '../util/getHarnessToken'; + +interface useGetMonitoredServiceListProps { + accountId: string; + orgId: string; + currProject: string | undefined; + serviceId: string | undefined; + env: string; + backendBaseUrl: Promise; +} +const useGetMonitoredServiceList = ({ + accountId, + orgId, + currProject, + serviceId, + env, + backendBaseUrl, +}: useGetMonitoredServiceListProps) => { + const [status, setStatus] = useState(AsyncStatus.Init); + const [currTableData, setCurrTableData] = useState([]); + const [flag, setFlag] = useState(false); + + useAsyncRetry(async () => { + const token = getSecureHarnessKey('token'); + const value = token ? `${token}` : ''; + + const headers = new Headers({ + 'content-type': 'application/json', + Authorization: value, + }); + + setStatus(AsyncStatus.Loading); + + const response = await fetch( + `${await backendBaseUrl}/harness/${env}/gateway/cv/api/monitored-service?routingId=${accountId}&offset=0&pageSize=30&accountId=${accountId}&orgIdentifier=${orgId}&projectIdentifier=${currProject}&filter=&servicesAtRiskFilter=false&serviceIdentifier=${serviceId}`, + { + method: 'GET', + headers, + }, + ); + + const getBuilds = (tableData: any): Array<{}> => { + return tableData.map((dataItem: any, index: number) => { + return { + id: `${index + 1}`, + name: `${dataItem?.name}`, + identifier: `${dataItem?.identifier}`, + environmentName: `${dataItem?.environmentName}`, + environmentRef: `${dataItem?.environmentRef}`, + serviceName: `${dataItem?.serviceName}`, + serviceRef: `${dataItem?.serviceRef}`, + changeSummary: dataItem?.changeSummary, + currentHealthScore: dataItem?.currentHealthScore, + sloHealthIndicators: dataItem?.sloHealthIndicators, + }; + }); + }; + + if (response.status === 200) { + const data = await response.json(); + setStatus(AsyncStatus.Success); + const responseData = data.data.content; + setCurrTableData(getBuilds(responseData)); + } else if (response.status === 401) setStatus(AsyncStatus.Unauthorized); + else setStatus(AsyncStatus.Error); + setFlag(true); + }, [accountId, orgId, currProject, serviceId, env]); + return { status, currTableData, flag }; +}; + +export default useGetMonitoredServiceList; diff --git a/plugins/harness-srm/src/hooks/useProjectSlugFromEntity.ts b/plugins/harness-srm/src/hooks/useProjectSlugFromEntity.ts new file mode 100644 index 0000000..f011202 --- /dev/null +++ b/plugins/harness-srm/src/hooks/useProjectSlugFromEntity.ts @@ -0,0 +1,28 @@ +import { match } from 'path-to-regexp'; + +export const useProjectSlugFromEntity = (selectedServiceUrl: string) => { + const serviceUrlMatch = match( + '(.*)/account/:accountId/:module/orgs/:orgId/projects/:projectId/services/:serviceId', + { + decode: decodeURIComponent, + }, + ); + + const hostname = new URL(selectedServiceUrl).hostname; + const baseUrl1 = new URL(selectedServiceUrl).origin; + + const envAB = hostname.split('.')[0]; + const envFromUrl = envAB === 'app' ? 'prod' : envAB; + + const serviceUrlParams: any = serviceUrlMatch(selectedServiceUrl); + + return { + orgId: serviceUrlParams.params.orgId, + accountId: serviceUrlParams.params.accountId, + serviceId: serviceUrlParams.params.serviceId, + urlParams: serviceUrlParams, + baseUrl1: baseUrl1, + projectIds: serviceUrlParams.params.projectId as string, + envFromUrl: envFromUrl, + }; +}; diff --git a/plugins/harness-srm/src/hooks/useSLOList.ts b/plugins/harness-srm/src/hooks/useSLOList.ts new file mode 100644 index 0000000..d47f949 --- /dev/null +++ b/plugins/harness-srm/src/hooks/useSLOList.ts @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; +import { AsyncStatus } from '../components/types'; +import { getSecureHarnessKey } from '../util/getHarnessToken'; + +interface useSLOListProps { + accountId: string; + orgId: string; + projectId: string | undefined; + monitoredServiceId: string | undefined; + env: string; + backendBaseUrl: Promise; +} +const useSLOList = ({ + accountId, + orgId, + projectId, + monitoredServiceId, + env, + backendBaseUrl, +}: useSLOListProps) => { + const [status, setStatus] = useState(AsyncStatus.Init); + const [currTableData, setCurrTableData] = useState([]); + const [flag, setFlag] = useState(false); + + useAsyncRetry(async () => { + const token = getSecureHarnessKey('token'); + const value = token ? `${token}` : ''; + + const headers = new Headers({ + 'content-type': 'application/json', + Authorization: value, + }); + + setStatus(AsyncStatus.Loading); + + const response = await fetch( + `${await backendBaseUrl}/harness/${env}/gateway/cv/api/slo-dashboard/widgets/list?routingId=${accountId}&pageNumber=0&pageSize=50&accountId=${accountId}&orgIdentifier=${orgId}&projectIdentifier=${projectId}&monitoredServiceIdentifier=${monitoredServiceId}`, + { + method: 'GET', + headers, + }, + ); + + const getBuilds = (tableData: any): Array<{}> => { + return tableData.map((dataItem: any, index: number) => { + return { + id: `${index + 1}`, + name: `${dataItem?.name}`, + sloIdentifier: `${dataItem?.sloIdentifier}`, + evaluationType: `${dataItem?.evaluationType}`, + userJourneyName: `${dataItem?.userJourneyName}`, + sloTargetPercentage: `${dataItem?.sloTargetPercentage}`, + burnRate: `${dataItem?.burnRate}`, + errorBudgetRemainingPercentage: `${dataItem?.errorBudgetRemainingPercentage}`, + errorBudgetRisk: `${dataItem?.errorBudgetRisk}`, + errorBudgetRemaining: `${dataItem?.errorBudgetRemaining}`, + }; + }); + }; + + if (response.status === 200) { + const data = await response.json(); + setStatus(AsyncStatus.Success); + const responseData = data.data.content; + setCurrTableData(getBuilds(responseData)); + } else if (response.status === 401) setStatus(AsyncStatus.Unauthorized); + else setStatus(AsyncStatus.Error); + + setFlag(true); + }, [accountId, orgId, projectId, monitoredServiceId, env]); + return { status, currTableData, flag }; +}; + +export default useSLOList; diff --git a/plugins/harness-srm/src/hooks/useServiceSlugEntity.ts b/plugins/harness-srm/src/hooks/useServiceSlugEntity.ts new file mode 100644 index 0000000..42ac079 --- /dev/null +++ b/plugins/harness-srm/src/hooks/useServiceSlugEntity.ts @@ -0,0 +1,29 @@ +import { useEntity } from '@backstage/plugin-catalog-react'; + +function convertStringToObject(inputString: string | undefined) { + if (!inputString) return {}; + const object: Record = {}; + const lines = inputString.split('\n'); + + for (const line of lines) { + if (line === '') continue; + const [label, ...rest] = line.split(':'); + const trimmedLabel = label.trim(); + object[trimmedLabel] = rest.join(':').trim(); + } + return object; +} + +const useServiceSlugEntity = () => { + const { entity } = useEntity(); + + const harnessServicesObject = convertStringToObject( + entity.metadata.annotations?.['harness.io/services'], + ); + + return { + harnessServicesObject, + }; +}; + +export default useServiceSlugEntity; diff --git a/plugins/harness-srm/src/index.ts b/plugins/harness-srm/src/index.ts new file mode 100644 index 0000000..7953061 --- /dev/null +++ b/plugins/harness-srm/src/index.ts @@ -0,0 +1,8 @@ +export { + harnessSrmPlugin, + HarnessSrmPage, + EntityHarnessSrmContent, +} from './plugin'; +export { Router } from './components/Router'; +export * from './route-refs'; +export * from './components/Router'; diff --git a/plugins/harness-srm/src/plugin.test.ts b/plugins/harness-srm/src/plugin.test.ts new file mode 100644 index 0000000..d703f5c --- /dev/null +++ b/plugins/harness-srm/src/plugin.test.ts @@ -0,0 +1,7 @@ +import { harnessSrmPlugin } from './plugin'; + +describe('harness-srm', () => { + it('should export plugin', () => { + expect(harnessSrmPlugin).toBeDefined(); + }); +}); diff --git a/plugins/harness-srm/src/plugin.ts b/plugins/harness-srm/src/plugin.ts new file mode 100644 index 0000000..cbf8749 --- /dev/null +++ b/plugins/harness-srm/src/plugin.ts @@ -0,0 +1,29 @@ +import { + createPlugin, + createRoutableExtension, +} from '@backstage/core-plugin-api'; + +import { rootRouteRef } from './routes'; +import { harnessSrmRouteRef } from './route-refs'; + +export const harnessSrmPlugin = createPlugin({ + id: 'harness-srm', + routes: { + root: rootRouteRef, + }, +}); + +export const HarnessSrmPage = harnessSrmPlugin.provide( + createRoutableExtension({ + name: 'HarnessSrmPage', + component: () => import('./components/Router').then(m => m.Router), + mountPoint: harnessSrmRouteRef, + }), +); +export const EntityHarnessSrmContent = harnessSrmPlugin.provide( + createRoutableExtension({ + name: 'HarnnessSrmContent', + component: () => import('./components/Router').then(m => m.Router), + mountPoint: harnessSrmRouteRef, + }), +); diff --git a/plugins/harness-srm/src/route-refs.tsx b/plugins/harness-srm/src/route-refs.tsx new file mode 100644 index 0000000..235f842 --- /dev/null +++ b/plugins/harness-srm/src/route-refs.tsx @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const harnessSrmRouteRef = createRouteRef({ + id: 'harness-srm', +}); diff --git a/plugins/harness-srm/src/routes.ts b/plugins/harness-srm/src/routes.ts new file mode 100644 index 0000000..06bd92e --- /dev/null +++ b/plugins/harness-srm/src/routes.ts @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'harness-srm', +}); diff --git a/plugins/harness-srm/src/setupTests.ts b/plugins/harness-srm/src/setupTests.ts new file mode 100644 index 0000000..48c09b5 --- /dev/null +++ b/plugins/harness-srm/src/setupTests.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/harness-srm/src/util/MonitoredServiceUtils.ts b/plugins/harness-srm/src/util/MonitoredServiceUtils.ts new file mode 100644 index 0000000..97b039b --- /dev/null +++ b/plugins/harness-srm/src/util/MonitoredServiceUtils.ts @@ -0,0 +1,166 @@ +import { CategoryCountDetails } from '../components/types'; +import { + AlertIcon, + ChaosIcon, + DeploymentIcon, + FFIcon, + InfraIcon, +} from '../components/Icons'; + +export interface NumberFormatterOptions { + truncate?: boolean; +} + +export enum ChangeTypes { + DEPLOYMENT = 'Deployment', + INFRASTRUCTURE = 'Infrastructure', + ALERT = 'Alert', + FEATURE_FLAG = 'FeatureFlag', + CHAOS_EXPERIMENT = 'ChaosExperiment', +} + +enum RiskValues { + NO_DATA = 'NO_DATA', + NO_ANALYSIS = 'NO_ANALYSIS', + HEALTHY = 'HEALTHY', + OBSERVE = 'OBSERVE', + NEED_ATTENTION = 'NEED_ATTENTION', + WARNING = 'WARNING', + UNHEALTHY = 'UNHEALTHY', + FAILED = 'FAILED', + PASSED = 'PASSED', + CUSTOMER_DEFINED_UNHEALTHY = 'CUSTOMER_DEFINED_UNHEALTHY', +} + +export const getRiskColorValue = ( + riskStatus?: String, + dark = false, +): string => { + const COLOR_NO_DATA = dark ? '#9293ab' : '#f3f3fa'; + + switch (riskStatus) { + case RiskValues.HEALTHY: + case RiskValues.PASSED: + return '#299b2c'; + case RiskValues.OBSERVE: + case RiskValues.WARNING: + return '#fcb519'; + case RiskValues.NEED_ATTENTION: + return '#ff7020'; + case RiskValues.UNHEALTHY: + case RiskValues.FAILED: + case RiskValues.CUSTOMER_DEFINED_UNHEALTHY: + return '#b41710'; + default: + return COLOR_NO_DATA; + } +}; + +export const getFixed = (value: number, places = 1): number => { + if (value % 1 === 0) { + return value; + } + return parseFloat(value.toFixed(places)); +}; + +export const numberFormatter: ( + value?: number, + options?: NumberFormatterOptions, +) => string = (value?: number, options = { truncate: true }) => { + if (value === undefined) { + return ''; + } + const truncateOptions = [ + { value: 1000000, suffix: 'm' }, + { value: 1000, suffix: 'k' }, + ]; + if (options.truncate) { + for (const truncateOption of truncateOptions) { + if (value >= truncateOption.value) { + const truncatedValue = value / truncateOption.value; + + if (truncatedValue % 1 !== 0) { + return `${truncatedValue.toFixed(1)}${truncateOption.suffix}`; + } + return `${truncatedValue}${truncateOption.suffix}`; + } + } + } + return `${getFixed(value)}`; +}; + +export const DefaultChangePercentage = { + color: '#0b0b0d', + percentage: 0, + icon: 'symbol-triangle-up', +}; + +export const calculateTotalChangePercentage = ( + changeSummaryTotal?: CategoryCountDetails, +): { color: string; percentage: number; icon: String } => { + if (changeSummaryTotal?.percentageChange) { + const { percentageChange } = changeSummaryTotal; + return { + color: percentageChange > 0 ? '#4dc952' : '#e43326', + percentage: Math.abs(percentageChange), + icon: + percentageChange > 0 ? 'symbol-triangle-up' : 'symbol-triangle-down', + }; + } + return DefaultChangePercentage; +}; + +export const getChangeIcon = (changeCategory?: ChangeTypes) => { + switch (changeCategory) { + case ChangeTypes.DEPLOYMENT: + return DeploymentIcon; + case ChangeTypes.INFRASTRUCTURE: + return InfraIcon; + case ChangeTypes.ALERT: + return AlertIcon; + case ChangeTypes.CHAOS_EXPERIMENT: + return ChaosIcon; + case ChangeTypes.FEATURE_FLAG: + return FFIcon; + default: + return DeploymentIcon; + } +}; + +export const getChangeTooltipText = (changeCategory?: ChangeTypes) => { + switch (changeCategory) { + case ChangeTypes.DEPLOYMENT: + return 'Deployment'; + case ChangeTypes.INFRASTRUCTURE: + return 'Infrastructure Change'; + case ChangeTypes.ALERT: + return 'Incident'; + case ChangeTypes.CHAOS_EXPERIMENT: + return 'Chaos Event'; + case ChangeTypes.FEATURE_FLAG: + return 'Feature Flag Change'; + default: + return 'Change'; + } +}; + +export const getRiskLabelStringId = (riskStatus?: String) => { + switch (riskStatus) { + case RiskValues.NO_DATA: + return 'No Data'; + case RiskValues.NO_ANALYSIS: + return 'No Analysis'; + case RiskValues.HEALTHY: + return 'Healthy'; + case RiskValues.OBSERVE: + return 'Observe'; + case RiskValues.WARNING: + return 'Warning'; + case RiskValues.NEED_ATTENTION: + return 'Need Attention'; + case RiskValues.UNHEALTHY: + return 'Unhealthy'; + default: + return 'NA'; + } +}; diff --git a/plugins/harness-srm/src/util/SloUtils.ts b/plugins/harness-srm/src/util/SloUtils.ts new file mode 100644 index 0000000..7e003ac --- /dev/null +++ b/plugins/harness-srm/src/util/SloUtils.ts @@ -0,0 +1,106 @@ +import { + AttentionIcon, + ExhaustedIcon, + HeartIcon, + ObserveIcon, + UnhealthyIcon, +} from '../components/Icons'; + +export enum EvaluationType { + WINDOW = 'Window', + REQUEST = 'Request', +} + +export enum RiskValues { + NO_DATA = 'NO_DATA', + NO_ANALYSIS = 'NO_ANALYSIS', + HEALTHY = 'HEALTHY', + OBSERVE = 'OBSERVE', + NEED_ATTENTION = 'NEED_ATTENTION', + EXHAUSTED = 'EXHAUSTED', + WARNING = 'WARNING', + UNHEALTHY = 'UNHEALTHY', + FAILED = 'FAILED', + PASSED = 'PASSED', + CUSTOMER_DEFINED_UNHEALTHY = 'CUSTOMER_DEFINED_UNHEALTHY', +} + +export type RiskTypes = keyof typeof RiskValues; +export enum SLOErrorBudget {} + +export function getEvaluationType(evaluationType: string) { + let evaluationLabel = ''; + switch (evaluationType) { + case EvaluationType.WINDOW: + evaluationLabel = 'Time Window'; + break; + case EvaluationType.REQUEST: + evaluationLabel = 'Request'; + break; + default: + break; + } + + return evaluationLabel; +} + +export function getStatusBackgroundColor(riskStatus?: RiskTypes) { + switch (riskStatus) { + case RiskValues.HEALTHY: + return '#e4f7e1'; + case RiskValues.OBSERVE: + return '#fff9e7'; + case RiskValues.NEED_ATTENTION: + return '#fff0e6'; + case RiskValues.UNHEALTHY: + return '#fcedec'; + case RiskValues.EXHAUSTED: + return '#fcedec'; + default: + return '#fafbfc'; + } +} + +export function getTextColor(riskStatus?: RiskTypes) { + switch (riskStatus) { + case RiskValues.HEALTHY: + return '#299b2c'; + case RiskValues.OBSERVE: + return '#fcb519'; + case RiskValues.NEED_ATTENTION: + return '#ff7020'; + case RiskValues.UNHEALTHY: + return '#b41710'; + case RiskValues.EXHAUSTED: + return '#b41710'; + default: + return '#383946'; + } +} + +export function objectLength(obj: any) { + let result = 0; + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + result++; + } + } + return result; +} + +export const getRiskColorLogo = (riskStatus?: RiskTypes) => { + switch (riskStatus) { + case RiskValues.HEALTHY: + return HeartIcon; + case RiskValues.OBSERVE: + return ObserveIcon; + case RiskValues.NEED_ATTENTION: + return AttentionIcon; + case RiskValues.UNHEALTHY: + return UnhealthyIcon; + case RiskValues.EXHAUSTED: + return ExhaustedIcon; + default: + return HeartIcon; + } +}; diff --git a/plugins/harness-srm/src/util/getHarnessToken.ts b/plugins/harness-srm/src/util/getHarnessToken.ts new file mode 100644 index 0000000..896e7d3 --- /dev/null +++ b/plugins/harness-srm/src/util/getHarnessToken.ts @@ -0,0 +1,10 @@ +export function getSecureHarnessKey(key: string): string | undefined { + try { + const token = JSON.parse(decodeURI(atob(localStorage.getItem(key) || ''))); + return token ? `Bearer ${token}` : ''; + } catch (err) { + // eslint-disable-next-line no-console + console.log('Failed to read Harness tokens'); + return undefined; + } +} diff --git a/yarn.lock b/yarn.lock index 5a5fbbf..22318ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7191,6 +7191,7 @@ anymatch@^3.0.3, anymatch@~3.1.2: "@backstage/theme" "^0.2.16" "@harnessio/backstage-plugin-ci-cd" "^0.6.0" "@harnessio/backstage-plugin-feature-flags" "^0.2.0" + "@harnessio/backstage-plugin-srm" "^0.1.0" "@material-ui/core" "^4.12.2" "@material-ui/icons" "^4.9.1" history "^5.0.0"