diff --git a/src/App.tsx b/src/App.tsx index e383a2867c..19d2bad14f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,11 +57,13 @@ 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 { PlaybookSettingsPage } from "./pages/Settings/PlaybookSettingsPage"; +import { PlaybooksListPage } from "./pages/playbooks/PlaybooksList"; 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 { features } from "./services/permissions/features"; import { stringSortHelper } from "./utils/common"; @@ -108,6 +110,13 @@ const navigation: NavigationItems = [ icon: LogsIcon, featureName: features.logs, resourceName: tables.database + }, + { + name: "Playbooks", + href: "/playbooks", + icon: FaTasks, + featureName: features.playbooks, + resourceName: tables.database } ]; @@ -188,13 +197,6 @@ const settingsNav: SettingsNavigationItems = { icon: FaTasks, featureName: features["settings.event_queue_status"], resourceName: tables.database - }, - { - name: "Playbooks", - href: "/settings/playbooks", - icon: FaTasks, - featureName: features["settings.playbooks"], - resourceName: tables.database } ].sort((v1, v2) => stringSortHelper(v1.name, v2.name)) }; @@ -258,6 +260,37 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { /> + + , + tables.database, + "read" + )} + /> + + + , + tables.database, + "read" + )} + /> + + , + tables.database, + "read" + )} + /> + + + - , - tables.database, - "read" - )} - /> - {settingsNav.submenu .filter((v) => (v as SchemaResourceType).table) .map((x) => { diff --git a/src/api/services/playbooks.ts b/src/api/services/playbooks.ts index c33f06aaa6..6e7a44a2a4 100644 --- a/src/api/services/playbooks.ts +++ b/src/api/services/playbooks.ts @@ -1,3 +1,5 @@ +import { PlaybookRunWithActions } from "../../components/Playbooks/Runs/PlaybookRunsActions"; +import { PlaybookRun } from "../../components/Playbooks/Runs/PlaybookRunsList"; import { NewPlaybookSpec, PlaybookSpec, @@ -20,6 +22,20 @@ export async function getPlaybookSpec(id: string) { return res.data ?? undefined; } +export async function getPlaybookRuns() { + const res = await IncidentCommander.get( + `/playbook_runs?select=*,created_by(${AVATAR_INFO}),playbooks(id,name),component:components(id,name,icon)&order=created_at.desc` + ); + return res.data ?? []; +} + +export async function getPlaybookRun(id: string) { + const res = await IncidentCommander.get( + `/playbook_runs?id=eq.${id}&select=*,created_by(${AVATAR_INFO}),playbooks(id,name),component:components(id,name,icon),actions:playbook_run_actions(*)` + ); + return res.data?.[0] ?? undefined; +} + export async function createPlaybookSpec(spec: NewPlaybookSpec) { const res = await IncidentCommander.post("/playbooks", spec); return res.data; diff --git a/src/components/Playbooks/Runs/PlaybookRunsActionItem.tsx b/src/components/Playbooks/Runs/PlaybookRunsActionItem.tsx new file mode 100644 index 0000000000..b94a0d7d36 --- /dev/null +++ b/src/components/Playbooks/Runs/PlaybookRunsActionItem.tsx @@ -0,0 +1,42 @@ +import { relativeDateTime } from "../../../utils/date"; +import { PlaybookRunAction } from "./PlaybookRunsActions"; +import PlaybookRunsStatus from "./PlaybookRunsStatus"; + +type PlaybookRunsActionItemProps = { + action: PlaybookRunAction; + onClick?: () => void; + isSelected?: boolean; +}; + +export default function PlaybookRunsActionItem({ + action, + onClick = () => {}, + isSelected = false +}: PlaybookRunsActionItemProps) { + return ( +
+
+
+ + {action.name} +
+
+
+
+
+ {" "} + {!isSelected + ? relativeDateTime(action.start_time!, action.end_time) + : relativeDateTime(action.start_time!)} +
+
+
+ ); +} diff --git a/src/components/Playbooks/Runs/PlaybookRunsActions.stories.tsx b/src/components/Playbooks/Runs/PlaybookRunsActions.stories.tsx new file mode 100644 index 0000000000..ee56c5e076 --- /dev/null +++ b/src/components/Playbooks/Runs/PlaybookRunsActions.stories.tsx @@ -0,0 +1,86 @@ +import { StoryObj } from "@storybook/react"; +import PlaybookRunsActions, { + PlaybookRunWithActions +} from "./PlaybookRunsActions"; + +export default { + title: "PlaybookRunsActions", + component: PlaybookRunsActions +}; + +const mockPlaybookRun: PlaybookRunWithActions = { + id: "1", + playbook_id: "1", + status: "completed", + created_at: "2021-08-19T07:00:00.000Z", + start_time: "2021-08-19T07:00:00.000Z", + end_time: "2021-08-19T07:00:00.000Z", + created_by: { + id: "1", + name: "John Doe", + email: "" + }, + component_id: "1", + parameters: {}, + component: { + id: "1", + name: "Topology 1", + icon: "TopologyIcon" + }, + actions: [ + { + id: "1", + name: "Action 1", + status: "completed", + end_time: "2021-08-19T07:00:00.000Z", + start_time: "2021-08-19T07:00:00.000Z", + playbook_run_id: "1", + result: `You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:font-serif to apply the font-serif utility at only medium screen sizes and above. + +You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:font-serif to apply the font-serif utility at only medium screen sizes and above. + +You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:font-serif to apply the font-serif utility at only medium screen sizes and above. + +You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:font-serif to apply the font-serif utility at only medium screen sizes and above.`, + error: "" + }, + { + id: "2", + name: "Action 2", + status: "completed", + end_time: "2021-08-19T07:00:00.000Z", + start_time: "2021-08-19T07:00:00.000Z", + playbook_run_id: "1", + result: `You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:font-serif to apply the font-serif utility at only medium screen sizes and above. + +You can also use variant modifiers to target media queries like responsive breakpoints, dark mode, prefers-reduced-motion, and more. For example, use md:font-serif to apply the font-serif utility at only medium screen sizes and above.`, + error: "" + }, + { + id: "3", + name: "Action 3", + status: "pending", + end_time: undefined, + error: "", + result: "", + start_time: "2021-08-19T07:00:00.000Z", + playbook_run_id: "1" + }, + { + id: "4", + name: "Action 4", + status: "running", + end_time: undefined, + error: "", + result: "", + start_time: "2021-08-19T07:00:00.000Z", + playbook_run_id: "1" + } + ] +}; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; diff --git a/src/components/Playbooks/Runs/PlaybookRunsActions.tsx b/src/components/Playbooks/Runs/PlaybookRunsActions.tsx new file mode 100644 index 0000000000..8b46b2cfa3 --- /dev/null +++ b/src/components/Playbooks/Runs/PlaybookRunsActions.tsx @@ -0,0 +1,119 @@ +import { ReactNode, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { relativeDateTime } from "../../../utils/date"; +import { Avatar } from "../../Avatar"; +import { Icon } from "../../Icon"; +import PlaybookRunsActionItem from "./PlaybookRunsActionItem"; +import { PlaybookRun, PlaybookRunStatus } from "./PlaybookRunsList"; +import PlaybookRunsStatus from "./PlaybookRunsStatus"; + +export type PlaybookRunAction = { + id: string; + name: string; + status: PlaybookRunStatus; + playbook_run_id: string; + start_time?: string; + end_time?: string; + result?: { + stdout?: string; + }; + error?: string; +}; + +export type PlaybookRunWithActions = PlaybookRun & { + actions: PlaybookRunAction[]; +}; + +type PlaybookRunActionsProps = { + data: PlaybookRunWithActions; +}; + +export default function PlaybookRunsActions({ data }: PlaybookRunActionsProps) { + const [selectedAction, setSelectedAction] = useState(); + + const headerContent = useMemo( + () => + new Map([ + [ + "Playbook", + + {data.playbooks?.name} + + ], + [ + "Component", + + + {data.component?.name} + + ], + [ + "Status", +
+ +
+ ], + ["Start Time", relativeDateTime(data.start_time)], + ["Duration", relativeDateTime(data.start_time, data.end_time)], + [ + "Triggered By", + data.created_by ? ( +
+ {" "} + {data.created_by.name} +
+ ) : null + ] + ]), + [data] + ); + + return ( +
+
+ {Array.from(headerContent).map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+
+
+
Actions
+
+
+ {data.actions.map((action) => ( + setSelectedAction(action)} + /> + ))} +
+
+
+ {selectedAction && ( +
+ {selectedAction.result?.stdout + ? selectedAction.result.stdout + : selectedAction.error || "No result"} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Playbooks/Runs/PlaybookRunsList.tsx b/src/components/Playbooks/Runs/PlaybookRunsList.tsx new file mode 100644 index 0000000000..9e062c6847 --- /dev/null +++ b/src/components/Playbooks/Runs/PlaybookRunsList.tsx @@ -0,0 +1,149 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { useCallback } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { User } from "../../../api/services/users"; +import { Topology } from "../../../context/TopologyPageContext"; +import { Avatar } from "../../Avatar"; +import { DateCell } from "../../ConfigViewer/columns"; +import { DataTable } from "../../DataTable"; +import { PlaybookSpec } from "../Settings/PlaybookSpecsTable"; +import { FaDotCircle } from "react-icons/fa"; +import { Icon } from "../../Icon"; +import { relativeDateTime } from "../../../utils/date"; +import PlaybookRunsStatus from "./PlaybookRunsStatus"; + +export type PlaybookRunStatus = + | "scheduled" + | "running" + | "cancelled" + | "completed" + | "failed" + | "pending"; + +export type PlaybookRun = { + id: string; + playbook_id: string; + status: PlaybookRunStatus; + created_at: string; + start_time: string; + end_time: string; + created_by: User; + check_id?: string; + config_id?: string; + component_id?: string; + parameters: Record; + agent_id?: string; + playbooks?: PlaybookSpec; + component?: Pick; +}; + +const playbookRunsTableColumns: ColumnDef[] = [ + { + header: "Name", + accessorKey: "name", + cell: ({ row }) => { + return row.original.playbooks?.name; + }, + size: 400 + }, + { + header: "Resource", + cell: ({ row }) => { + const component = row.original.component; + const componentId = row.original.component_id; + + if (componentId) { + return ( + { + e.stopPropagation(); + }} + className="block" + to={`/topology/${componentId}`} + > + + {component?.name} + + ); + } + } + }, + { + header: "Status", + accessorKey: "status", + cell: ({ getValue }) => { + const status = getValue(); + return ; + } + }, + { + header: "Date", + accessorKey: "start_time", + cell: DateCell, + sortingFn: "datetime" + }, + { + header: "Duration", + accessorKey: "duration", + cell: ({ row }) => { + const startTime = row.original.start_time; + const endTime = row.original.end_time; + + return ( + + {startTime && endTime && relativeDateTime(startTime, endTime)} + + ); + } + }, + { + header: "Created At", + accessorKey: "created_at", + cell: DateCell, + sortingFn: "datetime" + }, + { + header: "Created By", + accessorKey: "created_by", + cell: ({ getValue }) => { + const user = getValue(); + return ; + } + } +]; + +type Props = { + data: PlaybookRun[]; + isLoading?: boolean; +} & Omit, "data">; + +export default function PlaybookRunsTable({ + data, + isLoading, + className, + ...rest +}: Props) { + const navigate = useNavigate(); + + const onRowClick = useCallback( + (row: PlaybookRun) => { + navigate(`/playbooks/runs/${row.id}`); + }, + [navigate] + ); + + return ( +
+ onRowClick(row.original)} + /> +
+ ); +} diff --git a/src/components/Playbooks/Runs/PlaybookRunsPageTabs.tsx b/src/components/Playbooks/Runs/PlaybookRunsPageTabs.tsx new file mode 100644 index 0000000000..ea49b98295 --- /dev/null +++ b/src/components/Playbooks/Runs/PlaybookRunsPageTabs.tsx @@ -0,0 +1,10 @@ +export const playbookRunsPageTabs = [ + { + label: "Playbooks", + path: "/playbooks" + }, + { + label: "Runs", + path: "/playbooks/runs" + } +]; diff --git a/src/components/Playbooks/Runs/PlaybookRunsStatus.tsx b/src/components/Playbooks/Runs/PlaybookRunsStatus.tsx new file mode 100644 index 0000000000..afa178dfd1 --- /dev/null +++ b/src/components/Playbooks/Runs/PlaybookRunsStatus.tsx @@ -0,0 +1,50 @@ +import { + FaCheckCircle, + FaClock, + FaExclamationCircle, + FaSpinner +} from "react-icons/fa"; +import { PlaybookRunStatus } from "./PlaybookRunsList"; +import { VscError } from "react-icons/vsc"; + +const statusIconMap: Record = { + completed: { + icon: + }, + cancelled: { + icon: + }, + failed: { + icon: + }, + pending: { + icon: + }, + running: { + icon: + }, + scheduled: { + icon: + } +}; + +type PlaybookRunsStatusProps = { + status: PlaybookRunStatus; + className?: string; + hideStatusLabel?: boolean; +}; + +export default function PlaybookRunsStatus({ + status, + className = "capitalize", + hideStatusLabel = false +}: PlaybookRunsStatusProps) { + const { icon } = statusIconMap[status]; + + return ( +
+ {icon} + {!hideStatusLabel && {status}} +
+ ); +} diff --git a/src/pages/playbooks/PlaybookRuns.tsx b/src/pages/playbooks/PlaybookRuns.tsx new file mode 100644 index 0000000000..538f6c1fe2 --- /dev/null +++ b/src/pages/playbooks/PlaybookRuns.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPlaybookRuns } from "../../api/services/playbooks"; +import { Head } from "../../components/Head/Head"; +import { SearchLayout } from "../../components/Layout"; +import PlaybookRunsTable from "../../components/Playbooks/Runs/PlaybookRunsList"; +import { + BreadcrumbChild, + BreadcrumbNav, + BreadcrumbRoot +} from "../../components/BreadcrumbNav"; +import TabbedLinks from "../../components/Tabs/TabbedLinks"; +import { playbookRunsPageTabs } from "../../components/Playbooks/Runs/PlaybookRunsPageTabs"; + +export default function PlaybookRunsPage() { + const { + data: playbookRuns = [], + isLoading, + refetch + } = useQuery({ + queryKey: ["playbookRuns"], + queryFn: () => getPlaybookRuns() + }); + + return ( + <> + + Playbooks, + Runs + ]} + /> + } + onRefresh={refetch} + loading={isLoading} + contentClass="flex flex-col p-0 h-full overflow-y-hidden" + > + +
+ +
+
+
+ + ); +} diff --git a/src/pages/playbooks/PlaybookRunsDetails.tsx b/src/pages/playbooks/PlaybookRunsDetails.tsx new file mode 100644 index 0000000000..674970c987 --- /dev/null +++ b/src/pages/playbooks/PlaybookRunsDetails.tsx @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import { getPlaybookRun } from "../../api/services/playbooks"; +import { + BreadcrumbChild, + BreadcrumbNav, + BreadcrumbRoot +} from "../../components/BreadcrumbNav"; +import { SearchLayout } from "../../components/Layout"; +import PlaybookRunsActions from "../../components/Playbooks/Runs/PlaybookRunsActions"; +import CardsSkeletonLoader from "../../components/SkeletonLoader/CardsSkeletonLoader"; +import { Head } from "../../components/Head/Head"; +import { playbookRunsPageTabs } from "../../components/Playbooks/Runs/PlaybookRunsPageTabs"; +import TabbedLinks from "../../components/Tabs/TabbedLinks"; + +export default function PlaybookRunsDetailsPage() { + const { id } = useParams(); + + const { + data: playbookRuns, + isLoading, + refetch + } = useQuery({ + queryKey: ["playbookRuns", id], + queryFn: () => getPlaybookRun(id!), + enabled: !!id + }); + + return ( + <> + + Playbooks, + Runs, + {id} + ]} + /> + } + onRefresh={refetch} + loading={isLoading} + contentClass="flex flex-col p-0 h-full overflow-y-hidden" + > + +
+ {playbookRuns ? ( + + ) : ( + + )} +
+
+
+ + ); +} diff --git a/src/pages/Settings/PlaybookSettingsPage.tsx b/src/pages/playbooks/PlaybooksList.tsx similarity index 69% rename from src/pages/Settings/PlaybookSettingsPage.tsx rename to src/pages/playbooks/PlaybooksList.tsx index a5c4e5a4e1..d3e03f207c 100644 --- a/src/pages/Settings/PlaybookSettingsPage.tsx +++ b/src/pages/playbooks/PlaybooksList.tsx @@ -9,8 +9,10 @@ import PlaybookSpecsForm from "../../components/Playbooks/Settings/PlaybookSpecs import PlaybookSpecsTable, { PlaybookSpec } from "../../components/Playbooks/Settings/PlaybookSpecsTable"; +import { playbookRunsPageTabs } from "../../components/Playbooks/Runs/PlaybookRunsPageTabs"; +import TabbedLinks from "../../components/Tabs/TabbedLinks"; -export function PlaybookSettingsPage() { +export function PlaybooksListPage() { const [isOpen, setIsOpen] = useState(false); const [editedRow, setEditedRow] = useState(); @@ -45,24 +47,25 @@ export function PlaybookSettingsPage() { /> } onRefresh={refetch} - contentClass="p-0 h-full" + contentClass="flex flex-col p-0 h-full" loading={isLoading} > -
- {error && !playbooks ? ( - - ) : ( - { - setIsOpen(true); - setEditedRow(val); - }} - /> - )} -
- + +
+ {error && !playbooks ? ( + + ) : ( + { + setIsOpen(true); + setEditedRow(val); + }} + /> + )} +
+
{ diff --git a/src/services/permissions/features.ts b/src/services/permissions/features.ts index 892121a86d..d4b91584a2 100644 --- a/src/services/permissions/features.ts +++ b/src/services/permissions/features.ts @@ -4,6 +4,7 @@ export const features = { incidents: "incidents", config: "config", logs: "logs", + playbooks: "playbooks", "settings.connections": "settings.connections", "settings.users": "settings.users", "settings.teams": "settings.teams",