Skip to content

Commit

Permalink
feat: add feature to manage agents
Browse files Browse the repository at this point in the history
Closes #1418

feat: add agents page with list of agents

feat: add mechanism to create agents

feat: add form for adding agents
  • Loading branch information
mainawycliffe committed Oct 17, 2023
1 parent fbcbe50 commit b2bc794
Show file tree
Hide file tree
Showing 15 changed files with 511 additions and 5 deletions.
16 changes: 16 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
];

Expand Down Expand Up @@ -291,6 +300,13 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) {
</Route>
</Route>

<Route path="agents" element={sidebar}>
<Route
index
element={withAccessCheck(<AgentsPage />, tables.agents, "read")}
/>
</Route>

<Route path="settings" element={sidebar}>
<Route
path="connections"
Expand Down
10 changes: 10 additions & 0 deletions src/api/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ export const Snapshot = axios.create({
}
});

export const AgentAPI = axios.create({
baseURL: `${API_BASE}/agent`,
headers: {
Accept: "application/json",
Prefer: "return=representation",
"Content-Type": "application/json",
responseType: "blob"
}
});

export const LogsSearch = axios.create({
baseURL: `${API_BASE}/logs`,
headers: {
Expand Down
11 changes: 11 additions & 0 deletions src/api/query-hooks/mutations/useUpsertAgentMutations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
import { GenerateAgent, GeneratedAgent, addAgent } from "../../services/agents";

export default function useUpsertAgentMutations(
options?: UseMutationOptions<GeneratedAgent, Error, GenerateAgent>
) {
return useMutation({
...options,
mutationFn: async (agent: GenerateAgent) => addAgent(agent)
});
}
16 changes: 16 additions & 0 deletions src/api/query-hooks/useAgentsQuery.tsx
Original file line number Diff line number Diff line change
@@ -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<DatabaseResponse<AgentSummary>, Error>
) {
return useQuery<DatabaseResponse<AgentSummary>, Error>(
["agents", "list"],
() => getAgentsList(params, pagingParams),
options
);
}
8 changes: 4 additions & 4 deletions src/api/query-hooks/useNotificationsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Record<string, any>> =
| { error: Error; data: null; totalEntries: undefined }
| {
data: Notification[] | null;
data: T[] | null;
totalEntries?: number | undefined;
error: null;
};

export function useNotificationsSummaryQuery(
options?: UseQueryOptions<Response, Error>
options?: UseQueryOptions<DatabaseResponse<Notification>, Error>
) {
return useQuery<Response, Error>(
return useQuery<DatabaseResponse<Notification>, Error>(
["notifications", "settings"],
() => getNotificationsSummary(),
options
Expand Down
48 changes: 48 additions & 0 deletions src/api/services/agents.ts
Original file line number Diff line number Diff line change
@@ -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<AgentSummary[] | null>(
`/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<string, string>;
};

export type GeneratedAgent = GenerateAgent & {
id: string;
};

export async function addAgent(agent: GenerateAgent) {
const res = await AgentAPI.post<GeneratedAgent>("/generate", agent);
console.log(res);
return res.data;
}
43 changes: 43 additions & 0 deletions src/components/Agents/Add/AddAgent.tsx
Original file line number Diff line number Diff line change
@@ -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<GeneratedAgent>();

return (
<>
<button type="button" className="" onClick={() => setIsModalOpen(true)}>
<AiFillPlusCircle size={32} className="text-blue-600" />
</button>
<AgentForm
isOpen={isModalOpen}
onClose={() => {
// todo: show modal with helm install instructions
refresh();
return setIsModalOpen(false);
}}
onSuccess={(agent) => {
setGeneratedAgent(agent);
setIsModalOpen(false);
setIsInstallModalOpen(true);
}}
/>
{generatedAgent && (
<InstallAgentModal
isOpen={isInstallModalOpen}
onClose={() => setIsInstallModalOpen(false)}
generatedAgent={generatedAgent}
/>
)}
</>
);
}
76 changes: 76 additions & 0 deletions src/components/Agents/Add/AddAgentForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
title={"Add Agent"}
onClose={onClose}
open={isOpen}
bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto"
>
<Formik<GenerateAgent>
initialValues={{
name: "",
properties: {}
}}
onSubmit={(value) => {
upsertAgent(value);
}}
>
{({ handleSubmit }) => (
<Form
className="flex flex-col flex-1 overflow-y-auto"
onSubmit={handleSubmit}
>
<div className={clsx("flex flex-col h-full my-2 overflow-y-auto")}>
<div className={clsx("flex flex-col px-2 mb-2 overflow-y-auto")}>
<div className="flex flex-col space-y-4 overflow-y-auto p-4">
<FormikTextInput name="name" label="Name" required />
<FormikKeyValueMapField
name="properties"
label="Properties"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between py-4 px-5 bg-gray-100">
<Button
icon={
isLoading ? <FaSpinner className="animate-spin" /> : undefined
}
type="submit"
text={isLoading ? "Saving ..." : "Save"}
className="btn-primary"
/>
</div>
</Form>
)}
</Formik>
</Modal>
);
}
107 changes: 107 additions & 0 deletions src/components/Agents/AgentPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Head prefix="Agents" />
<SearchLayout
title={
<BreadcrumbNav
list={[
<BreadcrumbRoot link="/agents">Agents</BreadcrumbRoot>,
<AddAgent refresh={refetch} />
]}
/>
}
onRefresh={refetch}
contentClass="p-0 h-full"
loading={isLoading || isRefetching}
>
<div className="flex flex-col flex-1 p-6 pb-0 h-full w-full">
<AgentsTable
agents={agent ?? []}
isLoading={isLoading || isRefetching}
pageCount={pageCount}
pageIndex={pageIndex}
pageSize={pageSize}
setPageState={setPageState}
sortBy={sortBy}
sortOrder={sortOrder}
onSortByChanged={(sortBy) => {
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}
/>
</div>
</SearchLayout>
</>
);
}
Loading

0 comments on commit b2bc794

Please sign in to comment.