From b2bc794a101181f6f6d1a581afff130c70f52900 Mon Sep 17 00:00:00 2001 From: Maina Wycliffe Date: Mon, 25 Sep 2023 15:05:34 +0300 Subject: [PATCH] feat: add feature to manage agents Closes #1418 feat: add agents page with list of agents feat: add mechanism to create agents feat: add form for adding agents --- src/App.tsx | 16 +++ src/api/axios.ts | 10 ++ .../mutations/useUpsertAgentMutations.tsx | 11 ++ src/api/query-hooks/useAgentsQuery.tsx | 16 +++ src/api/query-hooks/useNotificationsQuery.ts | 8 +- src/api/services/agents.ts | 48 ++++++++ src/components/Agents/Add/AddAgent.tsx | 43 +++++++ src/components/Agents/Add/AddAgentForm.tsx | 76 +++++++++++++ src/components/Agents/AgentPage.tsx | 107 ++++++++++++++++++ src/components/Agents/InstallAgentModal.tsx | 44 +++++++ src/components/Agents/List/AgentsTable.tsx | 54 +++++++++ .../Agents/List/AgentsTableColumns.tsx | 56 +++++++++ src/components/Hooks/useCopyToClipboard.tsx | 23 ++++ src/context/UserAccessContext/permissions.ts | 3 +- src/services/permissions/features.ts | 1 + 15 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 src/api/query-hooks/mutations/useUpsertAgentMutations.tsx create mode 100644 src/api/query-hooks/useAgentsQuery.tsx create mode 100644 src/api/services/agents.ts create mode 100644 src/components/Agents/Add/AddAgent.tsx create mode 100644 src/components/Agents/Add/AddAgentForm.tsx create mode 100644 src/components/Agents/AgentPage.tsx create mode 100644 src/components/Agents/InstallAgentModal.tsx create mode 100644 src/components/Agents/List/AgentsTable.tsx create mode 100644 src/components/Agents/List/AgentsTableColumns.tsx create mode 100644 src/components/Hooks/useCopyToClipboard.tsx diff --git a/src/App.tsx b/src/App.tsx index 19d2bad14f..51d70bdc21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,6 +66,8 @@ import PlaybookRunsPage from "./pages/playbooks/PlaybookRuns"; import PlaybookRunsDetailsPage from "./pages/playbooks/PlaybookRunsDetails"; import { features } from "./services/permissions/features"; import { stringSortHelper } from "./utils/common"; +import { MdOutlineSupportAgent } from "react-icons/md"; +import AgentsPage from "./components/Agents/AgentPage"; export type NavigationItems = { name: string; @@ -117,6 +119,13 @@ const navigation: NavigationItems = [ icon: FaTasks, featureName: features.playbooks, resourceName: tables.database + }, + { + name: "Agents", + href: "/agents", + icon: MdOutlineSupportAgent, + featureName: features.agents, + resourceName: tables.database } ]; @@ -291,6 +300,13 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { + + , tables.agents, "read")} + /> + + +) { + return useMutation({ + ...options, + mutationFn: async (agent: GenerateAgent) => addAgent(agent) + }); +} diff --git a/src/api/query-hooks/useAgentsQuery.tsx b/src/api/query-hooks/useAgentsQuery.tsx new file mode 100644 index 0000000000..5c7f4ef98b --- /dev/null +++ b/src/api/query-hooks/useAgentsQuery.tsx @@ -0,0 +1,16 @@ +import { UseQueryOptions, useQuery } from "@tanstack/react-query"; +import { AgentSummary } from "../../components/Agents/AgentPage"; +import { getAgentsList } from "../services/agents"; +import { DatabaseResponse } from "./useNotificationsQuery"; + +export function useAgentsListQuery( + params: { sortBy?: string; sortOrder?: string } = {}, + pagingParams: { pageIndex?: number; pageSize?: number } = {}, + options?: UseQueryOptions, Error> +) { + return useQuery, Error>( + ["agents", "list"], + () => getAgentsList(params, pagingParams), + options + ); +} diff --git a/src/api/query-hooks/useNotificationsQuery.ts b/src/api/query-hooks/useNotificationsQuery.ts index dd0be6eb2d..8cb2a980d4 100644 --- a/src/api/query-hooks/useNotificationsQuery.ts +++ b/src/api/query-hooks/useNotificationsQuery.ts @@ -12,18 +12,18 @@ import { createResource, updateResource } from "../schemaResources"; import { toastError, toastSuccess } from "../../components/Toast/toast"; import { useUser } from "../../context"; -type Response = +export type DatabaseResponse> = | { error: Error; data: null; totalEntries: undefined } | { - data: Notification[] | null; + data: T[] | null; totalEntries?: number | undefined; error: null; }; export function useNotificationsSummaryQuery( - options?: UseQueryOptions + options?: UseQueryOptions, Error> ) { - return useQuery( + return useQuery, Error>( ["notifications", "settings"], () => getNotificationsSummary(), options diff --git a/src/api/services/agents.ts b/src/api/services/agents.ts new file mode 100644 index 0000000000..db6adbe5be --- /dev/null +++ b/src/api/services/agents.ts @@ -0,0 +1,48 @@ +import { AgentSummary } from "../../components/Agents/AgentPage"; +import { AVATAR_INFO } from "../../constants"; +import { AgentAPI, IncidentCommander } from "../axios"; +import { resolve } from "../resolve"; + +export const getAgentsList = async ( + params: { + sortBy?: string; + sortOrder?: string; + }, + pagingParams: { pageIndex?: number; pageSize?: number } +) => { + const { sortBy, sortOrder } = params; + + const sortByParam = sortBy ? `&order=${sortBy}` : "&order=created_at"; + const sortOrderParam = sortOrder ? `.${sortOrder}` : ".desc"; + + const { pageIndex, pageSize } = pagingParams; + const pagingParamsStr = + pageIndex || pageSize + ? `&limit=${pageSize}&offset=${pageIndex! * pageSize!}` + : ""; + return resolve( + IncidentCommander.get( + `/agent_summary?select=*,created_by(${AVATAR_INFO})&order=created_at.desc&${pagingParamsStr}${sortByParam}${sortOrderParam}`, + { + headers: { + Prefer: "count=exact" + } + } + ) + ); +}; + +export type GenerateAgent = { + name: string; + properties: Record; +}; + +export type GeneratedAgent = GenerateAgent & { + id: string; +}; + +export async function addAgent(agent: GenerateAgent) { + const res = await AgentAPI.post("/generate", agent); + console.log(res); + return res.data; +} diff --git a/src/components/Agents/Add/AddAgent.tsx b/src/components/Agents/Add/AddAgent.tsx new file mode 100644 index 0000000000..f67dfb9670 --- /dev/null +++ b/src/components/Agents/Add/AddAgent.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { AiFillPlusCircle } from "react-icons/ai"; +import AgentForm from "./AddAgentForm"; +import { GeneratedAgent } from "../../../api/services/agents"; +import InstallAgentModal from "../InstallAgentModal"; + +type Props = { + refresh: () => void; +}; + +export default function AddAgent({ refresh }: Props) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isInstallModalOpen, setIsInstallModalOpen] = useState(false); + const [generatedAgent, setGeneratedAgent] = useState(); + + return ( + <> + + { + // todo: show modal with helm install instructions + refresh(); + return setIsModalOpen(false); + }} + onSuccess={(agent) => { + setGeneratedAgent(agent); + setIsModalOpen(false); + setIsInstallModalOpen(true); + }} + /> + {generatedAgent && ( + setIsInstallModalOpen(false)} + generatedAgent={generatedAgent} + /> + )} + + ); +} diff --git a/src/components/Agents/Add/AddAgentForm.tsx b/src/components/Agents/Add/AddAgentForm.tsx new file mode 100644 index 0000000000..449a0c18c8 --- /dev/null +++ b/src/components/Agents/Add/AddAgentForm.tsx @@ -0,0 +1,76 @@ +import clsx from "clsx"; +import { Form, Formik } from "formik"; +import useUpsertAgentMutations from "../../../api/query-hooks/mutations/useUpsertAgentMutations"; +import { GenerateAgent, GeneratedAgent } from "../../../api/services/agents"; +import { Button } from "../../Button"; +import FormikTextInput from "../../Forms/Formik/FormikTextInput"; +import { Modal } from "../../Modal"; +import { toastError, toastSuccess } from "../../Toast/toast"; +import FormikKeyValueMapField from "../../Forms/Formik/FormikKeyValueMapField"; +import { FaSpinner } from "react-icons/fa"; + +type Props = { + isOpen: boolean; + onClose: () => void; + onSuccess: (agent: GeneratedAgent) => void; +}; + +export default function AgentForm({ isOpen, onClose, onSuccess }: Props) { + const { mutate: upsertAgent, isLoading } = useUpsertAgentMutations({ + onSuccess: (data) => { + toastSuccess("Agent saved"); + onSuccess(data); + }, + onError: (error) => { + toastError(error.message); + } + }); + + return ( + + + initialValues={{ + name: "", + properties: {} + }} + onSubmit={(value) => { + upsertAgent(value); + }} + > + {({ handleSubmit }) => ( +
+
+
+
+ + +
+
+
+
+
+
+ )} + +
+ ); +} diff --git a/src/components/Agents/AgentPage.tsx b/src/components/Agents/AgentPage.tsx new file mode 100644 index 0000000000..629f8a6874 --- /dev/null +++ b/src/components/Agents/AgentPage.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useAgentsListQuery } from "../../api/query-hooks/useAgentsQuery"; +import { User } from "../../api/services/users"; +import { BreadcrumbNav, BreadcrumbRoot } from "../BreadcrumbNav"; +import { Head } from "../Head/Head"; +import { SearchLayout } from "../Layout"; +import AddAgent from "./Add/AddAgent"; +import AgentsTable from "./List/AgentsTable"; + +export type Agent = { + id?: string; + name: string; + hostname?: string; + description?: string; + ip?: string; + version?: string; + username?: string; + person_id?: string; + person?: User; + properties?: { [key: string]: any }; + tls?: string; + created_by?: User; + created_at: Date; + updated_at: Date; +}; + +export type AgentSummary = Agent & { + config_count?: number; + checks_count?: number; + config_scrapper_count?: number; + playbook_runs_count?: number; +}; + +export default function AgentsPage() { + const [{ pageIndex, pageSize }, setPageState] = useState({ + pageIndex: 0, + pageSize: 150 + }); + + const [searchParams, setSearchParams] = useSearchParams(); + + const sortBy = searchParams.get("sortBy") ?? ""; + const sortOrder = searchParams.get("sortOrder") ?? "desc"; + + const { data, isLoading, refetch, isRefetching } = useAgentsListQuery( + { + sortBy, + sortOrder + }, + { + pageIndex, + pageSize + }, + { + keepPreviousData: true + } + ); + + const agent = data?.data; + const totalEntries = data?.totalEntries; + const pageCount = totalEntries ? Math.ceil(totalEntries / pageSize) : -1; + + return ( + <> + + Agents, + + ]} + /> + } + onRefresh={refetch} + contentClass="p-0 h-full" + loading={isLoading || isRefetching} + > +
+ { + const sort = typeof sortBy === "function" ? sortBy([]) : sortBy; + if (sort.length === 0) { + searchParams.delete("sortBy"); + searchParams.delete("sortOrder"); + } else { + searchParams.set("sortBy", sort[0]?.id); + searchParams.set("sortOrder", sort[0].desc ? "desc" : "asc"); + } + setSearchParams(searchParams); + }} + refresh={refetch} + /> +
+
+ + ); +} diff --git a/src/components/Agents/InstallAgentModal.tsx b/src/components/Agents/InstallAgentModal.tsx new file mode 100644 index 0000000000..76b59421b9 --- /dev/null +++ b/src/components/Agents/InstallAgentModal.tsx @@ -0,0 +1,44 @@ +import { FaCopy } from "react-icons/fa"; +import { GeneratedAgent } from "../../api/services/agents"; +import { Button } from "../Button"; +import { useCopyToClipboard } from "../Hooks/useCopyToClipboard"; +import { Modal } from "../Modal"; + +type Props = { + isOpen: boolean; + onClose: () => void; + generatedAgent: GeneratedAgent; +}; + +export default function InstallAgentModal({ + isOpen, + onClose, + generatedAgent +}: Props) { + const copyFn = useCopyToClipboard(); + + return ( + +
+

+ Copy and paste the following command to install the agent using helm: +

+
+          TODO: helm install command
+        
+
+
+ ); +} diff --git a/src/components/Agents/List/AgentsTable.tsx b/src/components/Agents/List/AgentsTable.tsx new file mode 100644 index 0000000000..bc376ae152 --- /dev/null +++ b/src/components/Agents/List/AgentsTable.tsx @@ -0,0 +1,54 @@ +import { SortingState, Updater } from "@tanstack/react-table"; +import { useMemo } from "react"; +import { DataTable } from "../../DataTable"; +import { AgentSummary } from "../AgentPage"; +import { agentsTableColumns } from "./AgentsTableColumns"; + +type AgentsTableProps = { + agents: AgentSummary[]; + isLoading?: boolean; + pageCount: number; + pageIndex: number; + pageSize: number; + sortBy: string; + sortOrder: string; + setPageState?: (state: { pageIndex: number; pageSize: number }) => void; + hiddenColumns?: string[]; + onSortByChanged?: (sortByState: Updater) => void; + refresh?: () => void; +}; + +export default function AgentsTable({ + agents, + isLoading, + hiddenColumns = [], + sortBy, + sortOrder, + onSortByChanged = () => {}, + refresh = () => {} +}: AgentsTableProps) { + const tableSortByState = useMemo(() => { + return [ + { + id: sortBy, + desc: sortOrder === "desc" + } + ]; + }, [sortBy, sortOrder]); + + const columns = useMemo(() => agentsTableColumns, []); + + return ( + {}} + stickyHead + hiddenColumns={hiddenColumns} + tableSortByState={tableSortByState} + onTableSortByChanged={onSortByChanged} + enableServerSideSorting + /> + ); +} diff --git a/src/components/Agents/List/AgentsTableColumns.tsx b/src/components/Agents/List/AgentsTableColumns.tsx new file mode 100644 index 0000000000..0213548007 --- /dev/null +++ b/src/components/Agents/List/AgentsTableColumns.tsx @@ -0,0 +1,56 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { User } from "../../../api/services/users"; +import { Avatar } from "../../Avatar"; +import { DateCell } from "../../ConfigViewer/columns"; +import { AgentSummary } from "../AgentPage"; + +export const agentsTableColumns: ColumnDef[] = [ + { + header: "Name", + accessorKey: "name", + minSize: 150, + enableSorting: true + }, + { + header: "Counts", + columns: [ + { + header: "Configs", + accessorKey: "config_count" + }, + { + header: "Checks", + accessorKey: "checks_count" + }, + { + header: "Scrapers", + accessorKey: "config_scrapper_count" + }, + { + header: "Playbook Runs", + accessorKey: "playbook_runs_count" + } + ] + }, + { + header: "Created At", + minSize: 150, + enableSorting: true, + cell: DateCell + }, + { + header: "Updated At", + minSize: 150, + enableSorting: true, + cell: DateCell + }, + { + header: "Created By", + minSize: 80, + accessorKey: "created_by", + cell: ({ row }) => { + const createdBy = row?.getValue("created_by"); + return ; + } + } +]; diff --git a/src/components/Hooks/useCopyToClipboard.tsx b/src/components/Hooks/useCopyToClipboard.tsx new file mode 100644 index 0000000000..9f0cd1ba8b --- /dev/null +++ b/src/components/Hooks/useCopyToClipboard.tsx @@ -0,0 +1,23 @@ +import { toast } from "react-hot-toast"; + +type CopyFn = (text: string) => Promise; + +export function useCopyToClipboard(): CopyFn { + const copy: CopyFn = async (text) => { + if (!navigator?.clipboard) { + toast.error("Clipboard not available"); + return false; + } + // Try to save to clipboard then save it in the state if worked + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + return true; + } catch (error) { + toast.error("Failed to copy to clipboard"); + return false; + } + }; + + return copy; +} diff --git a/src/context/UserAccessContext/permissions.ts b/src/context/UserAccessContext/permissions.ts index 9cf859b688..8abfc62a42 100644 --- a/src/context/UserAccessContext/permissions.ts +++ b/src/context/UserAccessContext/permissions.ts @@ -11,7 +11,8 @@ export const tables = { config_scrapers: "config_scrapers", identities: "identities", connections: "connections", - kratos: "kratos" + kratos: "kratos", + agents: "agents" }; export const permDefs = { diff --git a/src/services/permissions/features.ts b/src/services/permissions/features.ts index d4b91584a2..c256ae827e 100644 --- a/src/services/permissions/features.ts +++ b/src/services/permissions/features.ts @@ -5,6 +5,7 @@ export const features = { config: "config", logs: "logs", playbooks: "playbooks", + agents: "agents", "settings.connections": "settings.connections", "settings.users": "settings.users", "settings.teams": "settings.teams",