diff --git a/plugins/pagerduty-incidents/src/components/App.test.tsx b/plugins/pagerduty-incidents/src/components/App.test.tsx index bfcb46c..4768cad 100644 --- a/plugins/pagerduty-incidents/src/components/App.test.tsx +++ b/plugins/pagerduty-incidents/src/components/App.test.tsx @@ -1,15 +1,15 @@ -import { render, waitFor } from "@testing-library/react"; - +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import fetchMock from "jest-fetch-mock"; - import App from "./App"; +import { act } from "react-dom/test-utils"; + describe("App", () => { beforeEach(() => { // Reset fetchMock before each test to start with a clean slate fetchMock.resetMocks(); }); - it("gets no pd mapping", async () => { + it("does initial configuration when not configured", async () => { const mockBodies = { "https://api.getcortexapp.com/catalog/inventory-planner/openapi": { info: { @@ -25,13 +25,27 @@ describe("App", () => { }, ], }, - "https://api.pagerduty.com/services": { - services: [], + "https://api.getcortexapp.com/plugins/pagerduty-incidents": { + description: + "https://plugin-marketplace.s3.us-east-2.amazonaws.com/pagerduty-plugin/ui.html", }, + "https://api.getcortexapp.com/api/internal/v1/secrets": {}, + "https://api.getcortexapp.com/api/internal/v1/proxies": {}, + "https://plugin-marketplace.s3.us-east-2.amazonaws.com/pagerduty-plugin/ui.html": + {}, }; fetchMock.mockResponse(async (req) => { const url = req.url.split("?")[0]; + if (url === "https://api.pagerduty.com/abilities") { + // return success if authorization header contains our fake token + if (req.headers.get("Authorization") === "Token token=fake_token_123") { + return { + status: 200, + body: JSON.stringify({ abilities: [] }), + }; + } + } if (!mockBodies[url]) { return { status: 404, @@ -44,13 +58,155 @@ describe("App", () => { }; }); - const { getByText } = render(); + const { getByText, getByLabelText, getByRole } = render(); + // Wait for initial text to load await waitFor(() => { const element = getByText( - /This entity is not associated with any PagerDuty service./ + /To configure this plugin automatically, you need a PagerDuty API key/i ); expect(element).toBeInTheDocument(); + }); + + // Simulate clicking the "Configure" button to open the modal + const configureButton = screen.getByRole("button", { name: /Configure/i }); + fireEvent.click(configureButton); + + // Simulate typing a fake token into the input field + const tokenInput = getByLabelText(/PagerDuty REST API Token/i); + fireEvent.change(tokenInput, { target: { value: "fake_token_123" } }); + + const submitButton = getByRole("button", { name: "Submit" }); + // Wait for the Submit button to be enabled + await waitFor( + () => { + expect(submitButton).not.toBeDisabled(); + expect(submitButton).toBeVisible(); + }, + { timeout: 1000 } + ); + + await act(async () => { + // Simulate clicking the Submit button + fireEvent.click(submitButton); + }); + + await waitFor( + () => { + const element = getByText(/Configuration completed successfully/i); + expect(element).toBeInTheDocument(); + }, + { timeout: 1000 } + ); + + expect(fetch).toHaveBeenCalledWith("https://api.pagerduty.com/abilities", { + headers: { + Authorization: "Token token=fake_token_123", + }, + }); + }); + + 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/abilities": { + abilities: [], + }, + "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: [ + { + 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: [], + }, + ], + }, + }; + + 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(/Select a service/); + 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/ diff --git a/plugins/pagerduty-incidents/src/components/Instructions.tsx b/plugins/pagerduty-incidents/src/components/Instructions.tsx new file mode 100644 index 0000000..335f38c --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/Instructions.tsx @@ -0,0 +1,121 @@ +import type React from "react"; + +import { + Text, + Box, + Button, + Link, + useDisclosure, + Heading, +} from "@chakra-ui/react"; + +import InstructionsModal from "./InstructionsModal"; +import { useState, useEffect } from "react"; + +import { usePluginContext } from "@cortexapps/plugin-core/components"; + +const Instructions: React.FC = () => { + const { apiBaseUrl } = usePluginContext(); + const { + isOpen: isInstuctionsModalOpen, + onOpen: onInstructionsModalOpen, + onClose: onInstructionsModalClose, + } = useDisclosure(); + + const [isMarketplacePlugin, setIsMarketplacePlugin] = useState< + boolean | null + >(null); + const [configCompleted, setConfigCompleted] = useState(false); + + useEffect(() => { + const fetchMarketplacePlugin = async (): Promise => { + try { + const response = await fetch( + `${apiBaseUrl}/plugins/pagerduty-incidents` + ); + const { description } = await response.json(); + setIsMarketplacePlugin( + description.includes( + "https://plugin-marketplace.s3.us-east-2.amazonaws.com/pagerduty-plugin/ui.html" + ) + ); + } catch (e) { + setIsMarketplacePlugin(false); + } + }; + void fetchMarketplacePlugin(); + }, [apiBaseUrl]); + + const onConfigCompleted = (): void => { + setConfigCompleted(true); + }; + + if (configCompleted) { + return ( + + + Configure PagerDuty Incidents Plugin + + + Configuration completed successfully. Please refresh the page to start + using the plugin. + + + ); + } + + return ( + + + Configure PagerDuty Incidents Plugin + + {isMarketplacePlugin && ( + <> + + To configure this plugin automatically, you need a PagerDuty API key + and permissions to create proxies and secrets in Cortex. Click the + button below to enter your PagerDuty API key and do automatic + configuration. + + + + + )} + {isMarketplacePlugin === false && ( + + This plugin was not installed by the Plugin Marketplace, so it cannot + be configured automatically. To configure it manually, follow the + instructions in the plugin documentation{" "} + + here + + . + + )} + + ); +}; + +export default Instructions; diff --git a/plugins/pagerduty-incidents/src/components/InstructionsModal.tsx b/plugins/pagerduty-incidents/src/components/InstructionsModal.tsx new file mode 100644 index 0000000..d0105be --- /dev/null +++ b/plugins/pagerduty-incidents/src/components/InstructionsModal.tsx @@ -0,0 +1,131 @@ +import type React from "react"; +import { useState, useEffect, useCallback } from "react"; + +import { + Box, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + Button, + FormControl, + FormLabel, + Input, + FormErrorMessage, + Flex, +} from "@chakra-ui/react"; + +import { useIsPagerDutyTokenValid } from "../hooks/pagerDutyHooks"; +import { usePluginUpdateFns } from "../hooks/cortexHooks"; + +interface InstructionsModalProps { + isOpen: boolean; + onClose: () => void; + onConfigCompleted: () => void; +} + +const InstructionsModal: React.FC = ({ + isOpen, + onClose, + onConfigCompleted, +}) => { + const { doAddSecret, doAddProxy, doUpdatePlugin } = usePluginUpdateFns(); + + const [tokenInput, setTokenInput] = useState(""); + const [debouncedTokenInput, setDebouncedTokenInput] = useState(""); + + const tokenIsValid = useIsPagerDutyTokenValid(debouncedTokenInput); + + const handleInputChange = (e: React.ChangeEvent): void => { + setTokenInput(e.target.value); + }; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedTokenInput(tokenInput); + }, 100); + + return () => { + clearTimeout(handler); + }; + }, [tokenInput]); + + const handleFormSubmit = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + const didUpdatePlugin = + (await doAddSecret("pagerduty_secret", debouncedTokenInput)) && + (await doAddProxy( + "PagerDuty Plugin Proxy", + "pagerduty-plugin-proxy", + "pagerduty_secret" + )) && + (await doUpdatePlugin( + "pagerduty-incidents", + "https://plugin-marketplace.s3.us-east-2.amazonaws.com/pagerduty-plugin/ui.html", + "pagerduty-plugin-proxy" + )); + setTokenInput(""); + if (didUpdatePlugin) { + onConfigCompleted(); + } + onClose(); + }, + [ + onClose, + doAddSecret, + doAddProxy, + doUpdatePlugin, + onConfigCompleted, + debouncedTokenInput, + ] + ); + + return ( + + + + Configure Plugin Proxy + + + + + PagerDuty REST API Token + + {!tokenIsValid && ( + + Please enter a valid token. + + )} + + + + + + + + + + ); +}; + +export default InstructionsModal; diff --git a/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx b/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx index 6f86be6..dcb4a41 100644 --- a/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx +++ b/plugins/pagerduty-incidents/src/components/PagerDutyIncidents.tsx @@ -8,7 +8,7 @@ import { usePagerDutyService, usePagerDutyIncidents, usePagerDutyOnCalls, -} from "../hooks"; +} from "../hooks/pagerDutyHooks"; import OnCallBadges from "./OnCallBadges"; import "../baseStyles.css"; diff --git a/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx b/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx index be8ef45..120626e 100644 --- a/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx +++ b/plugins/pagerduty-incidents/src/components/PagerDutyPicker.tsx @@ -6,11 +6,9 @@ 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"; +import { usePagerDutyServices } from "../hooks/pagerDutyHooks"; + +import { useErrorToast, useErrorToastForResponse } from "../hooks/uiHooks"; interface PagerDutyPickerProps { entityYaml: Record; diff --git a/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx b/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx index ed0b480..8212188 100644 --- a/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx +++ b/plugins/pagerduty-incidents/src/components/PagerDutyPlugin.tsx @@ -7,13 +7,17 @@ import { Stack, Title, Link, + Loader, usePluginContext, } from "@cortexapps/plugin-core/components"; import { Text } from "@chakra-ui/react"; +import { useIsPagerDutyConfigured } from "../hooks/pagerDutyHooks"; + import PagerDutyIncidents from "./PagerDutyIncidents"; import PagerDutyPicker from "./PagerDutyPicker"; +import Instructions from "./Instructions"; const PagerDutyPlugin: React.FC = () => { const context = usePluginContext(); @@ -21,6 +25,8 @@ const PagerDutyPlugin: React.FC = () => { Record | undefined >(); + const isConfigured = useIsPagerDutyConfigured(); + const [hasGitops, setHasGitops] = useState(null); useEffect(() => { if (!context?.entity?.tag || !context?.apiBaseUrl) { @@ -62,6 +68,14 @@ const PagerDutyPlugin: React.FC = () => { void fetchEntityYaml(); }, [fetchEntityYaml, rerender]); + if (isConfigured === null) { + return ; + } + + if (!isConfigured) { + return ; + } + return (
{!isEmpty(entityYaml) && ( diff --git a/plugins/pagerduty-incidents/src/hooks/cortexHooks.tsx b/plugins/pagerduty-incidents/src/hooks/cortexHooks.tsx new file mode 100644 index 0000000..8f5a906 --- /dev/null +++ b/plugins/pagerduty-incidents/src/hooks/cortexHooks.tsx @@ -0,0 +1,196 @@ +import { usePluginContext } from "@cortexapps/plugin-core/components"; + +import { useErrorToast } from "./uiHooks"; +import { useCallback } from "react"; + +export interface PluginUpdateFns { + doAddSecret: (name: string, secret: string) => Promise; + doAddProxy: ( + name: string, + tag: string, + secretTag: string + ) => Promise; + doUpdatePlugin: ( + pluginTag: string, + uiBlobUrl: string, + proxyTag: string + ) => Promise; +} + +export const usePluginUpdateFns = (): PluginUpdateFns => { + const errorToast = useErrorToast(); + const { apiBaseUrl } = usePluginContext(); + const apiOrigin = apiBaseUrl ? new URL(apiBaseUrl).origin : ""; + const internalBaseUrl = apiOrigin ? `${apiOrigin}/api/internal/v1` : ""; + + const doAddSecret = useCallback( + async (name: string, secret: string): Promise => { + if (!internalBaseUrl) { + errorToast({ + title: "Failed to add secret", + message: "Internal base URL not available", + }); + return false; + } + const body = JSON.stringify({ + name, + tag: name, + secret, + }); + + try { + const response = await fetch(`${internalBaseUrl}/secrets`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + }); + + if (!response?.ok) { + const message = + (response as any).cortexResponse?.statusText || + response.statusText || + "An error occurred"; + throw new Error(message); + } + return true; + } catch (e) { + let message = e.message || "An error occurred"; + try { + const data = JSON.parse(e.message); + message = data.details || data.message || message; + } catch (e) { + // Ignore + } + errorToast({ + title: "Failed to add secret", + message, + }); + } + return false; + }, + [internalBaseUrl, errorToast] + ); + + const doAddProxy = useCallback( + async (name: string, tag: string, secretTag: string): Promise => { + if (!internalBaseUrl) { + errorToast({ + title: "Failed to add proxy", + message: "Internal base URL not available", + }); + return false; + } + const body = JSON.stringify({ + name, + tag, + urlConfigurations: { + "https://api.pagerduty.com": { + urlHeaders: [ + { + name: "Authorization", + value: `Token token={{{secrets.${secretTag}}}}`, + }, + ], + }, + }, + }); + + try { + const response = await fetch(`${internalBaseUrl}/proxies`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + }); + + if (!response?.ok) { + const message = + (response as any).cortexResponse?.statusText || + response.statusText || + "An error occurred"; + throw new Error(message); + } + return true; + } catch (e) { + console.error("Failed to add proxy", e); + let message = e.message || "An error occurred"; + try { + const data = JSON.parse(e.message); + message = data.details || data.message || message; + } catch (e) { + // Ignore + } + errorToast({ + title: "Failed to add proxy", + message, + }); + } + return false; + }, + [internalBaseUrl, errorToast] + ); + + const doUpdatePlugin = useCallback( + async ( + pluginTag: string, + uiBlobUrl: string, + proxyTag: string + ): Promise => { + try { + let response = await fetch(`${apiBaseUrl}/plugins/${pluginTag}`); + if (!response.ok) { + throw new Error("Failed to fetch plugin metadata"); + } + const plugin = await response.json(); + const pluginDescription = plugin.description || ""; + if (!pluginDescription.includes("plugin-marketplace")) { + throw new Error( + "This pagerduty-incidents plugin was not installed by the Plugin Marketplace" + ); + } + response = await fetch(uiBlobUrl); + if (!response.ok) { + throw new Error("Failed to fetch plugin UI"); + } + const ui = await response.text(); + plugin.proxyTag = proxyTag; + plugin.blob = ui; + response = await fetch(`${apiBaseUrl}/plugins/${pluginTag}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(plugin), + }); + if (!response.ok) { + throw new Error("Failed to update plugin"); + } + return true; + } catch (e) { + console.error("Failed to update plugin", e); + let message = e.message || "An error occurred"; + try { + const data = JSON.parse(e.message); + message = data.details || data.message || message; + } catch (e) { + // Ignore + } + errorToast({ + title: "Failed to update plugin", + message, + }); + } + return false; + }, + [apiBaseUrl, errorToast] + ); + + return { + doAddSecret, + doAddProxy, + doUpdatePlugin, + }; +}; diff --git a/plugins/pagerduty-incidents/src/hooks.tsx b/plugins/pagerduty-incidents/src/hooks/pagerDutyHooks.tsx similarity index 83% rename from plugins/pagerduty-incidents/src/hooks.tsx rename to plugins/pagerduty-incidents/src/hooks/pagerDutyHooks.tsx index 856c638..4049721 100644 --- a/plugins/pagerduty-incidents/src/hooks.tsx +++ b/plugins/pagerduty-incidents/src/hooks/pagerDutyHooks.tsx @@ -1,6 +1,5 @@ -import { useState, useEffect, useCallback } from "react"; -import { useToast } from "@chakra-ui/react"; -import { cortexResponseError } from "./util"; +import { useState, useEffect } from "react"; +import { useErrorToastForResponse } from "./uiHooks"; export interface UsePagerDutyServicesReturn { services: Array>; @@ -26,43 +25,61 @@ export interface UsePagerDutyOnCallsReturn { errorMessage: string; } -interface ErrorToastProps { - title?: string; - message?: string; -} +export const useIsPagerDutyTokenValid = (token: string): boolean => { + const [isValid, setIsValid] = useState(false); + + useEffect(() => { + if (!token) { + setIsValid(false); + return; + } + + const checkPagerDutyAbilities = async (): Promise => { + try { + const response = await fetch(`https://api.pagerduty.com/abilities`, { + headers: { + Authorization: `Token token=${token}`, + }, + }); + + if (response.ok) { + setIsValid(true); + } else { + setIsValid(false); + } + } catch (e) { + setIsValid(false); + } + }; + + void checkPagerDutyAbilities(); + }, [token]); -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; + return isValid; }; -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 useIsPagerDutyConfigured = (): boolean | null => { + const [isConfigured, setIsConfigured] = useState(null); + + useEffect(() => { + const checkPagerDutyAbilities = async (): Promise => { + try { + const response = await fetch(`https://api.pagerduty.com/abilities`); + + if (response.ok) { + setIsConfigured(true); + } else { + setIsConfigured(false); + } + } catch (e) { + setIsConfigured(false); + } + }; + + void checkPagerDutyAbilities(); + }, []); + + return isConfigured; }; export const usePagerDutyServices = (): UsePagerDutyServicesReturn => { diff --git a/plugins/pagerduty-incidents/src/hooks/uiHooks.tsx b/plugins/pagerduty-incidents/src/hooks/uiHooks.tsx new file mode 100644 index 0000000..6f390e1 --- /dev/null +++ b/plugins/pagerduty-incidents/src/hooks/uiHooks.tsx @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import { useToast } from "@chakra-ui/react"; +import { cortexResponseError } from "../util"; + +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; +};