diff --git a/plugins/pagerduty-incidents/.eslintignore b/plugins/pagerduty-incidents/.eslintignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/pagerduty-incidents/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/pagerduty-incidents/.eslintrc.js b/plugins/pagerduty-incidents/.eslintrc.js new file mode 100644 index 0000000..fc0ef33 --- /dev/null +++ b/plugins/pagerduty-incidents/.eslintrc.js @@ -0,0 +1,29 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "standard-with-typescript", + "prettier", + ], + overrides: [], + parserOptions: { + ecmaVersion: "latest", + project: "tsconfig.json", + sourceType: "module", + tsconfigRootDir: __dirname, + }, + plugins: ["react"], + rules: { + // conflicts with no-extra-boolean-cast + "@typescript-eslint/strict-boolean-expressions": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/plugins/pagerduty-incidents/.gitignore b/plugins/pagerduty-incidents/.gitignore new file mode 100644 index 0000000..4c577a9 --- /dev/null +++ b/plugins/pagerduty-incidents/.gitignore @@ -0,0 +1,12 @@ +# OSX +*.DS_Store + +# IDEs +.idea +*.iml +.vscode + +# This project +node_modules/ +dist/ +yarn-error.log diff --git a/plugins/pagerduty-incidents/.prettierignore b/plugins/pagerduty-incidents/.prettierignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/pagerduty-incidents/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/pagerduty-incidents/README.md b/plugins/pagerduty-incidents/README.md new file mode 100644 index 0000000..b19027d --- /dev/null +++ b/plugins/pagerduty-incidents/README.md @@ -0,0 +1,89 @@ +# PagerDuty Incidents Cortex Plugin + +View PagerDuty incidents associated with your services!! + +
+ +The PagerDuty Incidents plugin shows PagerDuty incidents associated with the PagerDuty service specified in the entity's `cortex.yaml`. If the `cortex.yaml` has a PagerDuty service defined in its `x-cortex-oncall` configuration, it will query for incidents in that service. For example: + +```yaml +openapi: 3.0.1 +info: + title: funrepo + description: Description of funrepo + x-cortex-tag: funrepo + x-cortex-type: service + x-cortex-oncall: + pagerduty: + id: PXXXXXX + type: SERVICE +``` + +## Setup + +This plugin requires a proxy to PagerDuty. To set up: + +- In PagerDuty, create an API Token by clicking on Integrations > API Access Keys > Create New API Key +- In Cortex, define a secret whose value is your new token. Name it `pagerduty_secret`. +- Create a plugin proxy in Cortex: + + - Navigate to Plugins, then click on the Proxies tab, then click on Create Proxy + - Give the proxy a name, then click on Add URL + - For the URL Prefix, type in `https://api.pagerduty.com` + - Click on Add Header and add a header whose name is `Authorization` and whose value is `Token token={{{secrets.pagerduty_secret}}}` (include the curly braces!) + +- Once you are done, the proxy should look like the below: + +
+ +Now, you can build and add the plugin. + +- Build the plugin: + - Make sure you have npm/yarn + - In your terminal, in the `pagerduty-incidents` directory, type `yarn` or `npm install` to install the dependencies + - Type `npm run build` or `yarn build` to build the plugin +- The compiled plugin will be created in `dist/ui.html` +- In Plugins > All, click **Register Plugin** +- Give the plugin a name, like PagerDuty Incidents. This is the name users will see in the plugin listing. +- Under **Associated Proxy**, choose the proxy you just created. +- Under **Plugin Context**, click on Add another context; choose Selection type: Include, and Entity types: service. +- This plugin does not work in the Global context. Turn off the switch labeled **Include in global context** +- In The **Plugin code** section, upload the `dist/ui.html` file you just built. +- Click on **Save plugin** + +Now, when you navigate to a Cortex entity that has a PagerDuty oncall associated with it, you should be able to click on Plugins > PagerDuty Incidents and see the PagerDuty incidents associated with the service that is linked to the entity. + +If no PagerDuty oncall is set for the entity, you will see a service chooser that allows you to map the entity to a PagerDuty service. + +
+ +If you don't want to see the PagerDuty plugin at all on entities that don't have a PagerDuty oncall, then you can adjust the plugin visibility via CQL to only show in entities where `x-cortex-oncall.pagerduty.type = SERVICE`. + +# Setting up your dev environment + +PagerDuty Incidents Plugin is a [Cortex](https://www.cortex.io/) plugin. To see how to run the plugin inside of Cortex, see [our docs](https://docs.cortex.io/docs/plugins). + +### Prerequisites + +Developing and building this plugin requires either [yarn](https://classic.yarnpkg.com/lang/en/docs/install/) or [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). + +## Getting started + +1. Run `yarn` or `npm install` to download all dependencies +2. Run `yarn build` or `npm run build` to compile the plugin code into `./dist/ui.html` +3. Upload `ui.html` into Cortex on a create or edit plugin page +4. Add or update the code and repeat steps 2-3 as necessary + +### Notable scripts + +The following commands come pre-configured in this repository. You can see all available commands in the `scripts` section of [package.json](./package.json). They can be run with npm via `npm run {script_name}` or with yarn via `yarn {script_name}`, depending on your package manager preference. For instance, the `build` command can be run with `npm run build` or `yarn build`. + +- `build` - compiles the plugin. The compiled code root is `./src/index.tsx` (or as defined by [webpack.config.js](webpack.config.js)) and the output is generated into `dist/ui.html`. +- `test` - runs all tests defined in the repository using [jest](https://jestjs.io/) +- `lint` - runs lint and format checking on the repository using [prettier](https://prettier.io/) and [eslint](https://eslint.org/) +- `lintfix` - runs eslint in fix mode to fix any linting errors that can be fixed automatically +- `formatfix` - runs Prettier in fix mode to fix any formatting errors that can be fixed automatically + +### Available React components + +See available UI components via our [Storybook](https://cortexapps.github.io/plugin-core/). diff --git a/plugins/pagerduty-incidents/__mocks__/fileMock.js b/plugins/pagerduty-incidents/__mocks__/fileMock.js new file mode 100644 index 0000000..0a445d0 --- /dev/null +++ b/plugins/pagerduty-incidents/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "test-file-stub"; diff --git a/plugins/pagerduty-incidents/__mocks__/styleMock.js b/plugins/pagerduty-incidents/__mocks__/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/plugins/pagerduty-incidents/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/plugins/pagerduty-incidents/babel.config.js b/plugins/pagerduty-incidents/babel.config.js new file mode 100644 index 0000000..1442fdf --- /dev/null +++ b/plugins/pagerduty-incidents/babel.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: ["@babel/plugin-syntax-jsx"], + presets: [ + ["@babel/preset-env", { targets: { node: "current" } }], + "@babel/preset-typescript", + ["@babel/preset-react", { runtime: "automatic" }], + ], +}; diff --git a/plugins/pagerduty-incidents/img/pagerduty-chooser.png b/plugins/pagerduty-incidents/img/pagerduty-chooser.png new file mode 100644 index 0000000..f0d1660 Binary files /dev/null and b/plugins/pagerduty-incidents/img/pagerduty-chooser.png differ diff --git a/plugins/pagerduty-incidents/img/pagerduty-incidents.png b/plugins/pagerduty-incidents/img/pagerduty-incidents.png new file mode 100644 index 0000000..8a92435 Binary files /dev/null and b/plugins/pagerduty-incidents/img/pagerduty-incidents.png differ diff --git a/plugins/pagerduty-incidents/img/pagerduty-proxy.png b/plugins/pagerduty-incidents/img/pagerduty-proxy.png new file mode 100644 index 0000000..542b26b Binary files /dev/null and b/plugins/pagerduty-incidents/img/pagerduty-proxy.png differ diff --git a/plugins/pagerduty-incidents/jest.config.js b/plugins/pagerduty-incidents/jest.config.js new file mode 100644 index 0000000..3048b79 --- /dev/null +++ b/plugins/pagerduty-incidents/jest.config.js @@ -0,0 +1,22 @@ +module.exports = { + moduleNameMapper: { + // map static asset imports to a stub file under the assumption they are not important to our tests + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/__mocks__/fileMock.js", + // map style asset imports to a stub file under the assumption they are not important to our tests + "\\.(css|less)$": "/__mocks__/styleMock.js", + "@cortexapps/plugin-core/components": + "/../../node_modules/@cortexapps/plugin-core/dist/components.cjs.js", + "@cortexapps/plugin-core": + "/../../node_modules/@cortexapps/plugin-core/dist/index.cjs.js", + }, + setupFilesAfterEnv: ["/setupTests.ts"], + testEnvironment: "jsdom", + transform: { + "^.+\\.tsx?$": "babel-jest", + "^.+\\.js$": "babel-jest", + }, + transformIgnorePatterns: [ + "/node_modules/(?!yaml/)", // Add this line to tell Jest to transform `yaml` module + ], +}; diff --git a/plugins/pagerduty-incidents/package.json b/plugins/pagerduty-incidents/package.json new file mode 100644 index 0000000..fc2a613 --- /dev/null +++ b/plugins/pagerduty-incidents/package.json @@ -0,0 +1,67 @@ +{ + "name": "pagerduty-incidents-cortex-plugin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@chakra-ui/react": "^2.10.2", + "@cortexapps/plugin-core": "^2.0.0", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "framer-motion": "^11.11.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "yaml": "^2.5.1" + }, + "devDependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@popperjs/core": "^2.11.8", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.55.0", + "babel-jest": "^29.5.0", + "css-loader": "^6.7.3", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.7.0", + "eslint-config-standard-with-typescript": "^34.0.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.6.1", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "html-webpack-plugin": "^5.5.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "jest-fetch-mock": "^3.0.3", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.4", + "prop-types": "^15.8.1", + "react-dev-utils": "^12.0.1", + "style-loader": "^3.3.1", + "terser-webpack-plugin": "^5.3.7", + "ts-loader": "^9.4.2", + "typescript": "^4.9.5", + "url-loader": "^4.1.1", + "webpack": "^5.76.1", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.15.0" + }, + "scripts": { + "build": "webpack --mode=production", + "clean": "rm -r ./dist", + "dev": "webpack serve --mode=development", + "fix": "run-p formatfix lintfix", + "formatfix": "yarn prettier . --write", + "formatcheck": "yarn prettier . --check", + "lint": "run-p formatcheck lintcheck", + "lintcheck": "yarn eslint src", + "lintfix": "yarn lintcheck --fix", + "test": "jest" + } +} diff --git a/plugins/pagerduty-incidents/setupTests.ts b/plugins/pagerduty-incidents/setupTests.ts new file mode 100644 index 0000000..fa0a10d --- /dev/null +++ b/plugins/pagerduty-incidents/setupTests.ts @@ -0,0 +1,60 @@ +import "@testing-library/jest-dom/extend-expect"; +import fetchMock from "jest-fetch-mock"; + +fetchMock.enableMocks(); + +const mockContext = { + apiBaseUrl: "https://api.getcortexapp.com", + entity: { + definition: null, + description: null, + groups: null, + name: "Inventory planner", + ownership: { + emails: [ + { + description: null, + email: "nikhil@cortex.io", + inheritance: null, + id: 1, + }, + ], + }, + tag: "inventory-planner", + type: "service", + }, + location: "ENTITY", + user: { + email: "ganesh@cortex.io", + name: "Ganesh Datta", + role: "ADMIN", + }, +}; + +jest.mock("@cortexapps/plugin-core/components", () => { + const originalModule = jest.requireActual( + "@cortexapps/plugin-core/components" + ); + return { + ...originalModule, + usePluginContext: () => { + return mockContext; + }, + PluginProvider: ({ children }) => { + return children; + }, + }; +}); + +jest.mock("@cortexapps/plugin-core", () => { + const originalModule = jest.requireActual("@cortexapps/plugin-core"); + return { + ...originalModule, + CortexApi: { + ...originalModule.CortexApi, + getContext: () => { + return mockContext; + }, + }, + }; +}); diff --git a/plugins/pagerduty-incidents/src/api/Cortex.ts b/plugins/pagerduty-incidents/src/api/Cortex.ts new file mode 100644 index 0000000..01a0d12 --- /dev/null +++ b/plugins/pagerduty-incidents/src/api/Cortex.ts @@ -0,0 +1,8 @@ +export const getEntityYaml = async ( + baseUrl: string, + entityTag: string +): Promise> => { + const res = await fetch(`${baseUrl}/catalog/${entityTag}/openapi`); + + return await res.json(); +}; diff --git a/plugins/pagerduty-incidents/src/baseStyles.css b/plugins/pagerduty-incidents/src/baseStyles.css new file mode 100644 index 0000000..e9c5e5f --- /dev/null +++ b/plugins/pagerduty-incidents/src/baseStyles.css @@ -0,0 +1,3 @@ +body { + font: 14px sans-serif; +} diff --git a/plugins/pagerduty-incidents/src/components/App.test.tsx b/plugins/pagerduty-incidents/src/components/App.test.tsx new file mode 100644 index 0000000..bfcb46c --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/App.test.tsx @@ -0,0 +1,153 @@ +import { render, waitFor } from "@testing-library/react"; + +import fetchMock from "jest-fetch-mock"; + +import App from "./App"; +describe("App", () => { + beforeEach(() => { + // Reset fetchMock before each test to start with a clean slate + fetchMock.resetMocks(); + }); + + it("gets no pd mapping", async () => { + const mockBodies = { + "https://api.getcortexapp.com/catalog/inventory-planner/openapi": { + info: { + title: "Inventory Planner", + description: "it is a inventory planner", + "x-cortex-tag": "inventory-planner", + "x-cortex-type": "service", + }, + openapi: "3.0.1", + servers: [ + { + url: "/", + }, + ], + }, + "https://api.pagerduty.com/services": { + services: [], + }, + }; + + fetchMock.mockResponse(async (req) => { + const url = req.url.split("?")[0]; + if (!mockBodies[url]) { + return { + status: 404, + }; + } + const body = mockBodies[url] || {}; + return { + status: 200, + body: JSON.stringify(body), + }; + }); + + const { getByText } = render(); + + await waitFor(() => { + const element = getByText( + /This entity is not associated with any PagerDuty service./ + ); + expect(element).toBeInTheDocument(); + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching( + /https:\/\/api\.getcortexapp\.com\/catalog\/inventory-planner\/openapi/ + ) + ); + }); + }); + + it("gets a PD mapping but no oncall", async () => { + const mockBodies = { + "https://api.getcortexapp.com/catalog/inventory-planner/openapi": { + info: { + title: "Inventory Planner", + description: "it is a inventory planner", + "x-cortex-tag": "inventory-planner", + "x-cortex-type": "service", + "x-cortex-oncall": { + pagerduty: { + id: "PXXXXXX", + type: "SERVICE", + }, + }, + }, + openapi: "3.0.1", + servers: [ + { + url: "/", + }, + ], + }, + "https://api.pagerduty.com/services/PXXXXXX": { + service: { + id: "PXXXXXX", + type: "service", + summary: "My Application Service", + self: "https://api.pagerduty.com/services/PXXXXXX", + html_url: "https://subdomain.pagerduty.com/service-directory/PXXXXXX", + name: "My Application Service", + auto_resolve_timeout: 14400, + acknowledgement_timeout: 600, + created_at: "2015-11-06T11:12:51-05:00", + status: "active", + alert_creation: "create_alerts_and_incidents", + integrations: [], + escalation_policy: { + id: "PYYYYYY", + type: "escalation_policy_reference", + summary: "Another Escalation Policy", + self: "https://api.pagerduty.com/escalation_policies/PYYYYYY", + html_url: + "https://subdomain.pagerduty.com/escalation_policies/PYYYYYY", + }, + teams: [], + }, + }, + "https://api.pagerduty.com/oncalls": { + oncalls: [], + }, + "https://api.pagerduty.com/services": { + services: [], + }, + }; + + fetchMock.mockResponse(async (req) => { + const url = req.url.split("?")[0]; + const body = mockBodies[url] || {}; + return { + status: 200, + body: JSON.stringify(body), + }; + }); + + const { getByText } = render(); + + await waitFor(() => { + const element = getByText( + /No PagerDuty oncalls were found for this entity/ + ); + expect(element).toBeInTheDocument(); + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching( + /https:\/\/api\.getcortexapp\.com\/catalog\/inventory-planner\/gitops-logs/ + ) + ); + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching( + /https:\/\/api\.getcortexapp\.com\/catalog\/inventory-planner\/openapi/ + ) + ); + expect(fetch).toHaveBeenCalledWith( + "https://api.pagerduty.com/services/PXXXXXX", + { + headers: { + Accept: "application/vnd.pagerduty+json;version=2", + }, + } + ); + }); + }); +}); diff --git a/plugins/pagerduty-incidents/src/components/App.tsx b/plugins/pagerduty-incidents/src/components/App.tsx new file mode 100644 index 0000000..25c05f0 --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/App.tsx @@ -0,0 +1,20 @@ +import type React from "react"; +import { PluginProvider } from "@cortexapps/plugin-core/components"; +import { ChakraProvider } from "@chakra-ui/react"; +import "../baseStyles.css"; +import ErrorBoundary from "./ErrorBoundary"; +import PagerDutyPlugin from "./PagerDutyPlugin"; + +const App: React.FC = () => { + return ( + + + + + + + + ); +}; + +export default App; diff --git a/plugins/pagerduty-incidents/src/components/ErrorBoundary.tsx b/plugins/pagerduty-incidents/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..f862bd3 --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/ErrorBoundary.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +interface ErrorBoundaryProps extends React.PropsWithChildren {} + +interface ErrorBoundaryState { + hasError: boolean; +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + public state: ErrorBoundaryState = { + hasError: false, + }; + + public static getDerivedStateFromError(_: Error): ErrorBoundaryState { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("Uncaught error:", error, errorInfo); + } + + public render(): React.ReactNode { + if (this.state.hasError) { + return ( +

+ Oops! There was a runtime error. See the console for more details. +

+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/plugins/pagerduty-incidents/src/components/OnCallBadges.tsx b/plugins/pagerduty-incidents/src/components/OnCallBadges.tsx new file mode 100644 index 0000000..efe7f0f --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/OnCallBadges.tsx @@ -0,0 +1,75 @@ +import type React from "react"; +import { useMemo } from "react"; +import { Box, Badge, Text, Flex } from "@chakra-ui/react"; + +interface OnCallBadgesProps { + oncalls: any[]; +} + +const OnCallBadges: React.FC = ({ oncalls }) => { + const badges = useMemo(() => { + if (!oncalls || oncalls.length === 0) { + return []; + } + + const mapByLevel: Record = {}; + oncalls.forEach((oncall) => { + if (!mapByLevel[oncall.escalation_level]) { + mapByLevel[oncall.escalation_level] = []; + } + mapByLevel[oncall.escalation_level].push(oncall); + }); + + return Object.keys(mapByLevel) + .sort() + .map((level) => ( + + + Level {level} + + + {mapByLevel[level].map((oncall) => ( + { + window.open(oncall.user.html_url, "_blank"); + }} + > + {oncall.user.summary} + + ))} + + + )); + }, [oncalls]); + + if (badges.length === 0) { + return null; + } + + return ( + + + On Call + + {badges} + + ); +}; + +export default OnCallBadges; diff --git a/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx b/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx new file mode 100644 index 0000000..6f86be6 --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx @@ -0,0 +1,196 @@ +import type React from "react"; +import { useMemo } from "react"; +import { SimpleTable, Title, Loader } from "@cortexapps/plugin-core/components"; + +import { Avatar, AvatarGroup, Badge, Box, Text } from "@chakra-ui/react"; + +import { + usePagerDutyService, + usePagerDutyIncidents, + usePagerDutyOnCalls, +} from "../hooks"; +import OnCallBadges from "./OnCallBadges"; +import "../baseStyles.css"; + +interface PagerDutyIncidentsProps { + entityYaml: Record; +} + +const StatusBadge: React.FC<{ status: string }> = ({ status }) => { + const color = + status === "resolved" + ? "green" + : status === "acknowledged" + ? "yellow" + : "red"; + return ( + + {status} + + ); +}; + +const UrgencyBadge: React.FC<{ urgency: string }> = ({ urgency }) => { + const variant = urgency === "high" ? "solid" : "subtle"; + return ( + + {urgency} + + ); +}; + +const PagerDutyIncidents: React.FC = ({ + entityYaml, +}) => { + const pdId: string = useMemo( + () => entityYaml?.info?.["x-cortex-oncall"]?.pagerduty?.id || "", + [entityYaml?.info] + ); + const pdType: string = useMemo( + () => entityYaml?.info?.["x-cortex-oncall"]?.pagerduty?.type || "", + [entityYaml?.info] + ); + + const { + service, + isLoading: serviceLoading, + errorMessage: serviceError, + } = usePagerDutyService(pdId, pdType); + const { + incidents, + isLoading: incidentsLoading, + errorMessage: incidentsError, + } = usePagerDutyIncidents(pdId, pdType); + const { + oncalls, + isLoading: onCallsLoading, + errorMessage: onCallsError, + } = usePagerDutyOnCalls(service); + + const isLoading = serviceLoading || incidentsLoading || onCallsLoading; + const errorMessage = serviceError || incidentsError || onCallsError; + + const config = { + columns: [ + { + Cell: (createdAt: string) => ( + + + {new Date(createdAt).toLocaleString()} + + + ), + accessor: "created_at", + id: "created_at", + title: "Created At", + width: "content", + }, + { + Cell: (urgency: string) => ( + + + + ), + accessor: "urgency", + id: "urgency", + title: "Urgency", + width: "auto", + }, + { + Cell: (status: string) => ( + + + + ), + accessor: "status", + id: "status", + title: "Status", + width: "auto", + }, + { + Cell: (incident: any) => ( + + + + {incident.title} + + + + ), + id: "summary", + title: "Summary", + width: "auto", + }, + { + Cell: (incident: any) => { + const assignees = incident.assignments.map((assignment: any) => ( + { + window.open(assignment.assignee.html_url, "_blank"); + }} + cursor={"pointer"} + getInitials={(name) => { + const allNames = name.trim().split(" "); + const firstInitial = allNames[0].match(/./u)?.toString(); + const lastInitial = allNames[allNames.length - 1] + .match(/./u) + ?.toString(); + return `${firstInitial ?? ""}${lastInitial ?? ""}`; + }} + /> + )); + return ( + + + {assignees} + + + ); + }, + id: "assignees", + title: "Assignees", + }, + ], + }; + + if (isLoading) { + return ; + } + + if (errorMessage) { + return ( + + {errorMessage} + + ); + } + + return ( + + + PagerDuty Service{" "} + <a href={service?.html_url} target="_blank" rel="noreferrer"> + {service?.name} + </a> + + {/* Use the new component */} + {incidents.length > 0 ? ( + <> + + Recent Incidents + + + + ) : ( + 🎉 No incidents 🎉 + )} + + ); +}; + +export default PagerDutyIncidents; diff --git a/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx b/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx new file mode 100644 index 0000000..be8ef45 --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx @@ -0,0 +1,155 @@ +import type React from "react"; +import { useCallback, useState, useEffect, useMemo } from "react"; +import "../baseStyles.css"; + +import { usePluginContext } from "@cortexapps/plugin-core/components"; +import { Flex, Button, Select } from "@chakra-ui/react"; +import { parseDocument } from "yaml"; + +import { + usePagerDutyServices, + useErrorToast, + useErrorToastForResponse, +} from "../hooks"; + +interface PagerDutyPickerProps { + entityYaml: Record; + changed: () => void; +} + +const PagerDutyPicker: React.FC = ({ changed }) => { + const context = usePluginContext(); + const entityTag: string = useMemo( + () => context.entity?.tag ?? "", + [context.entity?.tag] + ); + const [serviceSelectOptions, setServiceSelectOptions] = useState([] as any[]); + const [selectedService, setSelectedService] = useState("" as any); + const [fetchedEntityDocument, setFetchedEntityDocument] = useState( + null as any + ); + + const errorToastForResponse = useErrorToastForResponse(); + const errorToast = useErrorToast(); + + const { services, isLoading, errorMessage } = usePagerDutyServices(); + + useEffect(() => { + if (errorMessage) { + console.error(errorMessage); + } + }, [errorMessage, errorToast]); + + const updateEntity = useCallback((): void => { + const doUpdate = async (): Promise => { + const newDoc = fetchedEntityDocument.clone(); + const info = newDoc.get("info"); + info.set("x-cortex-oncall", { + pagerduty: { + id: selectedService, + type: "SERVICE", + }, + }); + + const url = `${context.apiBaseUrl}/open-api`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/openapi;charset=utf-8", + }, + body: newDoc.toString(), + }); + if (!response.ok) { + errorToastForResponse(response); + return; + } + changed(); + }; + void doUpdate(); + }, [ + context.apiBaseUrl, + fetchedEntityDocument, + selectedService, + changed, + errorToastForResponse, + ]); + + useEffect(() => { + setServiceSelectOptions( + services.map((service) => ({ + key: service.id, + label: service.name, + })) + ); + }, [services]); + + useEffect(() => { + if (!entityTag || !context?.apiBaseUrl) { + return; + } + const fetchEntity = async (): Promise => { + const url = `${context.apiBaseUrl}/catalog/${entityTag}/openapi?yaml=true`; + const response = await fetch(url); + if (!response.ok) { + errorToastForResponse(response); + return; + } + const data = await response.text(); + const doc = parseDocument(data); + setFetchedEntityDocument(doc); + }; + void fetchEntity(); + }, [ + selectedService, + context.apiBaseUrl, + entityTag, + errorToast, + errorToastForResponse, + ]); + + const handleSelectChange = useCallback( + (e: React.ChangeEvent): void => { + setSelectedService(e.target.value); + }, + [] + ); + + return ( +
+ + + + +
+ ); +}; + +export default PagerDutyPicker; diff --git a/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx b/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx new file mode 100644 index 0000000..ed0b480 --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx @@ -0,0 +1,111 @@ +import { isEmpty, isNil } from "lodash"; +import type React from "react"; +import { useCallback, useEffect, useState } from "react"; +import { getEntityYaml } from "../api/Cortex"; +import { + Box, + Stack, + Title, + Link, + usePluginContext, +} from "@cortexapps/plugin-core/components"; + +import { Text } from "@chakra-ui/react"; + +import PagerDutyIncidents from "./PagerDutyIncidents"; +import PagerDutyPicker from "./PagerDutyPicker"; + +const PagerDutyPlugin: React.FC = () => { + const context = usePluginContext(); + const [entityYaml, setEntityYaml] = useState< + Record | undefined + >(); + + const [hasGitops, setHasGitops] = useState(null); + useEffect(() => { + if (!context?.entity?.tag || !context?.apiBaseUrl) { + return; + } + const fetchGitopsLogs = async (): Promise => { + try { + const entityTag: string = context.entity?.tag ?? ""; + const response = await fetch( + `${context.apiBaseUrl}/catalog/${entityTag}/gitops-logs` + ); + if (response.ok) { + setHasGitops(true); + } else { + setHasGitops(false); + } + } catch (e) { + setHasGitops(false); + } + }; + void fetchGitopsLogs(); + }, [context.entity?.tag, context.apiBaseUrl]); + + const [rerender, setRerender] = useState(0); + const forceRerender = useCallback(() => { + setRerender((prev) => prev + 1); + }, []); + + const fetchEntityYaml = useCallback(async () => { + const entityTag = context.entity?.tag; + + if (!isNil(entityTag)) { + const yaml = await getEntityYaml(context.apiBaseUrl, entityTag); + setEntityYaml(yaml); + } + }, [context.apiBaseUrl, context.entity?.tag]); + + useEffect(() => { + void fetchEntityYaml(); + }, [fetchEntityYaml, rerender]); + + return ( +
+ {!isEmpty(entityYaml) && ( + + + {isEmpty(entityYaml?.info?.["x-cortex-oncall"]?.pagerduty?.id) ? ( + + PagerDuty + {hasGitops === false && ( + <> + + This entity is not associated with any PagerDuty service. + To associate it with a service, select one from the + dropdown below. + + + + )} + {hasGitops === true && ( + + This entity is managed by GitOps. To associate it with a + PagerDuty service, update the entity's YAML file in Git, as + described in the{" "} + + documentation + + . + + )} + + ) : ( + + )} + + + )} +
+ ); +}; + +export default PagerDutyPlugin; diff --git a/plugins/pagerduty-incidents/src/hooks.tsx b/plugins/pagerduty-incidents/src/hooks.tsx new file mode 100644 index 0000000..856c638 --- /dev/null +++ b/plugins/pagerduty-incidents/src/hooks.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useCallback } from "react"; +import { useToast } from "@chakra-ui/react"; +import { cortexResponseError } from "./util"; + +export interface UsePagerDutyServicesReturn { + services: Array>; + isLoading: boolean; + errorMessage: string; +} + +export interface UsePagerDutyServiceReturn { + service: Record; + isLoading: boolean; + errorMessage: string; +} + +export interface UsePagerDutyIncidentsReturn { + incidents: Array>; + isLoading: boolean; + errorMessage: string; +} + +export interface UsePagerDutyOnCallsReturn { + oncalls: Array>; + isLoading: boolean; + errorMessage: string; +} + +interface ErrorToastProps { + title?: string; + message?: string; +} + +export const useErrorToast = (): ((props: ErrorToastProps) => void) => { + const toast = useToast(); + const errorToast = useCallback( + ({ title = "Error", message = "An error occurred" }: ErrorToastProps) => { + toast({ + title, + description: message, + status: "error", + duration: 5000, + isClosable: true, + }); + }, + [toast] + ); + + return errorToast; +}; + +export const useErrorToastForResponse = (): ((response: Response) => void) => { + const errorToast = useErrorToast(); + const errorToastForResponse = useCallback( + (response: Response) => { + const { status, message } = cortexResponseError(response); + errorToast({ + title: `HTTP Error ${status}`, + message, + }); + }, + [errorToast] + ); + + return errorToastForResponse; +}; + +export const usePagerDutyServices = (): UsePagerDutyServicesReturn => { + const [services, setServices] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const errorToastForResponse = useErrorToastForResponse(); + + useEffect(() => { + const fetchServices = async (): Promise => { + setIsLoading(true); + setErrorMessage(""); + let more = false; + let offset = 0; + const limit = 25; + let allServices: any[] = []; + + try { + do { + const url = `https://api.pagerduty.com/services?limit=${limit}&offset=${offset}`; + const response = await fetch(url, { + headers: { + Accept: "application/vnd.pagerduty+json;version=2", + }, + }); + + if (!response.ok) { + errorToastForResponse(response); + throw new Error(`HTTP Error ${response.status}`); + } + + const data = await response.json(); + allServices = allServices.concat(data.services || []); + more = data.more || false; + offset += limit; + } while (more); + + setServices(allServices); + } catch (err: any) { + const msg: string = err.message || "Unknown error"; + setErrorMessage(`Error fetching PagerDuty services: ${msg}`); + } finally { + setIsLoading(false); + } + }; + + void fetchServices(); + }, [errorToastForResponse]); + + return { services, isLoading, errorMessage }; +}; + +export const usePagerDutyService = ( + pdId: string, + pdType: string +): UsePagerDutyServiceReturn => { + const [service, setService] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const errorToastForResponse = useErrorToastForResponse(); + + useEffect(() => { + const fetchService = async (): Promise => { + setIsLoading(true); + setErrorMessage(""); + + if (!pdId || pdType !== "SERVICE") { + setErrorMessage( + "This entity is not associated with a PagerDuty service" + ); + setIsLoading(false); + return; + } + + try { + const url = `https://api.pagerduty.com/services/${pdId}`; + const response = await fetch(url, { + headers: { + Accept: "application/vnd.pagerduty+json;version=2", + }, + }); + + if (!response.ok) { + errorToastForResponse(response); + throw new Error( + `HTTP Error ${response.status}: ${response.statusText}` + ); + } + + const data = await response.json(); + if (data.service) { + setService(data.service); + } else { + setErrorMessage("No PagerDuty service was found for this entity"); + } + } catch (err: any) { + const msg: string = err.message || "Unknown error"; + setErrorMessage(`Error fetching PagerDuty service: ${msg}`); + } + + setIsLoading(false); + }; + + void fetchService(); + }, [pdId, pdType, errorToastForResponse]); + + return { service, isLoading, errorMessage }; +}; + +export const usePagerDutyIncidents = ( + pdId: string, + pdType: string +): UsePagerDutyIncidentsReturn => { + const [incidents, setIncidents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const errorToastForResponse = useErrorToastForResponse(); + + useEffect(() => { + if (!pdId || pdType !== "SERVICE") return; + + const fetchIncidents = async (): Promise => { + setIsLoading(true); + setErrorMessage(""); + + try { + const url = `https://api.pagerduty.com/incidents`; + const params = new URLSearchParams(); + params.append("service_ids[]", pdId); + params.append("statuses[]", "triggered"); + params.append("statuses[]", "acknowledged"); + params.append("statuses[]", "resolved"); + params.append("limit", "20"); + params.append("sort_by", "created_at:desc"); + + const response = await fetch(`${url}?${params.toString()}`, { + headers: { + Accept: "application/vnd.pagerduty+json;version=2", + }, + }); + + if (!response.ok) { + errorToastForResponse(response); + throw new Error( + `HTTP Error ${response.status}: ${response.statusText}` + ); + } + + const data = await response.json(); + if (Array.isArray(data.incidents) && data.incidents.length > 0) { + setIncidents(data.incidents); + } else { + setIncidents([]); + } + } catch (err: any) { + const msg: string = err.message || "Unknown error"; + setErrorMessage(`Error fetching PagerDuty incidents: ${msg}`); + } + + setIsLoading(false); + }; + + void fetchIncidents(); + }, [pdId, pdType, errorToastForResponse]); + + return { incidents, isLoading, errorMessage }; +}; + +export const usePagerDutyOnCalls = ( + service: any +): UsePagerDutyOnCallsReturn => { + const [oncalls, setOncalls] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const errorToastForResponse = useErrorToastForResponse(); + + useEffect(() => { + if (!service) return; + + const fetchOnCalls = async (): Promise => { + setIsLoading(true); + setErrorMessage(""); + + try { + const url = `https://api.pagerduty.com/oncalls`; + const params = new URLSearchParams(); + params.append("escalation_policy_ids[]", service.escalation_policy.id); + + const response = await fetch(`${url}?${params.toString()}`, { + headers: { + Accept: "application/vnd.pagerduty+json;version=2", + }, + }); + + if (!response.ok) { + errorToastForResponse(response); + throw new Error( + `HTTP Error ${response.status}: ${response.statusText}` + ); + } + + const data = await response.json(); + if (Array.isArray(data.oncalls) && data.oncalls.length > 0) { + setOncalls(data.oncalls); + } else { + setErrorMessage("No PagerDuty oncalls were found for this entity"); + } + } catch (err: any) { + const msg: string = err.message || "Unknown error"; + setErrorMessage(`Error fetching PagerDuty oncalls: ${msg}`); + } + + setIsLoading(false); + }; + + void fetchOnCalls(); + }, [service, errorToastForResponse]); + + return { oncalls, isLoading, errorMessage }; +}; diff --git a/plugins/pagerduty-incidents/src/index.html b/plugins/pagerduty-incidents/src/index.html new file mode 100644 index 0000000..dd8e7eb --- /dev/null +++ b/plugins/pagerduty-incidents/src/index.html @@ -0,0 +1,10 @@ + + + + +
+ diff --git a/plugins/pagerduty-incidents/src/index.tsx b/plugins/pagerduty-incidents/src/index.tsx new file mode 100644 index 0000000..2a10097 --- /dev/null +++ b/plugins/pagerduty-incidents/src/index.tsx @@ -0,0 +1,9 @@ +import { createRoot } from "react-dom/client"; +import App from "./components/App"; + +document.addEventListener("DOMContentLoaded", function () { + const container = document.getElementById("cortex-plugin-root"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const root = createRoot(container!); + root.render(); +}); diff --git a/plugins/pagerduty-incidents/src/typings.d.ts b/plugins/pagerduty-incidents/src/typings.d.ts new file mode 100644 index 0000000..1a3dd3c --- /dev/null +++ b/plugins/pagerduty-incidents/src/typings.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: any; + export default content; +} diff --git a/plugins/pagerduty-incidents/src/util.ts b/plugins/pagerduty-incidents/src/util.ts new file mode 100644 index 0000000..8fb8a6d --- /dev/null +++ b/plugins/pagerduty-incidents/src/util.ts @@ -0,0 +1,17 @@ +export interface CortexResponseErrorReturn { + status: number; + message: string; +} + +export const cortexResponseError = ( + response: Response +): CortexResponseErrorReturn => { + const status = response.status; + let message = "Unknown error"; + try { + const r = response as any; + const cMsg = r.cortexResponse?.statusText; + message = cMsg || message; + } catch (e) {} + return { status, message }; +}; diff --git a/plugins/pagerduty-incidents/tsconfig.json b/plugins/pagerduty-incidents/tsconfig.json new file mode 100644 index 0000000..ffdca3b --- /dev/null +++ b/plugins/pagerduty-incidents/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "noImplicitAny": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist", + "removeComments": true, + "strictNullChecks": true, + "target": "es6", + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"] + } +} diff --git a/plugins/pagerduty-incidents/webpack.config.js b/plugins/pagerduty-incidents/webpack.config.js new file mode 100644 index 0000000..851c75f --- /dev/null +++ b/plugins/pagerduty-incidents/webpack.config.js @@ -0,0 +1,69 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin"); +const TerserPlugin = require("terser-webpack-plugin"); +const path = require("path"); + +module.exports = (env, argv) => ({ + mode: argv.mode === "production" ? "production" : "development", + + entry: { + ui: "./src/index.tsx", // The entry point for your UI plugin + }, + + module: { + rules: [ + // Converts TypeScript code to JavaScript + { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }, + + // Enables including CSS by doing "import './file.css'" in your TypeScript code + { test: /\.css$/, use: ["style-loader", { loader: "css-loader" }] }, + + // Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI + { test: /\.(png|jpg|gif|webp|svg)$/, loader: "url-loader" }, + ], + }, + + // minify the code + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + output: { + // make sure emojis don't get mangled 🙂 + ascii_only: true, + }, + }, + }), + ], + usedExports: true, + }, + + // Webpack tries these extensions for you if you omit the extension, like "import './file'" + resolve: { extensions: [".tsx", ".ts", ".jsx", ".js"] }, + + output: { + filename: "[name].js", + path: path.resolve(__dirname, "dist"), // Compile into a folder named "dist" + publicPath: "", + }, + + // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it + plugins: [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + filename: "ui.html", + chunks: ["ui"], + cache: false, + }), + new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/]), + ], + + devServer: { + compress: true, + port: 9000, + static: { + directory: path.join(__dirname, "dist"), + }, + }, +});