diff --git a/package-lock.json b/package-lock.json index 1891eb01b..7d608be23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "recharts": "2.1.12", "tailwindcss": "^3.0.24", "timeago.js": "^4.0.2", + "type-fest": "^4.8.2", "typescript": "^4.7.2", "uuid": "^8.3.2", "web-vitals": "^2.1.4", @@ -7623,6 +7624,17 @@ "node": ">=14.14" } }, + "node_modules/@storybook/csf/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@storybook/docs-mdx": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@storybook/docs-mdx/-/docs-mdx-0.1.0.tgz", @@ -8185,6 +8197,18 @@ "undici-types": "~5.26.4" } }, + "node_modules/@storybook/react/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@storybook/router": { "version": "7.6.1", "resolved": "https://registry.npmjs.org/@storybook/router/-/router-7.6.1.tgz", @@ -23287,6 +23311,18 @@ "node": ">=8" } }, + "node_modules/msw/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -23668,6 +23704,18 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, + "node_modules/node-polyfill-webpack-plugin/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -34303,6 +34351,17 @@ "node": ">=12" } }, + "node_modules/snakecase-keys/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -36850,11 +36909,11 @@ } }, "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.2.tgz", + "integrity": "sha512-mcvrCjixA5166hSrUoJgGb9gBQN4loMYyj9zxuMs/66ibHNEFd5JXMw37YVDx58L4/QID9jIzdTBB4mDwDJ6KQ==", "engines": { - "node": ">=12.20" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 99f57e976..4459a303b 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "recharts": "2.1.12", "tailwindcss": "^3.0.24", "timeago.js": "^4.0.2", + "type-fest": "^4.8.2", "typescript": "^4.7.2", "uuid": "^8.3.2", "web-vitals": "^2.1.4", diff --git a/src/App.tsx b/src/App.tsx index 366194bee..3df626a93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { BsLink, BsToggles } from "react-icons/bs"; import { FaBell, FaTasks } from "react-icons/fa"; import { HiUser } from "react-icons/hi"; import { ImLifebuoy } from "react-icons/im"; +import { MdOutlineSupportAgent } from "react-icons/md"; import { VscJson } from "react-icons/vsc"; import { BrowserRouter, @@ -19,6 +20,7 @@ import { import ReactTooltip from "react-tooltip"; import { Canary } from "./components"; import { withAccessCheck } from "./components/AccessCheck/AccessCheck"; +import AgentsPage from "./components/Agents/AgentPage"; import AuthProviderWrapper from "./components/Authentication/AuthProviderWrapper"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { LogsIcon } from "./components/Icons/LogsIcon"; @@ -57,18 +59,16 @@ import { ConnectionsPage } from "./pages/Settings/ConnectionsPage"; import { EventQueueStatusPage } from "./pages/Settings/EventQueueStatus"; import { FeatureFlagsPage } from "./pages/Settings/FeatureFlagsPage"; import { LogBackendsPage } from "./pages/Settings/LogBackendsPage"; -import { PlaybooksListPage } from "./pages/playbooks/PlaybooksList"; +import { TopologyCardPage } from "./pages/TopologyCard"; import { UsersPage } from "./pages/UsersPage"; import { ConfigDetailsInsightsPage } from "./pages/config/ConfigDetailsInsightsPage"; import { ConfigInsightsPage } from "./pages/config/ConfigInsightsList"; import { HealthPage } from "./pages/health"; import PlaybookRunsPage from "./pages/playbooks/PlaybookRuns"; import PlaybookRunsDetailsPage from "./pages/playbooks/PlaybookRunsDetails"; +import { PlaybooksListPage } from "./pages/playbooks/PlaybooksList"; import { features } from "./services/permissions/features"; import { stringSortHelper } from "./utils/common"; -import { MdOutlineSupportAgent } from "react-icons/md"; -import AgentsPage from "./components/Agents/AgentPage"; -import { TopologyCardPage } from "./pages/TopologyCard"; export type NavigationItems = { name: string; diff --git a/src/components/SchemaResourcePage/SchemaResourceEdit.tsx b/src/components/SchemaResourcePage/SchemaResourceEdit.tsx index 0be822398..3c97292e6 100644 --- a/src/components/SchemaResourcePage/SchemaResourceEdit.tsx +++ b/src/components/SchemaResourcePage/SchemaResourceEdit.tsx @@ -23,6 +23,7 @@ import HealthSpecEditor from "../SpecEditor/HealthSpecEditor"; import { Button } from "../Button"; import DeleteResource from "./Delete/DeleteResource"; import { HealthCheckEdit } from "../Canary/HealthCheckEdit"; +import EditTopologyResource from "../Topology/Settings/EditTopologyResource"; const CodeEditor = dynamic( () => import("../CodeEditor").then((m) => m.CodeEditor), @@ -232,6 +233,13 @@ export function SchemaResourceEdit({ resourceValue={defaultValues} /> + ) : table === "topologies" ? ( +
+ {}} + topologyResource={defaultValues as any} + /> +
) : (
+ {name} , - refetch()} - resourceInfo={resourceInfo!} - /> + // for topology, we want to show the add topology resource modal, + // which supports being linked to directly via the url + ...(resourceInfo.name === "Topology" + ? [ + refetch()} + /> + ] + : [ + refetch()} + resourceInfo={resourceInfo!} + /> + ]) ]} /> } diff --git a/src/components/Topology/Settings/AddTopologyResource.tsx b/src/components/Topology/Settings/AddTopologyResource.tsx new file mode 100644 index 000000000..9b5fe9257 --- /dev/null +++ b/src/components/Topology/Settings/AddTopologyResource.tsx @@ -0,0 +1,47 @@ +import { TupleToUnion } from "type-fest"; +import AddTopologyOptionsList, { + createTopologyOptions +} from "./StepsForms/AddTopologyOptionsList"; +import { useEffect, useState } from "react"; +import TopologyResourceForm from "./StepsForms/TopologyResourceForm"; + +type AddTopologyResourceProps = { + onSuccess: () => void; + isModal?: boolean; + setModalTitle?: (title: string) => void; +}; + +export default function AddTopologyResource({ + onSuccess, + isModal = false, + setModalTitle = () => {} +}: AddTopologyResourceProps) { + const [selectedOption, setSelectedOption] = useState< + TupleToUnion | undefined + >(); + + useEffect(() => { + if (selectedOption) { + setModalTitle(`Create a ${selectedOption.toLocaleLowerCase()} topology`); + } else { + setModalTitle("Create Topology"); + } + }, [selectedOption, setModalTitle]); + + return ( +
+ {selectedOption ? ( + setSelectedOption(undefined)} + isModal={isModal} + /> + ) : ( + setSelectedOption(options)} + /> + )} +
+ ); +} diff --git a/src/components/Topology/Settings/AddTopologyResourceModal.tsx b/src/components/Topology/Settings/AddTopologyResourceModal.tsx new file mode 100644 index 000000000..ec87b4c07 --- /dev/null +++ b/src/components/Topology/Settings/AddTopologyResourceModal.tsx @@ -0,0 +1,56 @@ +import { AiFillPlusCircle } from "react-icons/ai"; +import { useSearchParams } from "react-router-dom"; +import AddTopologyResource from "./AddTopologyResource"; +import { Modal } from "../../Modal"; +import { useState } from "react"; + +type Props = { + onClose?: () => void; +}; + +export default function AddTopologyResourceModal({ + onClose = () => {} +}: Props) { + const [searchParams, setSearchParams] = useSearchParams(); + + const [modalTitle, setModalTitle] = useState("Create Topology"); + + const isModalOpen = searchParams.get("openCreateForm") === "true"; + + const setModalIsOpen = (isOpen: boolean) => { + if (isOpen) { + searchParams.set("openCreateForm", "true"); + } else { + searchParams.delete("openCreateForm"); + } + setSearchParams(searchParams); + }; + + return ( + <> + + + { + setModalIsOpen(false); + onClose(); + }} + bodyClass="flex flex-col flex-1 overflow-y-auto" + size="full" + title={modalTitle} + > + { + setModalIsOpen(false); + onClose(); + }} + setModalTitle={setModalTitle} + /> + + + ); +} diff --git a/src/components/Topology/Settings/EditTopologyResource.tsx b/src/components/Topology/Settings/EditTopologyResource.tsx new file mode 100644 index 000000000..bd850e506 --- /dev/null +++ b/src/components/Topology/Settings/EditTopologyResource.tsx @@ -0,0 +1,26 @@ +import TopologyResourceForm, { + TopologyResource +} from "./StepsForms/TopologyResourceForm"; + +type EditTopologyResourceProps = { + onSuccess: () => void; + topologyResource: TopologyResource; + isModal?: boolean; +}; + +export default function EditTopologyResource({ + onSuccess, + topologyResource, + isModal = false +}: EditTopologyResourceProps) { + return ( +
+ +
+ ); +} diff --git a/src/components/Topology/Settings/StepsForms/AddTopologyOptionsList.tsx b/src/components/Topology/Settings/StepsForms/AddTopologyOptionsList.tsx new file mode 100644 index 000000000..bae4232bd --- /dev/null +++ b/src/components/Topology/Settings/StepsForms/AddTopologyOptionsList.tsx @@ -0,0 +1,40 @@ +import { TupleToUnion } from "type-fest"; +import { Icon } from "../../../Icon"; + +export const createTopologyOptions = [ + "Kubernetes", + "Flux", + "Prometheus", + "Custom" +] as const; + +type AddTopologyOptionsListProps = { + onSelectOption: (options: TupleToUnion) => void; +}; + +export default function AddTopologyOptionsList({ + onSelectOption +}: AddTopologyOptionsListProps) { + return ( +
+
+ {createTopologyOptions.map((item) => { + return ( +
+
{ + onSelectOption(item); + }} + > + +
{item}
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/Topology/Settings/StepsForms/TopologyResourceForm.tsx b/src/components/Topology/Settings/StepsForms/TopologyResourceForm.tsx new file mode 100644 index 000000000..2a10f2484 --- /dev/null +++ b/src/components/Topology/Settings/StepsForms/TopologyResourceForm.tsx @@ -0,0 +1,215 @@ +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import { Form, Formik } from "formik"; +import { useCallback, useMemo } from "react"; +import { FaSpinner } from "react-icons/fa"; +import { TupleToUnion } from "type-fest"; +import { parse } from "yaml"; +import { + useSettingsCreateResource, + useSettingsUpdateResource +} from "../../../../api/query-hooks/mutations/useSettingsResourcesMutations"; +import { Button } from "../../../Button"; +import { FormikCodeEditor } from "../../../Forms/Formik/FormikCodeEditor"; +import FormikTextInput from "../../../Forms/Formik/FormikTextInput"; +import DeleteResource from "../../../SchemaResourcePage/Delete/DeleteResource"; +import { schemaResourceTypes } from "../../../SchemaResourcePage/resourceTypes"; +import FormSkeletonLoader from "../../../SkeletonLoader/FormSkeletonLoader"; +import { createTopologyOptions } from "./AddTopologyOptionsList"; + +const selectedOptionToSpecMap = new Map< + TupleToUnion, + string +>([ + [ + "Flux", + "https://raw.githubusercontent.com/flanksource/mission-control-registry/main/topologies/flux/flux.yaml" + ], + [ + "Kubernetes", + "https://raw.githubusercontent.com/flanksource/mission-control-registry/main/topologies/kubernetes/kubernetes.yaml" + ], + [ + "Prometheus", + "https://raw.githubusercontent.com/flanksource/mission-control-registry/main/topologies/prometheus/prometheus.yaml" + ] +]); + +export type TopologyResource = { + id: string; + name: string; + namespace: string; + labels: Record; + spec: Record; +}; + +type TopologyResourceFormProps = { + topology?: TopologyResource; + /** + * The selected option from the create topology options list, + * this is used to determine the initial values for the spec field + * and is only used when creating a new topology. When updating a topology + * the spec field is populated with the current topology's spec and this + * prop is ignored. + */ + selectedOption?: TupleToUnion; + onBack?: () => void; + footerClassName?: string; + onSuccess?: () => void; + isModal?: boolean; +}; + +export default function TopologyResourceForm({ + topology, + onBack, + selectedOption, + footerClassName = "bg-gray-100 p-4", + onSuccess = () => {}, + isModal = false +}: TopologyResourceFormProps) { + const resourceInfo = schemaResourceTypes.find( + (item) => item.name === "Topology" + ); + + const { data: spec, isLoading: isLoadingSpec } = useQuery({ + queryKey: ["Github", "mission-control-registry", selectedOption], + queryFn: async () => { + const url = selectedOptionToSpecMap.get(selectedOption!); + const response = await fetch(url!); + const data = await response.text(); + return parse(data); + }, + enabled: !!selectedOption && selectedOption !== "Custom" + }); + + const { mutate: createResource, isLoading: isCreatingResource } = + useSettingsCreateResource(resourceInfo!, onSuccess); + + const { mutate: updateResource, isLoading: isUpdatingResource } = + useSettingsUpdateResource(resourceInfo!, topology, isModal); + + const isLoading = isCreatingResource || isUpdatingResource; + + const initialValues: TopologyResource = useMemo(() => { + if (topology) { + return topology; + } + // the spec here is determined by the selected option + // todo: pull the specs for each option from the backend and use that to + // determine the initial values for the spec field + return { + id: "", + name: "", + namespace: "", + labels: {}, + spec: spec ?? {} + }; + }, [spec, topology]); + + const handleSubmit = useCallback( + (values: TopologyResource) => { + if (topology) { + updateResource(values); + } else { + createResource(values); + } + }, + [createResource, topology, updateResource] + ); + + const saveButtonText = useMemo(() => { + if (topology) { + return isLoading ? "Updating" : "Update"; + } + return isLoading ? "Saving" : "Save"; + }, [isLoading, topology]); + + if (selectedOption && selectedOption !== "Custom" && isLoadingSpec) { + return ( +
+ +
+ ); + } + + return ( +
+ handleSubmit(values)} + > + {({ isValid, handleSubmit }) => ( + +
+ + + + +
+
+
+ {!topology?.id && ( +
+
+ )} + {!!topology?.id && ( + name === "Topology" + )! + } + /> + )} + +
+
+ + )} +
+
+ ); +} diff --git a/src/components/Topology/Settings/StepsForms/__tests__/AddTopologyOptionsList.unit.test.tsx b/src/components/Topology/Settings/StepsForms/__tests__/AddTopologyOptionsList.unit.test.tsx new file mode 100644 index 000000000..60eba5fc1 --- /dev/null +++ b/src/components/Topology/Settings/StepsForms/__tests__/AddTopologyOptionsList.unit.test.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import AddTopologyOptionsList, { + createTopologyOptions +} from "./../AddTopologyOptionsList"; + +describe("AddTopologyOptionsList", () => { + const onSelectOption = jest.fn(); + + it("renders correctly", () => { + render(); + + createTopologyOptions.forEach((option) => { + expect( + screen.getByRole("button", { + name: option + }) + ).toBeInTheDocument(); + }); + }); + + it("calls onSelectOption when an option is clicked", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: createTopologyOptions[0] + }) + ); + + expect(onSelectOption).toHaveBeenCalledWith(createTopologyOptions[0]); + }); +}); diff --git a/src/pages/Settings/CreateTopologyPage.tsx b/src/pages/Settings/CreateTopologyPage.tsx new file mode 100644 index 000000000..b20723dfc --- /dev/null +++ b/src/pages/Settings/CreateTopologyPage.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import AddTopologyResource from "../../components/Topology/Settings/AddTopologyResource"; +import { Head } from "../../components/Head/Head"; + +export function CreateTopologyPage() { + return ( + <> + + +
+

Create Topology

+ } + /> +
+ + ); +}