From b88145f5e11be5fc91770f33ded4a9ff4e9dbba1 Mon Sep 17 00:00:00 2001 From: Mubashir Shariq Date: Sat, 21 Sep 2024 18:55:47 +0530 Subject: [PATCH 01/40] progress 1 --- keep-ui/app/runbooks/page.tsx | 9 + keep-ui/app/runbooks/runbook-table.tsx | 177 ++++++++++++++++++ .../components/navbar/NoiseReductionLinks.tsx | 9 + keep/api/models/db/runbook.py | 92 +++++++++ .../github_provider/github_provider.py | 47 ++++- .../gitlab_provider/gitlab_provider.py | 44 +++++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 keep-ui/app/runbooks/page.tsx create mode 100644 keep-ui/app/runbooks/runbook-table.tsx create mode 100644 keep/api/models/db/runbook.py diff --git a/keep-ui/app/runbooks/page.tsx b/keep-ui/app/runbooks/page.tsx new file mode 100644 index 000000000..21853c030 --- /dev/null +++ b/keep-ui/app/runbooks/page.tsx @@ -0,0 +1,9 @@ +import RunbookIncidentTable from './runbook-table'; + +export default function RunbookPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx new file mode 100644 index 000000000..aa3ad1933 --- /dev/null +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -0,0 +1,177 @@ +"use client"; + +import React, { useState } from "react"; +import Modal from "react-modal"; // Add this import for react-modal +import { + Button, + Badge, + Table as TremorTable, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "@tremor/react"; +import { DisplayColumnDef } from "@tanstack/react-table"; +import { GenericTable } from "@/components/table/GenericTable"; + + +const customStyles = { + content: { + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + marginRight: '-50%', + transform: 'translate(-50%, -50%)', + width: '400px', + }, +}; + +interface Incident { + id: number; + name: string; +} + +interface Runbook { + id: number; + title: string; + incidents: Incident[]; +} + +const runbookData: Runbook[] = [ + { + id: 1, + title: "Database Recovery", + incidents: [ + { id: 101, name: "DB Outage on 2024-01-01" }, + { id: 102, name: "DB Backup Failure" }, + ], + }, + { + id: 2, + title: "API Health Check", + incidents: [ + { id: 201, name: "API Latency Issue" }, + ], + }, + { + id: 3, + title: "Server Restart Guide", + incidents: [ + { id: 301, name: "Unexpected Server Crash" }, + { id: 302, name: "Scheduled Maintenance" }, + ], + }, +]; + +const columns: DisplayColumnDef[] = [ + { + accessorKey: 'title', + header: 'Runbook Title', + cell: info => info.getValue(), + }, + { + accessorKey: 'incidents', + header: 'Incidents', + cell: info => ( +
+ {info.getValue().map((incident: Incident) => ( + + {incident.name} + + ))} +
+ ), + }, +]; + +function RunbookIncidentTable() { + const [offset, setOffset] = useState(0); + const [limit, setLimit] = useState(10); + + // Modal state management + const [isModalOpen, setIsModalOpen] = useState(false); + const [repositoryName, setRepositoryName] = useState(''); + const [pathToMdFiles, setPathToMdFiles] = useState(''); + + const handlePaginationChange = (newLimit: number, newOffset: number) => { + setLimit(newLimit); + setOffset(newOffset); + }; + + // Open modal handler + const openModal = () => { + setIsModalOpen(true); + }; + + // Close modal handler + const closeModal = () => { + setIsModalOpen(false); + }; + + // Handle save action from modal + const handleSave = () => { + // You can handle saving the data here (e.g., API call or updating state) + console.log('Repository:', repositoryName); + console.log('Path to MD Files:', pathToMdFiles); + closeModal(); + }; + + return ( +
+ + + + data={runbookData} + columns={columns} + rowCount={runbookData.length} + offset={offset} + limit={limit} + onPaginationChange={handlePaginationChange} + onRowClick={(row) => { + console.log("Runbook clicked:", row); + }} + /> + + {/* Modal for Settings */} + +

Runbook Settings

+ +
+ + setRepositoryName(e.target.value)} + placeholder="Enter repository name" + style={{ width: '100%', padding: '8px', marginBottom: '10px' }} + /> +
+ +
+ + setPathToMdFiles(e.target.value)} + placeholder="Enter path to markdown files" + style={{ width: '100%', padding: '8px', marginBottom: '10px' }} + /> +
+ +
+ + +
+
+
+ ); +} + +export default RunbookIncidentTable; diff --git a/keep-ui/components/navbar/NoiseReductionLinks.tsx b/keep-ui/components/navbar/NoiseReductionLinks.tsx index b7799d42b..06980dbe4 100644 --- a/keep-ui/components/navbar/NoiseReductionLinks.tsx +++ b/keep-ui/components/navbar/NoiseReductionLinks.tsx @@ -10,6 +10,7 @@ import classNames from "classnames"; import { AILink } from "./AILink"; import { TbTopologyRing } from "react-icons/tb"; import { FaVolumeMute } from "react-icons/fa"; +import { FaMarkdown } from "react-icons/fa"; import { useTopology } from "utils/hooks/useTopology"; type NoiseReductionLinksProps = { session: Session | null }; @@ -41,6 +42,14 @@ export const NoiseReductionLinks = ({ session }: NoiseReductionLinksProps) => { +
  • + + Runbooks + +
  • Correlations diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py new file mode 100644 index 000000000..5aba84e24 --- /dev/null +++ b/keep/api/models/db/runbook.py @@ -0,0 +1,92 @@ +from datetime import datetime +from typing import List, Optional +from uuid import UUID, uuid4 + +from pydantic import BaseModel +from sqlalchemy import DateTime, ForeignKey, Column, TEXT, JSON +from sqlmodel import Field, Relationship, SQLModel +from keep.api.models.db.tenant import Tenant + +# Runbook Model +class Runbook(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + tenant_id: str = Field(foreign_key="tenant.id") + tenant: Tenant = Relationship() + + title: str = Field(nullable=False) # Title of the runbook + link: str = Field(nullable=False) # Link to the .md file + + incidents: List["Incident"] = Relationship( + back_populates="runbooks", link_model=RunbookToIncident + ) + + created_at: datetime = Field(default_factory=datetime.utcnow) + + class Config: + arbitrary_types_allowed = True + + +# Link Model between Runbook and Incident +class RunbookToIncident(SQLModel, table=True): + tenant_id: str = Field(foreign_key="tenant.id") + runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) + incident_id: UUID = Field(foreign_key="incident.id", primary_key=True) + + incident_id: UUID = Field( + sa_column=Column( + UUID(binary=False), + ForeignKey("incident.id", ondelete="CASCADE"), + primary_key=True, + ) + ) + + +# Incident Model +class Incident(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + tenant_id: str = Field(foreign_key="tenant.id") + tenant: Tenant = Relationship() + + user_generated_name: Optional[str] = None + ai_generated_name: Optional[str] = None + + user_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) + generated_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) + + assignee: Optional[str] = None + severity: int = Field(default=IncidentSeverity.CRITICAL.order) + + creation_time: datetime = Field(default_factory=datetime.utcnow) + + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + last_seen_time: Optional[datetime] = None + + runbooks: List["Runbook"] = Relationship( + back_populates="incidents", link_model=RunbookToIncident + ) + + is_predicted: bool = Field(default=False) + is_confirmed: bool = Field(default=False) + + alerts_count: int = Field(default=0) + affected_services: List = Field(sa_column=Column(JSON), default_factory=list) + sources: List = Field(sa_column=Column(JSON), default_factory=list) + + rule_id: Optional[UUID] = Field( + sa_column=Column( + UUID(binary=False), + ForeignKey("rule.id", ondelete="CASCADE"), + nullable=True, + ), + ) + + rule_fingerprint: str = Field(default="", sa_column=Column(TEXT)) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if "runbooks" not in kwargs: + self.runbooks = [] + + class Config: + arbitrary_types_allowed = True diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 5dc3fe265..7e9bdfaa1 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -25,6 +25,20 @@ class GithubProviderAuthConfig: "sensitive": True, } ) + repository: str = dataclasses.field( + metadata={ + "description": "GitHub Repository", + "sensitive": False, + }, + default=None, + ) + md_path: str = dataclasses.field( + metadata={ + "description": "Path to .md files in the repository", + "sensitive": False, + }, + default=None, + ) class GithubProvider(BaseProvider): @@ -58,6 +72,32 @@ def validate_config(self): self.authentication_config = GithubProviderAuthConfig( **self.config.authentication ) + def query_runbook(self,query): + """Retrieve markdown files from the GitHub repository.""" + + if not query: + raise ValueError("Query is required") + + auth=None + if self.authentication_config.repository and self.authentication_config.md_path: + auth = HTTPBasicAuth( + self.authentication_config.repository, + self.authentication_config.md_path, + ) + + resp = requests.get( + f"{self.authentication_config.url}/api/v1/query", + params={"query": query}, + auth=( + auth + if self.authentication_config.repository and self.authentication_config.md_path + else None + ) + ) + if response.status_code != 200: + raise Exception(f"Runbook Query Failed: {response.content}") + + return response.json() class GithubStarsProvider(GithubProvider): @@ -111,7 +151,12 @@ def _query( github_stars_provider = GithubStarsProvider( context_manager, "test", - ProviderConfig(authentication={"access_token": os.environ.get("GITHUB_PAT")}), + ProviderConfig(authentication={ + "access_token": os.environ.get("GITHUB_PAT"), + "repository": os.environ.get("GITHUB_REPOSITORY"), + "md_path": os.environ.get("MARKDOWN_PATH"), + } + ), ) result = github_stars_provider.query( diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 90563a15e..dc6b8656a 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -35,6 +35,20 @@ class GitlabProviderAuthConfig: "documentation_url": "https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html", } ) + repository: str = dataclasses.field( + metadata={ + "description": "GitHub Repository", + "sensitive": False, + }, + default=None, + ) + md_path: str = dataclasses.field( + metadata={ + "description": "Path to .md files in the repository", + "sensitive": False, + }, + default=None, + ) class GitlabProvider(BaseProvider): @@ -142,6 +156,34 @@ def __build_params_from_kwargs(self, kwargs: dict): else: params[param] = kwargs[param] return params + + def query_runbook(self,query): + """Retrieve markdown files from the GitHub repository.""" + + if not query: + raise ValueError("Query is required") + + auth=None + if self.authentication_config.repository and self.authentication_config.md_path: + auth = HTTPBasicAuth( + self.authentication_config.repository, + self.authentication_config.md_path, + ) + + resp = requests.get( + f"{self.authentication_config.url}/api/v1/query", + params={"query": query}, + auth=( + auth + if self.authentication_config.repository and self.authentication_config.md_path + else None + ) + ) + if response.status_code != 200: + raise Exception(f"Runbook Query Failed: {response.content}") + + return response.json() + def _notify(self, id: str, title: str, description: str = "", labels: str = "", issue_type: str = "issue", **kwargs: dict): @@ -180,6 +222,8 @@ def _notify(self, id: str, title: str, description: str = "", labels: str = "", authentication={ "personal_access_token": gitlab_pat, "host": gitlab_host, + "repository": os.environ.get("GITHUB_REPOSITORY"), + "md_path": os.environ.get("MARKDOWN_PATH") }, ) provider = GitlabProvider(context_manager, provider_id="gitlab", config=config) From d2e90fdfff184bf9e5ad3149b9406ff2beecbab0 Mon Sep 17 00:00:00 2001 From: Mubashirshariq Date: Sun, 29 Sep 2024 12:06:30 +0530 Subject: [PATCH 02/40] made some changes --- keep-ui/app/runbooks/runbook-table.tsx | 45 ++++++++++++------- .../github_provider/github_provider.py | 44 ------------------ 2 files changed, 29 insertions(+), 60 deletions(-) diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index aa3ad1933..8e6ec4877 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -1,19 +1,14 @@ "use client"; import React, { useState } from "react"; -import Modal from "react-modal"; // Add this import for react-modal +import Modal from "react-modal"; import { Button, Badge, - Table as TremorTable, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, } from "@tremor/react"; import { DisplayColumnDef } from "@tanstack/react-table"; -import { GenericTable } from "@/components/table/GenericTable"; +import { GenericTable } from "@/components/table/GenericTable"; +import { useSession } from "next-auth/react"; const customStyles = { @@ -95,6 +90,8 @@ function RunbookIncidentTable() { const [repositoryName, setRepositoryName] = useState(''); const [pathToMdFiles, setPathToMdFiles] = useState(''); + const { data: session } = useSession(); + const handlePaginationChange = (newLimit: number, newOffset: number) => { setLimit(newLimit); setOffset(newOffset); @@ -110,12 +107,28 @@ function RunbookIncidentTable() { setIsModalOpen(false); }; - // Handle save action from modal - const handleSave = () => { - // You can handle saving the data here (e.g., API call or updating state) - console.log('Repository:', repositoryName); - console.log('Path to MD Files:', pathToMdFiles); - closeModal(); + const handleQuerySettings = async ({ repositoryName, pathToMdFiles }) => { + try { + const [owner, repo] = repositoryName.split("/"); + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${pathToMdFiles}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session?.accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to query settings'); + } + + const data = await response.json(); + console.log('Settings queried successfully:', data); + + } catch (error) { + console.error('Error while querying settings:', error); + alert('An error occurred while querying settings'); + } }; return ( @@ -149,7 +162,7 @@ function RunbookIncidentTable() { type="text" value={repositoryName} onChange={(e) => setRepositoryName(e.target.value)} - placeholder="Enter repository name" + placeholder="Enter repository name (e.g., owner/repo)" style={{ width: '100%', padding: '8px', marginBottom: '10px' }} /> @@ -167,7 +180,7 @@ function RunbookIncidentTable() {
    - +
    diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 7e9bdfaa1..7b495189e 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -25,21 +25,6 @@ class GithubProviderAuthConfig: "sensitive": True, } ) - repository: str = dataclasses.field( - metadata={ - "description": "GitHub Repository", - "sensitive": False, - }, - default=None, - ) - md_path: str = dataclasses.field( - metadata={ - "description": "Path to .md files in the repository", - "sensitive": False, - }, - default=None, - ) - class GithubProvider(BaseProvider): """ @@ -72,33 +57,6 @@ def validate_config(self): self.authentication_config = GithubProviderAuthConfig( **self.config.authentication ) - def query_runbook(self,query): - """Retrieve markdown files from the GitHub repository.""" - - if not query: - raise ValueError("Query is required") - - auth=None - if self.authentication_config.repository and self.authentication_config.md_path: - auth = HTTPBasicAuth( - self.authentication_config.repository, - self.authentication_config.md_path, - ) - - resp = requests.get( - f"{self.authentication_config.url}/api/v1/query", - params={"query": query}, - auth=( - auth - if self.authentication_config.repository and self.authentication_config.md_path - else None - ) - ) - if response.status_code != 200: - raise Exception(f"Runbook Query Failed: {response.content}") - - return response.json() - class GithubStarsProvider(GithubProvider): """ @@ -153,8 +111,6 @@ def _query( "test", ProviderConfig(authentication={ "access_token": os.environ.get("GITHUB_PAT"), - "repository": os.environ.get("GITHUB_REPOSITORY"), - "md_path": os.environ.get("MARKDOWN_PATH"), } ), ) From 2e9bc5d22c7e05caf0bd934511357e79368dc7ce Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Tue, 1 Oct 2024 19:06:58 +0530 Subject: [PATCH 03/40] fix: access token issue on frontend. but need to move the fetching logic to backend. --- keep-ui/app/runbooks/runbook-table.tsx | 379 ++++++++++++++----------- keep-ui/utils/apiUrl.ts | 13 + keep-ui/utils/fetcher.ts | 9 +- keep-ui/utils/hooks/useRunbook.ts | 157 ++++++++++ 4 files changed, 391 insertions(+), 167 deletions(-) create mode 100644 keep-ui/utils/hooks/useRunbook.ts diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index 8e6ec4877..7cc095bee 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -1,190 +1,239 @@ "use client"; -import React, { useState } from "react"; -import Modal from "react-modal"; -import { - Button, - Badge, -} from "@tremor/react"; +import React, { useEffect, useMemo, useState } from "react"; +import Modal from "react-modal"; +import { Button, Badge } from "@tremor/react"; import { DisplayColumnDef } from "@tanstack/react-table"; -import { GenericTable } from "@/components/table/GenericTable"; +import { GenericTable } from "@/components/table/GenericTable"; import { useSession } from "next-auth/react"; - +import { useProviders } from "utils/hooks/useProviders"; +import { ProvidersResponse, Provider } from "app/providers/providers"; +import { set } from "date-fns"; +import { useRunBookTriggers } from "utils/hooks/useRunbook"; +import { useForm, get } from "react-hook-form"; const customStyles = { - content: { - top: '50%', - left: '50%', - right: 'auto', - bottom: 'auto', - marginRight: '-50%', - transform: 'translate(-50%, -50%)', - width: '400px', - }, + content: { + top: "50%", + left: "50%", + right: "auto", + bottom: "auto", + marginRight: "-50%", + transform: "translate(-50%, -50%)", + width: "400px", + }, }; interface Incident { - id: number; - name: string; + id: number; + name: string; } interface Runbook { - id: number; - title: string; - incidents: Incident[]; + id: number; + title: string; + incidents: Incident[]; } const runbookData: Runbook[] = [ - { - id: 1, - title: "Database Recovery", - incidents: [ - { id: 101, name: "DB Outage on 2024-01-01" }, - { id: 102, name: "DB Backup Failure" }, - ], - }, - { - id: 2, - title: "API Health Check", - incidents: [ - { id: 201, name: "API Latency Issue" }, - ], - }, - { - id: 3, - title: "Server Restart Guide", - incidents: [ - { id: 301, name: "Unexpected Server Crash" }, - { id: 302, name: "Scheduled Maintenance" }, - ], - }, + { + id: 1, + title: "Database Recovery", + incidents: [ + { id: 101, name: "DB Outage on 2024-01-01" }, + { id: 102, name: "DB Backup Failure" }, + ], + }, + { + id: 2, + title: "API Health Check", + incidents: [{ id: 201, name: "API Latency Issue" }], + }, + { + id: 3, + title: "Server Restart Guide", + incidents: [ + { id: 301, name: "Unexpected Server Crash" }, + { id: 302, name: "Scheduled Maintenance" }, + ], + }, ]; const columns: DisplayColumnDef[] = [ - { - accessorKey: 'title', - header: 'Runbook Title', - cell: info => info.getValue(), - }, - { - accessorKey: 'incidents', - header: 'Incidents', - cell: info => ( -
    - {info.getValue().map((incident: Incident) => ( - - {incident.name} - - ))} -
    - ), - }, + { + accessorKey: "title", + header: "Runbook Title", + cell: (info) => info.getValue(), + }, + { + accessorKey: "incidents", + header: "Incidents", + cell: (info) => ( +
    + {info.getValue().map((incident: Incident) => ( + + {incident.name} + + ))} +
    + ), + }, ]; -function RunbookIncidentTable() { - const [offset, setOffset] = useState(0); - const [limit, setLimit] = useState(10); - - // Modal state management - const [isModalOpen, setIsModalOpen] = useState(false); - const [repositoryName, setRepositoryName] = useState(''); - const [pathToMdFiles, setPathToMdFiles] = useState(''); - - const { data: session } = useSession(); - - const handlePaginationChange = (newLimit: number, newOffset: number) => { - setLimit(newLimit); - setOffset(newOffset); - }; - - // Open modal handler - const openModal = () => { - setIsModalOpen(true); - }; - - // Close modal handler - const closeModal = () => { - setIsModalOpen(false); - }; - - const handleQuerySettings = async ({ repositoryName, pathToMdFiles }) => { - try { - const [owner, repo] = repositoryName.split("/"); - const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${pathToMdFiles}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session?.accessToken}`, - }, - }); - - if (!response.ok) { - throw new Error('Failed to query settings'); - } - - const data = await response.json(); - console.log('Settings queried successfully:', data); - - } catch (error) { - console.error('Error while querying settings:', error); - alert('An error occurred while querying settings'); - } - }; - - return ( -
    - - - - data={runbookData} - columns={columns} - rowCount={runbookData.length} - offset={offset} - limit={limit} - onPaginationChange={handlePaginationChange} - onRowClick={(row) => { - console.log("Runbook clicked:", row); - }} +function SettingsPage() { + const [isModalOpen, setIsModalOpen] = useState(false); + const { register, handleSubmit, reset, getValues, setValue } = useForm(); + const [userName, setUserName] = useState(""); + const { + runBookInstalledProviders, + reposData, + handleSubmit: submitHandler, + } = useRunBookTriggers(getValues()); + + const openModal = () => { + reset(); // Reset form when opening modal + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + const onSubmit = (data: any) => { + submitHandler(data); // Call the submit handler with form data + // closeModal(); // Close modal after submit + }; + + return ( +
    + + +

    Runbook Settings

    +
    +
    + + +
    +
    + {/* It change according to the provider. for github we neeed user name and for gitlab we need userId */} + + { + setUserName(e.target.value); + setValue("userName", e.target.value); + }} /> - - {/* Modal for Settings */} - +
    + + +
    +
    + + +
    +
    + + +
    +
    + - -
    -
    -
    - ); + Cancel + + +
    + + +
    + ); +} + +function RunbookIncidentTable() { + const [offset, setOffset] = useState(0); + const [limit, setLimit] = useState(10); + + // Modal state management + + const { data: session } = useSession(); + + const handlePaginationChange = (newLimit: number, newOffset: number) => { + setLimit(newLimit); + setOffset(newOffset); + }; + + // Open modal handler + + return ( +
    + + + data={runbookData} + columns={columns} + rowCount={runbookData.length} + offset={offset} + limit={limit} + onPaginationChange={handlePaginationChange} + onRowClick={(row) => { + console.log("Runbook clicked:", row); + }} + /> + + {/* Modal for Settings */} +
    + ); } export default RunbookIncidentTable; diff --git a/keep-ui/utils/apiUrl.ts b/keep-ui/utils/apiUrl.ts index 512f7dba9..8d773c901 100644 --- a/keep-ui/utils/apiUrl.ts +++ b/keep-ui/utils/apiUrl.ts @@ -1,3 +1,5 @@ +import { Provider } from "app/providers/providers"; + export function getApiURL(): string { // https://github.com/vercel/next.js/issues/5354#issuecomment-520305040 // https://stackoverflow.com/questions/49411796/how-do-i-detect-whether-i-am-on-server-on-client-in-next-js @@ -38,3 +40,14 @@ export function getApiURL(): string { return process.env.API_URL!.replace("keep-api", serviceName); } } + +export function getRunBookUrl(provider: Provider): string { + switch (provider?.type) { + case "github": + return "https://api.github.com"; + case "gitlab": + return "https://gitlab.com/api/v4"; + default: + return ""; + } +} diff --git a/keep-ui/utils/fetcher.ts b/keep-ui/utils/fetcher.ts index 1b1446b1b..24a59867e 100644 --- a/keep-ui/utils/fetcher.ts +++ b/keep-ui/utils/fetcher.ts @@ -1,12 +1,17 @@ import { KeepApiError } from '../app/error'; +export interface OverideHeaders { + headers: Record; +} + export const fetcher = async ( url: string, accessToken: string | undefined, - requestInit: RequestInit = {} + requestInit: RequestInit = {}, + overideHeaders?: OverideHeaders ) => { const response = await fetch(url, { - headers: { + headers: overideHeaders?.headers ?? { Authorization: `Bearer ${accessToken}`, }, ...requestInit, diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts new file mode 100644 index 000000000..aa6543cea --- /dev/null +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -0,0 +1,157 @@ +import useSWR, { SWRConfiguration } from "swr"; +import { getRunBookUrl } from "utils/apiUrl"; +import { fetcher, OverideHeaders } from "utils/fetcher"; +import { useProviders } from "./useProviders"; +import { ProvidersResponse, Provider } from "app/providers/providers"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { debounce } from "lodash"; + +export const useRunBookTriggers = (values: any) => { + const providersData = useProviders(); + const [error, setError] = useState(""); + const [synced, setSynced] = useState(false); + const [fileData, setFileData] = useState([]); + const [reposData, setRepoData] = useState([]); + const { pathToMdFile, repoName, userName, providerId, domain } = values || {}; + const { installed_providers, providers } = (providersData?.data || + {}) as ProvidersResponse; + const runBookInstalledProviders = + installed_providers?.filter((provider) => + ["github", "gitlab"].includes(provider.type) + ) || []; + const provider = runBookInstalledProviders?.find( + (provider) => provider.id === providerId + ); + + const apiUrl = getRunBookUrl(provider!); + + function getAccessToken(provider: Provider) { + switch (provider?.type) { + case "github": + return provider?.details?.authentication?.access_token; + case "gitlab": + return ( + provider?.details?.authentication?.personal_access_token || + provider?.details?.authentication?.access_token + ); + default: + return ""; + } + } + + const accessToken = getAccessToken(provider!); + + function constructRepoUrl() { + switch (provider?.type) { + case "github": + return `${apiUrl}/users/${userName}/repos`; + case "gitlab": + return `${apiUrl}/users/${userName}/projects`; + default: + return ""; + } + } + + useEffect(() => { + const getUserRepos = async () => { + console.log("entering this", userName, apiUrl); + if (!userName) { + return setRepoData([]); + } + const url = constructRepoUrl(); + if (!url) { + return setRepoData([]); + } + try { + //need to move it backend. + const data = await fetcher(url, accessToken); + setRepoData(data); + } catch (err) { + console.log("error occurred while fetching data"); + setRepoData([]); + } + }; + + const debounceUserReposRequest = debounce(getUserRepos, 400); + debounceUserReposRequest(); + + // Cleanup to cancel the debounce on unmount or before the next effect run + return () => { + debounceUserReposRequest.cancel(); + }; + }, [userName, apiUrl, provider?.id]); + + function constructUrl( + userName: string, + repoName: string, + pathToMdFile: string + ) { + switch (provider?.type) { + case "github": + return `${apiUrl}/repos/${userName}/${repoName}/contents/${pathToMdFile}`; + case "gitlab": + const repoId = reposData?.find( + (repo: any) => repo.name === repoName + )?.id; + return `${apiUrl}/projects/${repoId}/repository/files/${pathToMdFile}?ref=main`; + default: + return ""; + } + } + + function constructHeaders() { + switch (provider?.type) { + case "gitlab": + return { + headers: { + "PRIVATE-TOKEN": accessToken, + }, + } as OverideHeaders; + default: + return {} as OverideHeaders; + } + } + + const handleSubmit = async (data: any) => { + const { pathToMdFile, repoName, userName } = data; + console.log("entering this", data); + if (!pathToMdFile) { + return { loading: false, data: [], error: "User name is required" }; + } + + const url = constructUrl(userName, repoName, pathToMdFile); + if (!url) { + return setError("Url not found"); + } + + const headers = constructHeaders(); + + try { + //need to move to backend. + const response = await fetcher(url, accessToken, {}, headers); + + if (!response) { + return setError("Something went wrong. try agian after some time"); + } + + setFileData(response); + setSynced(false); + //send it to backend and store the details in db. to DO + } catch (err) { + return setError("Something went wrong. try agian after some time"); + } finally { + setSynced(true); + } + }; + + const HandlePreview = () => {}; + + return { + runBookInstalledProviders, + providersData, + reposData, + handleSubmit, + fileData, + HandlePreview, + }; +}; From 8d1c4433be501c84867a87381462f71ee166cdaa Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Tue, 1 Oct 2024 19:08:51 +0530 Subject: [PATCH 04/40] chore:remvoed unwanted logs --- keep-ui/utils/hooks/useRunbook.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts index aa6543cea..fcf75bfb6 100644 --- a/keep-ui/utils/hooks/useRunbook.ts +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -54,7 +54,6 @@ export const useRunBookTriggers = (values: any) => { useEffect(() => { const getUserRepos = async () => { - console.log("entering this", userName, apiUrl); if (!userName) { return setRepoData([]); } @@ -114,7 +113,6 @@ export const useRunBookTriggers = (values: any) => { const handleSubmit = async (data: any) => { const { pathToMdFile, repoName, userName } = data; - console.log("entering this", data); if (!pathToMdFile) { return { loading: false, data: [], error: "User name is required" }; } From 546a0bd21c3d01f76a761459fb51e4f2bc052c8a Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 2 Oct 2024 17:52:36 +0530 Subject: [PATCH 05/40] refactor: moved the entire runbook fetching logic to backend and added the new tag --- .../app/providers/filter-context/constants.ts | 1 + keep-ui/app/providers/provider-tile.tsx | 4 +- keep-ui/app/providers/providers.tsx | 2 +- keep-ui/app/runbooks/runbook-table.tsx | 137 +++++++++------ keep-ui/utils/apiUrl.ts | 12 -- keep-ui/utils/fetcher.ts | 8 +- keep-ui/utils/hooks/useRunbook.ts | 117 +++---------- keep/api/models/db/runbook.py | 6 +- keep/api/models/provider.py | 2 +- keep/api/routes/providers.py | 69 +++++++- keep/providers/base/base_provider.py | 11 +- .../github_provider/github_provider.py | 143 +++++++++++++++- .../gitlab_provider/gitlab_provider.py | 161 +++++++++++++++--- keep/providers/providers_factory.py | 7 +- 14 files changed, 483 insertions(+), 197 deletions(-) diff --git a/keep-ui/app/providers/filter-context/constants.ts b/keep-ui/app/providers/filter-context/constants.ts index 4034af03f..99b072577 100644 --- a/keep-ui/app/providers/filter-context/constants.ts +++ b/keep-ui/app/providers/filter-context/constants.ts @@ -7,6 +7,7 @@ export const PROVIDER_LABELS: Record = { ticketing: 'Ticketing', data: 'Data', queue: 'Queue', + runbook: 'Runbook', } export const PROVIDER_LABELS_KEYS = Object.keys(PROVIDER_LABELS); \ No newline at end of file diff --git a/keep-ui/app/providers/provider-tile.tsx b/keep-ui/app/providers/provider-tile.tsx index 0e09b6e0a..f5a5b127e 100644 --- a/keep-ui/app/providers/provider-tile.tsx +++ b/keep-ui/app/providers/provider-tile.tsx @@ -19,7 +19,7 @@ import { import "./provider-tile.css"; import moment from "moment"; import ImageWithFallback from "@/components/ImageWithFallback"; -import { FaCode } from "react-icons/fa"; +import { FaCode, FaMarkdown } from "react-icons/fa"; interface Props { provider: Provider; @@ -101,6 +101,8 @@ function getIconForTag(tag: TProviderLabels) { return QueueListIcon; case "topology": return MapIcon; + case "runbook": + return FaMarkdown default: return ChatBubbleBottomCenterIcon; } diff --git a/keep-ui/app/providers/providers.tsx b/keep-ui/app/providers/providers.tsx index ed060ead2..c6af9d314 100644 --- a/keep-ui/app/providers/providers.tsx +++ b/keep-ui/app/providers/providers.tsx @@ -51,7 +51,7 @@ interface AlertDistritbuionData { number: number; } -export type TProviderLabels = 'alert' | 'topology' | 'messaging' | 'ticketing' | 'data' | 'queue'; +export type TProviderLabels = 'alert' | 'topology' | 'messaging' | 'ticketing' | 'data' | 'queue' | 'runbook'; export interface Provider { // key value pair of auth method name and auth method config diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index 7cc095bee..dda6f3965 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react"; import Modal from "react-modal"; import { Button, Badge } from "@tremor/react"; -import { DisplayColumnDef } from "@tanstack/react-table"; +import { createColumnHelper, DisplayColumnDef } from "@tanstack/react-table"; import { GenericTable } from "@/components/table/GenericTable"; import { useSession } from "next-auth/react"; import { useProviders } from "utils/hooks/useProviders"; @@ -11,6 +11,8 @@ import { ProvidersResponse, Provider } from "app/providers/providers"; import { set } from "date-fns"; import { useRunBookTriggers } from "utils/hooks/useRunbook"; import { useForm, get } from "react-hook-form"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; const customStyles = { content: { @@ -35,7 +37,7 @@ interface Runbook { incidents: Incident[]; } -const runbookData: Runbook[] = [ +const runbookData = [ { id: 1, title: "Database Recovery", @@ -57,46 +59,88 @@ const runbookData: Runbook[] = [ { id: 302, name: "Scheduled Maintenance" }, ], }, -]; +] as Runbook[]; -const columns: DisplayColumnDef[] = [ - { - accessorKey: "title", +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.display({ + id: "title", header: "Runbook Title", - cell: (info) => info.getValue(), - }, - { - accessorKey: "incidents", - header: "Incidents", - cell: (info) => ( -
    - {info.getValue().map((incident: Incident) => ( - - {incident.name} - - ))} -
    - ), - }, -]; + cell: ({ row }) => { + return
    {row.original.title}
    ; + }, + }), + columnHelper.display({ + id: "incidents", + header: "Incdients", + cell: ({ row }) => { + return ( +
    + {row.original.incidents?.map((incident: Incident) => ( + + {incident.name} + + ))} +
    + ); + }, + }), +] as DisplayColumnDef[]; + +// function PreviewContent({type, data}:{type:string, data:string}){ +// const [isModalOpen, setIsModalOpen] = useState(open); + +// // const closeModal = () => { +// // setIsModalOpen(false); +// // }; +// const decodeBase64 = (encodedContent:string) => { +// // Use atob to decode the Base64 string and then handle UTF-8 encoding +// return encodedContent ? decodeURIComponent(atob(encodedContent)) : ''; +// }; +// return
    +// +// {decodeBase64(data)} +// +//
    +// } + +// TO DO: Need to work on styling function SettingsPage() { const [isModalOpen, setIsModalOpen] = useState(false); const { register, handleSubmit, reset, getValues, setValue } = useForm(); - const [userName, setUserName] = useState(""); + const [refresh, setRefresh] = useState(0); + const [openPreview, setOpenPreview] = useState(false); const { runBookInstalledProviders, reposData, handleSubmit: submitHandler, - } = useRunBookTriggers(getValues()); + provider, + fileData, + } = useRunBookTriggers(getValues(), refresh); + + useEffect(() => { + setValue( + "repoName", + reposData?.legnth ? provider?.details?.authentication.repository : "" + ); + setOpenPreview(false); + }, [reposData]); const openModal = () => { reset(); // Reset form when opening modal setIsModalOpen(true); + setOpenPreview(false); }; - const closeModal = () => { + const closeModal = (openPreview?: boolean) => { setIsModalOpen(false); + if (openPreview) { + setOpenPreview(true); + } else { + setOpenPreview(false); + } }; const onSubmit = (data: any) => { @@ -109,7 +153,7 @@ function SettingsPage() { closeModal()} style={customStyles} contentLabel="Settings Modal" > @@ -121,8 +165,8 @@ function SettingsPage() { {...register("providerId")} style={{ width: "100%", padding: "8px", marginBottom: "10px" }} onChange={(e) => { - setValue("userName", ""); - setUserName(""); + setValue("providerId", e.target.value); + setRefresh((prev) => prev + 1); }} > + {/* {fileData?.content && openPreview &&} */} ); } @@ -207,15 +248,11 @@ function RunbookIncidentTable() { // Modal state management - const { data: session } = useSession(); - const handlePaginationChange = (newLimit: number, newOffset: number) => { setLimit(newLimit); setOffset(newOffset); }; - // Open modal handler - return (
    @@ -230,8 +267,6 @@ function RunbookIncidentTable() { console.log("Runbook clicked:", row); }} /> - - {/* Modal for Settings */}
    ); } diff --git a/keep-ui/utils/apiUrl.ts b/keep-ui/utils/apiUrl.ts index 8d773c901..52006d2ee 100644 --- a/keep-ui/utils/apiUrl.ts +++ b/keep-ui/utils/apiUrl.ts @@ -1,4 +1,3 @@ -import { Provider } from "app/providers/providers"; export function getApiURL(): string { // https://github.com/vercel/next.js/issues/5354#issuecomment-520305040 @@ -40,14 +39,3 @@ export function getApiURL(): string { return process.env.API_URL!.replace("keep-api", serviceName); } } - -export function getRunBookUrl(provider: Provider): string { - switch (provider?.type) { - case "github": - return "https://api.github.com"; - case "gitlab": - return "https://gitlab.com/api/v4"; - default: - return ""; - } -} diff --git a/keep-ui/utils/fetcher.ts b/keep-ui/utils/fetcher.ts index 24a59867e..c8c7fd899 100644 --- a/keep-ui/utils/fetcher.ts +++ b/keep-ui/utils/fetcher.ts @@ -1,17 +1,11 @@ import { KeepApiError } from '../app/error'; - -export interface OverideHeaders { - headers: Record; -} - export const fetcher = async ( url: string, accessToken: string | undefined, requestInit: RequestInit = {}, - overideHeaders?: OverideHeaders ) => { const response = await fetch(url, { - headers: overideHeaders?.headers ?? { + headers: { Authorization: `Bearer ${accessToken}`, }, ...requestInit, diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts index fcf75bfb6..fabc1dc25 100644 --- a/keep-ui/utils/hooks/useRunbook.ts +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -1,18 +1,20 @@ import useSWR, { SWRConfiguration } from "swr"; -import { getRunBookUrl } from "utils/apiUrl"; -import { fetcher, OverideHeaders } from "utils/fetcher"; +import { getApiURL } from "utils/apiUrl"; +import { fetcher } from "utils/fetcher"; import { useProviders } from "./useProviders"; import { ProvidersResponse, Provider } from "app/providers/providers"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { debounce } from "lodash"; +import { useSession } from "next-auth/react"; -export const useRunBookTriggers = (values: any) => { +export const useRunBookTriggers = (values: any, refresh: number) => { const providersData = useProviders(); const [error, setError] = useState(""); const [synced, setSynced] = useState(false); const [fileData, setFileData] = useState([]); const [reposData, setRepoData] = useState([]); const { pathToMdFile, repoName, userName, providerId, domain } = values || {}; + const { data: session } = useSession(); const { installed_providers, providers } = (providersData?.data || {}) as ProvidersResponse; const runBookInstalledProviders = @@ -23,47 +25,15 @@ export const useRunBookTriggers = (values: any) => { (provider) => provider.id === providerId ); - const apiUrl = getRunBookUrl(provider!); - - function getAccessToken(provider: Provider) { - switch (provider?.type) { - case "github": - return provider?.details?.authentication?.access_token; - case "gitlab": - return ( - provider?.details?.authentication?.personal_access_token || - provider?.details?.authentication?.access_token - ); - default: - return ""; - } - } - - const accessToken = getAccessToken(provider!); - - function constructRepoUrl() { - switch (provider?.type) { - case "github": - return `${apiUrl}/users/${userName}/repos`; - case "gitlab": - return `${apiUrl}/users/${userName}/projects`; - default: - return ""; - } - } + const baseApiurl = getApiURL(); useEffect(() => { const getUserRepos = async () => { - if (!userName) { - return setRepoData([]); - } - const url = constructRepoUrl(); - if (!url) { - return setRepoData([]); - } try { - //need to move it backend. - const data = await fetcher(url, accessToken); + const data = await fetcher( + `${baseApiurl}/providers/${provider?.type}/${provider?.id}/repositories`, + session?.accessToken + ); setRepoData(data); } catch (err) { console.log("error occurred while fetching data"); @@ -78,63 +48,31 @@ export const useRunBookTriggers = (values: any) => { return () => { debounceUserReposRequest.cancel(); }; - }, [userName, apiUrl, provider?.id]); - - function constructUrl( - userName: string, - repoName: string, - pathToMdFile: string - ) { - switch (provider?.type) { - case "github": - return `${apiUrl}/repos/${userName}/${repoName}/contents/${pathToMdFile}`; - case "gitlab": - const repoId = reposData?.find( - (repo: any) => repo.name === repoName - )?.id; - return `${apiUrl}/projects/${repoId}/repository/files/${pathToMdFile}?ref=main`; - default: - return ""; - } - } - - function constructHeaders() { - switch (provider?.type) { - case "gitlab": - return { - headers: { - "PRIVATE-TOKEN": accessToken, - }, - } as OverideHeaders; - default: - return {} as OverideHeaders; - } - } + }, [refresh]); const handleSubmit = async (data: any) => { - const { pathToMdFile, repoName, userName } = data; - if (!pathToMdFile) { - return { loading: false, data: [], error: "User name is required" }; - } - - const url = constructUrl(userName, repoName, pathToMdFile); - if (!url) { - return setError("Url not found"); - } - - const headers = constructHeaders(); - + const { pathToMdFile, repoName } = data; try { - //need to move to backend. - const response = await fetcher(url, accessToken, {}, headers); + const params = new URLSearchParams(); + if (pathToMdFile) { + params.append("md_path", pathToMdFile); + } + if (repoName) { + params.append("repo", repoName); + } + //TO DO backend runbook records needs to be created. + const response = await fetcher( + `${baseApiurl}/providers/${provider?.type}/${ + provider?.id + }/runbook?${params.toString()}`, + session?.accessToken + ); if (!response) { return setError("Something went wrong. try agian after some time"); } - setFileData(response); setSynced(false); - //send it to backend and store the details in db. to DO } catch (err) { return setError("Something went wrong. try agian after some time"); } finally { @@ -151,5 +89,6 @@ export const useRunBookTriggers = (values: any) => { handleSubmit, fileData, HandlePreview, + provider, }; }; diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index 5aba84e24..2549140c1 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -12,14 +12,16 @@ class Runbook(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) tenant_id: str = Field(foreign_key="tenant.id") tenant: Tenant = Relationship() - + repo_id: str = Field(nullable=False) # Github repo id + relative_path: str = Field(nullable=False) # Relative path to the .md file title: str = Field(nullable=False) # Title of the runbook link: str = Field(nullable=False) # Link to the .md file incidents: List["Incident"] = Relationship( back_populates="runbooks", link_model=RunbookToIncident ) - + provider_type: str + provider_id: str | None created_at: datetime = Field(default_factory=datetime.utcnow) class Config: diff --git a/keep/api/models/provider.py b/keep/api/models/provider.py index b800a2890..f316873ab 100644 --- a/keep/api/models/provider.py +++ b/keep/api/models/provider.py @@ -40,7 +40,7 @@ class Provider(BaseModel): last_pull_time: datetime | None = None docs: str | None = None tags: list[ - Literal["alert", "ticketing", "messaging", "data", "queue", "topology"] + Literal["alert", "ticketing", "messaging", "data", "queue", "topology", "runbook"] ] = [] alertsDistribution: dict[str, int] | None = None alertExample: dict | None = None diff --git a/keep/api/routes/providers.py b/keep/api/routes/providers.py index e55e92e24..c3fd52497 100644 --- a/keep/api/routes/providers.py +++ b/keep/api/routes/providers.py @@ -5,7 +5,7 @@ import uuid from typing import Callable, Optional -from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request, Query from fastapi.responses import JSONResponse from sqlmodel import Session, select from starlette.datastructures import UploadFile @@ -733,3 +733,70 @@ def get_webhook_settings( ), webhookMarkdown=webhookMarkdown, ) + +@router.get("/{provider_type}/{provider_id}/repositories") +def get_repositories( + provider_type: str, + provider_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:providers"]) + ), + session: Session = Depends(get_session), +): + tenant_id = authenticated_entity.tenant_id + logger.info("Getting respositories", extra={"provider_type": provider_type, "provider_id": provider_id}) + tenant_id = authenticated_entity.tenant_id + logger.info( + "Getting provider alerts", + extra={ + "tenant_id": tenant_id, + "provider_type": provider_type, + "provider_id": provider_id, + }, + ) + context_manager = ContextManager(tenant_id=tenant_id) + secret_manager = SecretManagerFactory.get_secret_manager(context_manager) + provider_config = secret_manager.read_secret( + f"{tenant_id}_{provider_type}_{provider_id}", is_json=True + ) + provider = ProvidersFactory.get_provider( + context_manager, provider_id, provider_type, provider_config + ) + + return provider.pull_repositories() + + +@router.get("/{provider_type}/{provider_id}/runbook") +def get_repositories( + provider_type: str, + provider_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:providers"]) + ), + session: Session = Depends(get_session), + repo: str = Query(None), + branch: str = Query(None), + md_path: str = Query(None), +): + tenant_id = authenticated_entity.tenant_id + logger.info("Getting runbook", extra={"provider_type": provider_type, "provider_id": provider_id}) + tenant_id = authenticated_entity.tenant_id + logger.info( + "Getting provider alerts", + extra={ + "tenant_id": tenant_id, + "provider_type": provider_type, + "provider_id": provider_id, + }, + ) + context_manager = ContextManager(tenant_id=tenant_id) + secret_manager = SecretManagerFactory.get_secret_manager(context_manager) + provider_config = secret_manager.read_secret( + f"{tenant_id}_{provider_type}_{provider_id}", is_json=True + ) + provider = ProvidersFactory.get_provider( + context_manager, provider_id, provider_type, provider_config + ) + + return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path) + diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index ba968837b..09721b973 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -37,7 +37,7 @@ class BaseProvider(metaclass=abc.ABCMeta): PROVIDER_METHODS: list[ProviderMethod] = [] FINGERPRINT_FIELDS: list[str] = [] PROVIDER_TAGS: list[ - Literal["alert", "ticketing", "messaging", "data", "queue", "topology"] + Literal["alert", "ticketing", "messaging", "data", "queue", "topology", "runbook"] ] = [] def __init__( @@ -663,3 +663,12 @@ def simulate_alert(cls) -> dict: class BaseTopologyProvider(BaseProvider): def pull_topology(self) -> list[TopologyServiceInDto]: raise NotImplementedError("get_topology() method not implemented") + + +class BaseRunBookProvider(BaseProvider): + def pull_runbook(self, repo=None, branch=None, md_path=None): + raise NotImplementedError("get_runbook() method not implemented") + + def pull_repositories(self): + raise NotImplementedError("get_repositories() method not implemented") + diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 7b495189e..3445b502b 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -5,10 +5,10 @@ import dataclasses import pydantic -from github import Github +from github import Github, GithubException from keep.contextmanager.contextmanager import ContextManager -from keep.providers.base.base_provider import BaseProvider +from keep.providers.base.base_provider import BaseProvider, BaseRunBookProvider from keep.providers.models.provider_config import ProviderConfig @@ -26,7 +26,22 @@ class GithubProviderAuthConfig: } ) -class GithubProvider(BaseProvider): + repository: str = dataclasses.field( + metadata={ + "description": "GitHub Repository Name", + "sensitive": False, + }, + default=None, + ) + md_path: str = dataclasses.field( + metadata={ + "description": "Path to .md files in the repository", + "sensitive": False, + }, + default=None, + ) + +class GithubProvider(BaseRunBookProvider): """ Enrich alerts with data from GitHub. """ @@ -58,6 +73,108 @@ def validate_config(self): **self.config.authentication ) + + def _format_repo(self, repo:dict): + """ + Format the repository data. + """ + if repo is not None: + return { + "id": repo.id, + "name": repo.name, + "full_name": repo.full_name, + "url": repo.html_url, + "description": repo.description, + "private": repo.private, + "option_value": repo.name, + "display_name": repo.full_name, + "default_branch": repo.default_branch, + } + + return {} + + def _format_repos(self, repos:list[dict]): + """ + Format the repository data into a list of dictionaries. + """ + formatted_repos = [] + for repo in repos: + formatted_repos.append( + self._format_repo(repo) + ) + + return formatted_repos + + def pull_repositories(self, project_id=None): + """ + Get user repositories. + """ + if self.authentication_config.access_token: + client = Github(self.authentication_config.access_token) + else: + client = Github() + user = client.get_user() + repos = user.get_repos() + if project_id: + repo = client.get_repo(project_id) + return self._format_repo(repo) + + repos_list = self._format_repos(repos) + return repos_list + + def _format_runbook(self, runbook, repo): + """ + Format the runbook data into a dictionary. + """ + if runbook is None: + return {} + + return { + "file_name": runbook.name, + "file_path": runbook.path, + "file_size": runbook.size, + "file_type": runbook.type, + "repo_id": repo.get("id"), + "repo_name": repo.get("name"), + "repo_display_name": repo.get("display_name"), + "provider_type": "github", + "provider_id": self.config.authentication.get("provider_id"), + "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", + "content": runbook.content, + "encoding": runbook.encoding, + } + + + + def pull_runbook(self, repo=None, branch=None, md_path=None): + """Retrieve markdown files from the GitHub repository using the GitHub client.""" + + repo_name = repo if repo else self.authentication_config.repository + branch = branch if branch else "main" + md_path = md_path if md_path else self.authentication_config.md_path + + + if repo_name and branch and md_path: + # Initialize the GitHub client + client = self.__generate_client() + + try: + # Get the repository + user = client.get_user() + username = user.login + repo = client.get_repo(f"{username}/{repo_name}") + if repo is None: + raise Exception(f"Repository {repo_name} not found") + + runbook = repo.get_contents(md_path, branch) + response = self._format_runbook(runbook, self._format_repo(repo)) + return response + + except GithubException as e: + raise Exception(f"Failed to retrieve runbook: {e}") + + raise Exception(f"Failed to get runbook: repository, branch, md_path, or access_token not set") + class GithubStarsProvider(GithubProvider): """ GithubStarsProvider is a class that provides a way to read stars from a GitHub repository. @@ -106,11 +223,13 @@ def _query( tenant_id="singletenant", workflow_id="test", ) + github_pat = os.environ.get("GITHUB_PAT") + github_stars_provider = GithubStarsProvider( context_manager, "test", ProviderConfig(authentication={ - "access_token": os.environ.get("GITHUB_PAT"), + "access_token": github_pat, } ), ) @@ -119,3 +238,19 @@ def _query( repository="keephq/keep", previous_stars_count=910 ) print(result) + + + # Initalize the provider and provider config + config = ProviderConfig( + description="GitHub Provider", + authentication={ + "access_token": github_pat, + "repository": os.environ.get("GITHUB_REPOSITORY"), + "md_path": os.environ.get("MARKDOWN_PATH"), + }, + ) + provider = GithubProvider(context_manager, provider_id="github", config=config) + result = provider.pull_runbook() + result = provider.pull_repositories() + + print(result) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index dc6b8656a..8bba50f46 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -10,7 +10,7 @@ from requests import HTTPError from keep.contextmanager.contextmanager import ContextManager -from keep.providers.base.base_provider import BaseProvider +from keep.providers.base.base_provider import BaseProvider, BaseRunBookProvider from keep.providers.models.provider_config import ProviderConfig, ProviderScope @@ -37,7 +37,7 @@ class GitlabProviderAuthConfig: ) repository: str = dataclasses.field( metadata={ - "description": "GitHub Repository", + "description": "GitHub Repository Id", "sensitive": False, }, default=None, @@ -51,7 +51,7 @@ class GitlabProviderAuthConfig: ) -class GitlabProvider(BaseProvider): +class GitlabProvider(BaseRunBookProvider): """Enrich alerts with GitLab tickets.""" PROVIDER_SCOPES = [ @@ -62,7 +62,7 @@ class GitlabProvider(BaseProvider): alias="GitLab PAT with api scope", ), ] - PROVIDER_TAGS = ["ticketing"] + PROVIDER_TAGS = ["ticketing", "runbook"] PROVIDER_DISPLAY_NAME = "GitLab" def __init__( @@ -156,33 +156,138 @@ def __build_params_from_kwargs(self, kwargs: dict): else: params[param] = kwargs[param] return params - - def query_runbook(self,query): - """Retrieve markdown files from the GitHub repository.""" - if not query: - raise ValueError("Query is required") - auth=None - if self.authentication_config.repository and self.authentication_config.md_path: - auth = HTTPBasicAuth( - self.authentication_config.repository, - self.authentication_config.md_path, + # def query_for_repositories(self, **kwargs: dict): + # """Retrieve repositories from the Gitlab""" + # project_id = kwargs.get("project_id") + # user_name = self.authentication_config.username + # auth=self.__get_auth_header() + # url = f"{slef.gitlab_host}/api/v4/projects/{project_id}" if project_id else f"{self.gitlab_host}/api/v4/projects" + # resp = requests.get( + # url, headers=self.__get_auth_header() + # auth=auth + # ) + # if response.status_code != 200: + # raise Exception(f"Repository Query Failed: {response.content}") + # return response.json() + + def get_gitlab_user_id(self): + """ + Retrieve the user ID from the access token in GitLab. + """ + url = f"{self.gitlab_host}/api/v4/user" + headers = self.__get_auth_header() + response = requests.get(url, headers=headers) + + if response.status_code == 200: + user_data = response.json() + print(user_data) + return user_data['id'] # The user ID + else: + raise Exception(f"Failed to retrieve user info: {response.status_code}, {response.text}") + + def _format_repos(self, repos, project_id=None): + """ + Format the repository data into a list of dictionaries. + """ + if project_id is not None: + if repos is not None: + return { + "id": repos.get("id"), + "name": repos.get("name"), + "full_name": repos.get("full_name"), + "url": repos.get("web_url"), + "description": repos.get("description"), + "private": repos.get("visibility"), + "option_value": repos.get("id"), + "display_name": repos.get("path_with_namespace"), + "default_branch": repos.get("default_branch"), + } + return {} + + formatted_repos = [] + for repo in repos: + formatted_repos.append( + { + "id": repo.get("id"), + "name": repo.get("name"), + "full_name": repo.get("full_name"), + "url": repo.get("web_url"), + "description": repo.get("description"), + "private": repo.get("visibility"), + "option_value": repo.get("id"), + "display_name": repo.get("path_with_namespace"), + "default_branch": repo.get("default_branch"), + } + ) + + return formatted_repos + + def pull_repositories(self, project_id=None): + """Get user repositories.""" + if self.authentication_config.personal_access_token: + user_id = self.get_gitlab_user_id() + url = f"{self.gitlab_host}/api/v4/projects/{project_id}" if project_id else f"{self.gitlab_host}/api/v4/users/{user_id}/projects" + resp = requests.get( + url, + headers=self.__get_auth_header() + ) + try: + resp.raise_for_status() + except HTTPError as e: + raise Exception(f"Failed to query repositories: {e}") + + repos = resp.json() + return self._format_repos(repos, project_id) + + raise Exception(f"Failed to get repositories: personal_access_token not set") + + def _format_runbook(self, runbook, repo): + """ + Format the runbook data into a dictionary. + """ + if runbook is None: + return {} + + return { + "file_name": runbook.get("file_name"), + "file_path": runbook.get("file_path"), + "file_size": runbook.get("size"), + "file_type": runbook.get("type"), + "repo_id": repo.get("id"), + "repo_name": repo.get("name"), + "repo_display_name": repo.get("display_name"), + "provider_type": "gitlab", + "config": self.config.authentication.get("provider_id"), + "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", + "content": runbook.get("content"), + "encoding": runbook.get("encoding"), + } + + def pull_runbook(self, repo=None, branch=None, md_path=None): + """Retrieve markdown files from the GitLab repository.""" + repo = repo if repo else self.authentication_config.repository + branch = branch if branch else "main" + md_path = md_path if md_path else self.authentication_config.md_path + + repo_meta = self.pull_repositories(project_id=repo) + + if repo_meta and branch and md_path: + repo_id = repo_meta.get("id") + resp = requests.get( + f"{self.gitlab_host}/api/v4/projects/{repo_id}/repository/files/{md_path}?ref={branch}", + headers=self.__get_auth_header() ) - resp = requests.get( - f"{self.authentication_config.url}/api/v1/query", - params={"query": query}, - auth=( - auth - if self.authentication_config.repository and self.authentication_config.md_path - else None - ) - ) - if response.status_code != 200: - raise Exception(f"Runbook Query Failed: {response.content}") + try: + resp.raise_for_status() + except HTTPError as e: + raise Exception(f"Failed to get runbook: {e}") - return response.json() + return self._format_runbook(resp.json(), repo_meta) + + raise Exception(f"Failed to get runbook: repository or md_path not set") def _notify(self, id: str, title: str, description: str = "", labels: str = "", issue_type: str = "issue", @@ -235,3 +340,7 @@ def _notify(self, id: str, title: str, description: str = "", labels: str = "", summary="Test Alert", description="Test Alert Description", ) + + result = provider.pull_runbook() + result = provider.pull_repositories() + print(result) diff --git a/keep/providers/providers_factory.py b/keep/providers/providers_factory.py index 0b3385625..3daf72d64 100644 --- a/keep/providers/providers_factory.py +++ b/keep/providers/providers_factory.py @@ -22,7 +22,7 @@ from keep.api.models.alert import DeduplicationRuleDto from keep.api.models.provider import Provider from keep.contextmanager.contextmanager import ContextManager -from keep.providers.base.base_provider import BaseProvider, BaseTopologyProvider +from keep.providers.base.base_provider import BaseProvider, BaseTopologyProvider, BaseRunBookProvider from keep.providers.models.provider_config import ProviderConfig from keep.providers.models.provider_method import ProviderMethodDTO, ProviderMethodParam from keep.secretmanager.secretmanagerfactory import SecretManagerFactory @@ -313,7 +313,12 @@ def get_all_providers() -> list[Provider]: docs = provider_class.__doc__ can_fetch_topology = issubclass(provider_class, BaseTopologyProvider) + can_fetch_runbook = issubclass(provider_class, BaseRunBookProvider) + provider_tags = set(provider_class.PROVIDER_TAGS) + + if can_fetch_runbook: + provider_tags.add("runbook") if can_fetch_topology: provider_tags.add("topology") if can_query and "data" not in provider_tags: From 9c99c29b05484acb873052fb2e97686269912320 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 2 Oct 2024 17:56:31 +0530 Subject: [PATCH 06/40] chore: clean up the code --- keep/providers/gitlab_provider/gitlab_provider.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 8bba50f46..2feec6216 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -157,21 +157,6 @@ def __build_params_from_kwargs(self, kwargs: dict): params[param] = kwargs[param] return params - - # def query_for_repositories(self, **kwargs: dict): - # """Retrieve repositories from the Gitlab""" - # project_id = kwargs.get("project_id") - # user_name = self.authentication_config.username - # auth=self.__get_auth_header() - # url = f"{slef.gitlab_host}/api/v4/projects/{project_id}" if project_id else f"{self.gitlab_host}/api/v4/projects" - # resp = requests.get( - # url, headers=self.__get_auth_header() - # auth=auth - # ) - # if response.status_code != 200: - # raise Exception(f"Repository Query Failed: {response.content}") - # return response.json() - def get_gitlab_user_id(self): """ Retrieve the user ID from the access token in GitLab. From a8712623b59169d045216dbf0081f3fb7e1ce8c8 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 2 Oct 2024 18:17:49 +0530 Subject: [PATCH 07/40] chore: resolved the lint issues --- keep/api/models/db/runbook.py | 37 +++++++++---------- .../github_provider/github_provider.py | 4 +- .../gitlab_provider/gitlab_provider.py | 8 ++-- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index 2549140c1..b0f577210 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -2,11 +2,25 @@ from typing import List, Optional from uuid import UUID, uuid4 -from pydantic import BaseModel -from sqlalchemy import DateTime, ForeignKey, Column, TEXT, JSON +from sqlalchemy import ForeignKey, Column, TEXT, JSON from sqlmodel import Field, Relationship, SQLModel from keep.api.models.db.tenant import Tenant +# Link Model between Runbook and Incident +class RunbookToIncident(SQLModel, table=True): + tenant_id: str = Field(foreign_key="tenant.id") + runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) + incident_id: UUID = Field(foreign_key="incident.id", primary_key=True) + + incident_id: UUID = Field( + sa_column=Column( + UUID(binary=False), + ForeignKey("incident.id", ondelete="CASCADE"), + primary_key=True, + ) + ) + + # Runbook Model class Runbook(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -21,28 +35,13 @@ class Runbook(SQLModel, table=True): back_populates="runbooks", link_model=RunbookToIncident ) provider_type: str - provider_id: str | None + provider_id: Optional[str] = None created_at: datetime = Field(default_factory=datetime.utcnow) class Config: arbitrary_types_allowed = True -# Link Model between Runbook and Incident -class RunbookToIncident(SQLModel, table=True): - tenant_id: str = Field(foreign_key="tenant.id") - runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) - incident_id: UUID = Field(foreign_key="incident.id", primary_key=True) - - incident_id: UUID = Field( - sa_column=Column( - UUID(binary=False), - ForeignKey("incident.id", ondelete="CASCADE"), - primary_key=True, - ) - ) - - # Incident Model class Incident(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -56,7 +55,7 @@ class Incident(SQLModel, table=True): generated_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) assignee: Optional[str] = None - severity: int = Field(default=IncidentSeverity.CRITICAL.order) + # severity: int = Field(default=IncidentSeverity.CRITICAL.order) creation_time: datetime = Field(default_factory=datetime.utcnow) diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 3445b502b..f89db02a1 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -8,7 +8,7 @@ from github import Github, GithubException from keep.contextmanager.contextmanager import ContextManager -from keep.providers.base.base_provider import BaseProvider, BaseRunBookProvider +from keep.providers.base.base_provider import BaseRunBookProvider from keep.providers.models.provider_config import ProviderConfig @@ -173,7 +173,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None): except GithubException as e: raise Exception(f"Failed to retrieve runbook: {e}") - raise Exception(f"Failed to get runbook: repository, branch, md_path, or access_token not set") + raise Exception("Failed to get runbook: repository, branch, md_path, or access_token not set") class GithubStarsProvider(GithubProvider): """ diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 2feec6216..480f5dbff 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -1,5 +1,5 @@ """ -GitlabProvider is a class that implements the BaseProvider interface for GitLab updates. +GitlabProvider is a class that implements the BaseRunBookProvider interface for GitLab updates. """ import dataclasses @@ -10,7 +10,7 @@ from requests import HTTPError from keep.contextmanager.contextmanager import ContextManager -from keep.providers.base.base_provider import BaseProvider, BaseRunBookProvider +from keep.providers.base.base_provider import BaseRunBookProvider from keep.providers.models.provider_config import ProviderConfig, ProviderScope @@ -226,7 +226,7 @@ def pull_repositories(self, project_id=None): repos = resp.json() return self._format_repos(repos, project_id) - raise Exception(f"Failed to get repositories: personal_access_token not set") + raise Exception("Failed to get repositories: personal_access_token not set") def _format_runbook(self, runbook, repo): """ @@ -272,7 +272,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None): return self._format_runbook(resp.json(), repo_meta) - raise Exception(f"Failed to get runbook: repository or md_path not set") + raise Exception("Failed to get runbook: repository or md_path not set") def _notify(self, id: str, title: str, description: str = "", labels: str = "", issue_type: str = "issue", From 25cd6e847bb3f5efe7a354ff5bc00dabcac9dac8 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 2 Oct 2024 19:58:22 +0530 Subject: [PATCH 08/40] fix: minor drop down issue --- keep-ui/app/runbooks/runbook-table.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index dda6f3965..b3fbc9cb4 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -148,6 +148,7 @@ function SettingsPage() { // closeModal(); // Close modal after submit }; + console.log("content", fileData); return (
    @@ -167,9 +168,11 @@ function SettingsPage() { onChange={(e) => { setValue("providerId", e.target.value); setRefresh((prev) => prev + 1); + setValue('repoName', ''); }} + defaultValue={provider?.details?.authentication.provider_id ?? ""} > - {runBookInstalledProviders.map((provider) => ( @@ -184,8 +187,9 @@ function SettingsPage() { { - setValue("providerId", e.target.value); - setRefresh((prev) => prev + 1); - setValue('repoName', ''); - }} - defaultValue={provider?.details?.authentication.provider_id ?? ""} - > - - {runBookInstalledProviders.map((provider) => ( - - ))} - +
    +
    + {/* */} + +
    +
    + +
    +
    + +
    +
    + +
    -
    - - -
    -
    - - + Preview + + +
    */} +
    + +
    -
    - - + + + {/* {fileData?.content && openPreview &&} */} +
    + ); +} + +function RunbookIncidentTable() { + const [offset, setOffset] = useState(0); + const [limit, setLimit] = useState(10); + + // Modal state management + + const handlePaginationChange = (newLimit: number, newOffset: number) => { + setLimit(newLimit); + setOffset(newOffset); + }; + + return ( +
    +
    + Runbook + +
    + + + data={runbookData} + columns={columns} + rowCount={runbookData.length} + offset={offset} + limit={limit} + onPaginationChange={handlePaginationChange} + onRowClick={(row) => { + console.log("Runbook clicked:", row); + }} + /> + +
    + ); +} + +export default RunbookIncidentTable; + + setOpenPreview(false); + }; + + const closeModal = (openPreview?: boolean) => { + setIsModalOpen(false); + if (openPreview) { + setOpenPreview(true); + } else { + setOpenPreview(false); + } + }; + + const onSubmit = (data: any) => { + submitHandler(data); // Call the submit handler with form data + // closeModal(); // Close modal after submit + }; + + const handleProviderChange = (value: string) => { + setValue("repoName", ""); + setValue("providerId", value); + setRefresh((prev) => prev + 1); + }; + + return ( +
    + + closeModal()} + style={customStyles} + contentLabel="Settings Modal" + > +

    Runbook Settings

    +
    +
    +
    + {/* */} + +
    +
    + +
    +
    + +
    +
    + +
    {/*
    // } -// TO DO: Need to work on styling -function SettingsPage() { - const [isModalOpen, setIsModalOpen] = useState(false); - const { register, handleSubmit, reset, getValues, setValue, watch } = - useForm(); - const [refresh, setRefresh] = useState(0); - const [openPreview, setOpenPreview] = useState(false); - - const { - runBookInstalledProviders, - reposData, - handleSubmit: submitHandler, - provider, - fileData, - } = useRunBookTriggers(getValues(), refresh); - - const selectedProviderId = watch( - "providerId", - provider?.details?.authentication?.provider_id ?? "" - ); - const selectedRepo = watch( - "repoName", - provider?.details?.authentication?.repository ?? "" - ); - - useEffect(() => { - setValue( - "repoName", - reposData?.legnth ? provider?.details?.authentication.repository : "" - ); - setOpenPreview(false); - }, [reposData]); - - const openModal = () => { - reset(); // Reset form when opening modal - setIsModalOpen(true);"use client"; - -import React, { useEffect, useMemo, useState } from "react"; -import Modal from "react-modal"; -import { - Button, - Badge, - Select, - SelectItem, - TextInput, - Title, - Card, -} from "@tremor/react"; -import { createColumnHelper, DisplayColumnDef } from "@tanstack/react-table"; -import { GenericTable } from "@/components/table/GenericTable"; -import { useRunBookTriggers } from "utils/hooks/useRunbook"; -import { useForm, get } from "react-hook-form"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; - -const customStyles = { - content: { - top: "50%", - left: "50%", - right: "auto", - bottom: "auto", - marginRight: "-50%", - transform: "translate(-50%, -50%)", - width: "400px", - }, -}; - -interface Incident { - id: number; - name: string; -} - -interface Runbook { - id: number; - title: string; - incidents: Incident[]; -} - -const runbookData = [ - { - id: 1, - title: "Database Recovery", - incidents: [ - { id: 101, name: "DB Outage on 2024-01-01" }, - { id: 102, name: "DB Backup Failure" }, - ], - }, - { - id: 2, - title: "API Health Check", - incidents: [{ id: 201, name: "API Latency Issue" }], - }, - { - id: 3, - title: "Server Restart Guide", - incidents: [ - { id: 301, name: "Unexpected Server Crash" }, - { id: 302, name: "Scheduled Maintenance" }, - ], - }, -] as Runbook[]; - -const columnHelper = createColumnHelper(); - -const columns = [ - columnHelper.display({ - id: "title", - header: "Runbook Title", - cell: ({ row }) => { - return
    {row.original.title}
    ; - }, - }), - columnHelper.display({ - id: "incidents", - header: "Incdients", - cell: ({ row }) => { - return ( -
    - {row.original.incidents?.map((incident: Incident) => ( - - {incident.name} - - ))} -
    - ); - }, - }), -] as DisplayColumnDef[]; - -// function PreviewContent({type, data}:{type:string, data:string}){ -// const [isModalOpen, setIsModalOpen] = useState(open); - -// // const closeModal = () => { -// // setIsModalOpen(false); -// // }; -// const decodeBase64 = (encodedContent:string) => { -// // Use atob to decode the Base64 string and then handle UTF-8 encoding -// return encodedContent ? decodeURIComponent(atob(encodedContent)) : ''; -// }; -// return
    - -// -// {decodeBase64(data)} -// -//
    -// } - // TO DO: Need to work on styling function SettingsPage() { const [isModalOpen, setIsModalOpen] = useState(false); @@ -422,163 +275,11 @@ function RunbookIncidentTable() { return (
    -
    - Runbook - -
    - - - data={runbookData} - columns={columns} - rowCount={runbookData.length} - offset={offset} - limit={limit} - onPaginationChange={handlePaginationChange} - onRowClick={(row) => { - console.log("Runbook clicked:", row); - }} - /> - -
    - ); -} - -export default RunbookIncidentTable; - - setOpenPreview(false); - }; - - const closeModal = (openPreview?: boolean) => { - setIsModalOpen(false); - if (openPreview) { - setOpenPreview(true); - } else { - setOpenPreview(false); - } - }; - - const onSubmit = (data: any) => { - submitHandler(data); // Call the submit handler with form data - // closeModal(); // Close modal after submit - }; - - const handleProviderChange = (value: string) => { - setValue("repoName", ""); - setValue("providerId", value); - setRefresh((prev) => prev + 1); - }; - - return ( -
    - - closeModal()} - style={customStyles} - contentLabel="Settings Modal" - > -

    Runbook Settings

    - -
    -
    - {/* */} - -
    -
    - -
    -
    - -
    -
    - -
    -
    - {/*
    - - -
    */} -
    - - -
    - -
    - {/* {fileData?.content && openPreview &&} */} -
    - ); -} - -function RunbookIncidentTable() { - const [offset, setOffset] = useState(0); - const [limit, setLimit] = useState(10); - - // Modal state management - - const handlePaginationChange = (newLimit: number, newOffset: number) => { - setLimit(newLimit); - setOffset(newOffset); - }; - - return ( -
    -
    +
    Runbook
    - + data={runbookData} columns={columns} From 12bdd122a405cb7624b7f5573bbfc0f2909d0534 Mon Sep 17 00:00:00 2001 From: Mubashirshariq Date: Mon, 7 Oct 2024 14:19:06 +0530 Subject: [PATCH 11/40] moved runbook endpoints to runbook.py --- keep-ui/next-env.d.ts | 2 +- keep-ui/utils/hooks/useRunbook.ts | 4 +- keep/api/api.py | 4 ++ keep/api/routes/providers.py | 68 +------------------------- keep/api/routes/runbooks.py | 79 +++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 70 deletions(-) create mode 100644 keep/api/routes/runbooks.py diff --git a/keep-ui/next-env.d.ts b/keep-ui/next-env.d.ts index 725dd6f24..fd36f9494 100644 --- a/keep-ui/next-env.d.ts +++ b/keep-ui/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts index 9d4be158c..37e0e1939 100644 --- a/keep-ui/utils/hooks/useRunbook.ts +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -35,7 +35,7 @@ export const useRunBookTriggers = (values: any, refresh: number) => { return setRepoData([]); } const data = await fetcher( - `${baseApiurl}/providers/${provider?.type}/${provider?.id}/repositories`, + `${baseApiurl}/runbooks/${provider?.type}/${provider?.id}/repositories`, session?.accessToken ); setRepoData(data); @@ -69,7 +69,7 @@ export const useRunBookTriggers = (values: any, refresh: number) => { } //TO DO backend runbook records needs to be created. const response = await fetcher( - `${baseApiurl}/providers/${provider?.type}/${ + `${baseApiurl}/runbooks/${provider?.type}/${ provider?.id }/runbook?${params.toString()}`, session?.accessToken diff --git a/keep/api/api.py b/keep/api/api.py index 33e195f2b..4f184ecda 100644 --- a/keep/api/api.py +++ b/keep/api/api.py @@ -54,6 +54,7 @@ topology, whoami, workflows, + runbooks, ) from keep.api.routes.auth import groups as auth_groups from keep.api.routes.auth import permissions, roles, users @@ -243,6 +244,9 @@ def get_app( app.include_router( deduplications.router, prefix="/deduplications", tags=["deduplications"] ) + app.include_router( + runbooks.router, prefix="/runbooks", tags=["runbooks"] + ) # if its single tenant with authentication, add signin endpoint logger.info(f"Starting Keep with authentication type: {AUTH_TYPE}") # If we run Keep with SINGLE_TENANT auth type, we want to add the signin endpoint diff --git a/keep/api/routes/providers.py b/keep/api/routes/providers.py index c3fd52497..0eb6b59d0 100644 --- a/keep/api/routes/providers.py +++ b/keep/api/routes/providers.py @@ -732,71 +732,5 @@ def get_webhook_settings( keep_webhook_api_url_with_auth=keep_webhook_api_url_with_auth, ), webhookMarkdown=webhookMarkdown, - ) - -@router.get("/{provider_type}/{provider_id}/repositories") -def get_repositories( - provider_type: str, - provider_id: str, - authenticated_entity: AuthenticatedEntity = Depends( - IdentityManagerFactory.get_auth_verifier(["read:providers"]) - ), - session: Session = Depends(get_session), -): - tenant_id = authenticated_entity.tenant_id - logger.info("Getting respositories", extra={"provider_type": provider_type, "provider_id": provider_id}) - tenant_id = authenticated_entity.tenant_id - logger.info( - "Getting provider alerts", - extra={ - "tenant_id": tenant_id, - "provider_type": provider_type, - "provider_id": provider_id, - }, - ) - context_manager = ContextManager(tenant_id=tenant_id) - secret_manager = SecretManagerFactory.get_secret_manager(context_manager) - provider_config = secret_manager.read_secret( - f"{tenant_id}_{provider_type}_{provider_id}", is_json=True - ) - provider = ProvidersFactory.get_provider( - context_manager, provider_id, provider_type, provider_config - ) - - return provider.pull_repositories() - - -@router.get("/{provider_type}/{provider_id}/runbook") -def get_repositories( - provider_type: str, - provider_id: str, - authenticated_entity: AuthenticatedEntity = Depends( - IdentityManagerFactory.get_auth_verifier(["read:providers"]) - ), - session: Session = Depends(get_session), - repo: str = Query(None), - branch: str = Query(None), - md_path: str = Query(None), -): - tenant_id = authenticated_entity.tenant_id - logger.info("Getting runbook", extra={"provider_type": provider_type, "provider_id": provider_id}) - tenant_id = authenticated_entity.tenant_id - logger.info( - "Getting provider alerts", - extra={ - "tenant_id": tenant_id, - "provider_type": provider_type, - "provider_id": provider_id, - }, - ) - context_manager = ContextManager(tenant_id=tenant_id) - secret_manager = SecretManagerFactory.get_secret_manager(context_manager) - provider_config = secret_manager.read_secret( - f"{tenant_id}_{provider_type}_{provider_id}", is_json=True - ) - provider = ProvidersFactory.get_provider( - context_manager, provider_id, provider_type, provider_config - ) - - return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path) + ) diff --git a/keep/api/routes/runbooks.py b/keep/api/routes/runbooks.py new file mode 100644 index 000000000..b94ee3054 --- /dev/null +++ b/keep/api/routes/runbooks.py @@ -0,0 +1,79 @@ +import logging +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session +from keep.api.core.db import get_session +from keep.contextmanager.contextmanager import ContextManager +from keep.identitymanager.authenticatedentity import AuthenticatedEntity +from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory +from keep.providers.providers_factory import ProvidersFactory +from keep.secretmanager.secretmanagerfactory import SecretManagerFactory + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/{provider_type}/{provider_id}/repositories") +def get_repositories( + provider_type: str, + provider_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:providers"]) + ), + session: Session = Depends(get_session), +): + tenant_id = authenticated_entity.tenant_id + logger.info("Getting respositories", extra={"provider_type": provider_type, "provider_id": provider_id}) + tenant_id = authenticated_entity.tenant_id + logger.info( + "Getting provider alerts", + extra={ + "tenant_id": tenant_id, + "provider_type": provider_type, + "provider_id": provider_id, + }, + ) + context_manager = ContextManager(tenant_id=tenant_id) + secret_manager = SecretManagerFactory.get_secret_manager(context_manager) + provider_config = secret_manager.read_secret( + f"{tenant_id}_{provider_type}_{provider_id}", is_json=True + ) + provider = ProvidersFactory.get_provider( + context_manager, provider_id, provider_type, provider_config + ) + + return provider.pull_repositories() + + +@router.get("/{provider_type}/{provider_id}/runbook") +def get_repositories( + provider_type: str, + provider_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:providers"]) + ), + session: Session = Depends(get_session), + repo: str = Query(None), + branch: str = Query(None), + md_path: str = Query(None), +): + tenant_id = authenticated_entity.tenant_id + logger.info("Getting runbook", extra={"provider_type": provider_type, "provider_id": provider_id}) + logger.info( + "Getting provider alerts", + extra={ + "tenant_id": tenant_id, + "provider_type": provider_type, + "provider_id": provider_id, + }, + ) + context_manager = ContextManager(tenant_id=tenant_id) + secret_manager = SecretManagerFactory.get_secret_manager(context_manager) + provider_config = secret_manager.read_secret( + f"{tenant_id}_{provider_type}_{provider_id}", is_json=True + ) + provider = ProvidersFactory.get_provider( + context_manager, provider_id, provider_type, provider_config + ) + + return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path) From 9f1fabe27aa84ac8400b4cdad863583a056f1db8 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Tue, 8 Oct 2024 22:20:28 +0530 Subject: [PATCH 12/40] feat: add runbook data into db. to do: incident link needs to be done. --- keep-ui/app/runbooks/runbook-table.tsx | 1 + keep-ui/utils/hooks/useRunbook.ts | 27 +++- keep/api/models/db/migrations/env.py | 1 + .../versions/2024-10-08-16-09_8902e1a17f66.py | 55 +++++++ keep/api/models/db/runbook.py | 143 ++++++++---------- keep/api/routes/runbooks.py | 47 +++++- keep/providers/base/base_provider.py | 2 +- .../github_provider/github_provider.py | 24 +-- .../gitlab_provider/gitlab_provider.py | 24 +-- keep/runbooks/runbooks_service.py | 51 +++++++ 10 files changed, 265 insertions(+), 110 deletions(-) create mode 100644 keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py create mode 100644 keep/runbooks/runbooks_service.py diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index cee7353b6..b640663dd 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -230,6 +230,7 @@ function SettingsPage() {
    diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts index 37e0e1939..14675a860 100644 --- a/keep-ui/utils/hooks/useRunbook.ts +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -14,7 +14,7 @@ export const useRunBookTriggers = (values: any, refresh: number) => { const [synced, setSynced] = useState(false); const [fileData, setFileData] = useState({}); const [reposData, setRepoData] = useState([]); - const { pathToMdFile, repoName, userName, providerId, domain } = values || {}; + const { pathToMdFile, repoName, userName, providerId, domain, runBookTitle } = values || {}; const { data: session } = useSession(); const { installed_providers, providers } = (providersData?.data || {}) as ProvidersResponse; @@ -67,18 +67,31 @@ export const useRunBookTriggers = (values: any, refresh: number) => { if (repoName) { params.append("repo", repoName); } + if(runBookTitle){ + params.append("title", runBookTitle); + + } //TO DO backend runbook records needs to be created. - const response = await fetcher( - `${baseApiurl}/runbooks/${provider?.type}/${ + const response = await fetch(`${baseApiurl}/runbooks/${provider?.type}/${ provider?.id - }/runbook?${params.toString()}`, - session?.accessToken - ); + }?${params.toString()}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); if (!response) { return setError("Something went wrong. try agian after some time"); } - setFileData(response); + + if(!response.ok) { + return setError("Something went wrong. try agian after some time"); + + } + const result = await response.json(); + setFileData(result); setSynced(false); } catch (err) { return setError("Something went wrong. try agian after some time"); diff --git a/keep/api/models/db/migrations/env.py b/keep/api/models/db/migrations/env.py index 6aab4afad..912a921b9 100644 --- a/keep/api/models/db/migrations/env.py +++ b/keep/api/models/db/migrations/env.py @@ -21,6 +21,7 @@ from keep.api.models.db.topology import * from keep.api.models.db.user import * from keep.api.models.db.workflow import * +from keep.api.models.db.runbook import * target_metadata = SQLModel.metadata diff --git a/keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py b/keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py new file mode 100644 index 000000000..8612283ee --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py @@ -0,0 +1,55 @@ +"""add runbook table + +Revision ID: 8902e1a17f66 +Revises: 01ebe17218c0 +Create Date: 2024-10-08 16:09:28.158034 + +""" + +import sqlalchemy as sa +import sqlalchemy_utils +import sqlmodel +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "8902e1a17f66" +down_revision = "01ebe17218c0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "runbook", + sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("relative_path", sa.Text(), nullable=True), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("repo_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("provider_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("provider_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenant.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "runbookcontent", + sa.Column("runbook_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("link", sa.Text(), nullable=True), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("encoding", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["runbook_id"], ["runbook.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("runbookcontent") + op.drop_table("runbook") + # ### end Alembic commands ### diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index b0f577210..3d32eadae 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -1,93 +1,82 @@ +from uuid import UUID, uuid4 from datetime import datetime from typing import List, Optional -from uuid import UUID, uuid4 - -from sqlalchemy import ForeignKey, Column, TEXT, JSON from sqlmodel import Field, Relationship, SQLModel -from keep.api.models.db.tenant import Tenant +from sqlalchemy import Column, ForeignKey, Text +from pydantic import BaseModel -# Link Model between Runbook and Incident -class RunbookToIncident(SQLModel, table=True): - tenant_id: str = Field(foreign_key="tenant.id") - runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) - incident_id: UUID = Field(foreign_key="incident.id", primary_key=True) - incident_id: UUID = Field( - sa_column=Column( - UUID(binary=False), - ForeignKey("incident.id", ondelete="CASCADE"), - primary_key=True, - ) +# RunbookContent Model +class RunbookContent(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + runbook_id: UUID = Field( + sa_column=Column(ForeignKey("runbook.id", ondelete="CASCADE")) # Foreign key with CASCADE delete ) + runbook: Optional["Runbook"] = Relationship(back_populates="contents") + content: str = Field(sa_column=Column(Text), nullable=False) # Using SQLAlchemy's Text type + link: str = Field(sa_column=Column(Text), nullable=False) # Using SQLAlchemy's Text type + encoding: Optional[str] = None + created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation + class Config: + orm_mode = True # Runbook Model class Runbook(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) - tenant_id: str = Field(foreign_key="tenant.id") - tenant: Tenant = Relationship() - repo_id: str = Field(nullable=False) # Github repo id - relative_path: str = Field(nullable=False) # Relative path to the .md file - title: str = Field(nullable=False) # Title of the runbook - link: str = Field(nullable=False) # Link to the .md file - - incidents: List["Incident"] = Relationship( - back_populates="runbooks", link_model=RunbookToIncident + tenant_id: str = Field( + sa_column=Column(ForeignKey("tenant.id", ondelete="CASCADE")) # Foreign key with CASCADE delete ) - provider_type: str - provider_id: Optional[str] = None - created_at: datetime = Field(default_factory=datetime.utcnow) + repo_id: str # Repository ID + relative_path: str = Field(sa_column=Column(Text), nullable=False) # Path in the repo, must be set + title: str = Field(sa_column=Column(Text), nullable=False) # Title of the runbook, must be set + contents: List["RunbookContent"] = Relationship(back_populates="runbook") # Relationship to RunbookContent + provider_type: str # Type of the provider + provider_id: Optional[str] = None # Optional provider ID + created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation class Config: - arbitrary_types_allowed = True - - -# Incident Model -class Incident(SQLModel, table=True): - id: UUID = Field(default_factory=uuid4, primary_key=True) - tenant_id: str = Field(foreign_key="tenant.id") - tenant: Tenant = Relationship() - - user_generated_name: Optional[str] = None - ai_generated_name: Optional[str] = None - - user_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) - generated_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) - - assignee: Optional[str] = None - # severity: int = Field(default=IncidentSeverity.CRITICAL.order) - - creation_time: datetime = Field(default_factory=datetime.utcnow) - - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - last_seen_time: Optional[datetime] = None - - runbooks: List["Runbook"] = Relationship( - back_populates="incidents", link_model=RunbookToIncident - ) - - is_predicted: bool = Field(default=False) - is_confirmed: bool = Field(default=False) - - alerts_count: int = Field(default=0) - affected_services: List = Field(sa_column=Column(JSON), default_factory=list) - sources: List = Field(sa_column=Column(JSON), default_factory=list) - - rule_id: Optional[UUID] = Field( - sa_column=Column( - UUID(binary=False), - ForeignKey("rule.id", ondelete="CASCADE"), - nullable=True, - ), - ) - - rule_fingerprint: str = Field(default="", sa_column=Column(TEXT)) + orm_mode = True # Enable ORM mode for compatibility with Pydantic models + + +class RunbookDto(BaseModel, extra="ignore"): + id: UUID + tenant_id: str + repo_id: str + relative_path: str + title: str + contents: List["RunbookContent"] = [] + provider_type: str + provider_id: Optional[str] = None - def __init__(self, **kwargs): - super().__init__(**kwargs) - if "runbooks" not in kwargs: - self.runbooks = [] +class RunbookContentDto(BaseModel, extra="ignore"): + id: UUID + content: str + link: str + encoding: Optional[str] = None + + @classmethod + def from_orm(cls, content: "RunbookContent") -> "RunbookContentDto": + return cls( + id=content.id, + content=content.content, + link=content.link, + encoding=content.encoding + ) - class Config: - arbitrary_types_allowed = True +class RunbookDtoOut(RunbookDto): + contents: List[RunbookContentDto] = [] + @classmethod + def from_orm( + cls, runbook: "Runbook" + ) -> "RunbookDtoOut": + return cls( + id=runbook.id, + title=runbook.title, + tenant_id=runbook.tenant_id, + repo_id=runbook.repo_id, + relative_path=runbook.relative_path, + provider_type=runbook.provider_type, + provider_id=runbook.provider_id, + contents=[RunbookContentDto.from_orm(content) for content in runbook.contents] + ) diff --git a/keep/api/routes/runbooks.py b/keep/api/routes/runbooks.py index b94ee3054..82ec5292f 100644 --- a/keep/api/routes/runbooks.py +++ b/keep/api/routes/runbooks.py @@ -7,6 +7,13 @@ from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory from keep.providers.providers_factory import ProvidersFactory from keep.secretmanager.secretmanagerfactory import SecretManagerFactory +from keep.runbooks.runbooks_service import ( + RunbookService + ) +from keep.api.models.db.runbook import ( + RunbookDtoOut + ) + logger = logging.getLogger(__name__) @@ -45,8 +52,8 @@ def get_repositories( return provider.pull_repositories() -@router.get("/{provider_type}/{provider_id}/runbook") -def get_repositories( +@router.get("/{provider_type}/{provider_id}") +def get_runbook( provider_type: str, provider_id: str, authenticated_entity: AuthenticatedEntity = Depends( @@ -56,6 +63,7 @@ def get_repositories( repo: str = Query(None), branch: str = Query(None), md_path: str = Query(None), + title: str = Query(None), ): tenant_id = authenticated_entity.tenant_id logger.info("Getting runbook", extra={"provider_type": provider_type, "provider_id": provider_id}) @@ -76,4 +84,37 @@ def get_repositories( context_manager, provider_id, provider_type, provider_config ) - return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path) + return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path, title=title) + + +@router.post( + "/{provider_type}/{provider_id}", + description="Create a new Runbook", + # response_model=RunbookDtoOut, +) +def create_runbook( + provider_type: str, + provider_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["write:runbook"]) + ), + session: Session = Depends(get_session), + repo: str = Query(None), + branch: str = Query(None), + md_path: str = Query(None), + title: str = Query(None), +): + tenant_id = authenticated_entity.tenant_id + logger.info("Creating Runbook", extra={tenant_id: tenant_id}) + context_manager = ContextManager(tenant_id=tenant_id) + secret_manager = SecretManagerFactory.get_secret_manager(context_manager) + provider_config = secret_manager.read_secret( + f"{tenant_id}_{provider_type}_{provider_id}", is_json=True + ) + provider = ProvidersFactory.get_provider( + context_manager, provider_id, provider_type, provider_config + ) + + runbook_dto= provider.pull_runbook(repo=repo, branch=branch, md_path=md_path, title=title) + return RunbookService.create_runbook(session, tenant_id, runbook_dto) + diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index 09721b973..c20b6636c 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -666,7 +666,7 @@ def pull_topology(self) -> list[TopologyServiceInDto]: class BaseRunBookProvider(BaseProvider): - def pull_runbook(self, repo=None, branch=None, md_path=None): + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): raise NotImplementedError("get_runbook() method not implemented") def pull_repositories(self): diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index f89db02a1..47ccdda61 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -122,13 +122,12 @@ def pull_repositories(self, project_id=None): repos_list = self._format_repos(repos) return repos_list - def _format_runbook(self, runbook, repo): + def _format_runbook(self, runbook, repo, title): """ Format the runbook data into a dictionary. """ - if runbook is None: - return {} - + + # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) return { "file_name": runbook.name, "file_path": runbook.path, @@ -138,15 +137,18 @@ def _format_runbook(self, runbook, repo): "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "github", - "provider_id": self.config.authentication.get("provider_id"), - "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", - "content": runbook.content, - "encoding": runbook.encoding, + "provider_id": self.provider_id, + "contents": [{ + "content":runbook.content, + "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", + "encoding": runbook.encoding + }], + "title": title, } - def pull_runbook(self, repo=None, branch=None, md_path=None): + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): """Retrieve markdown files from the GitHub repository using the GitHub client.""" repo_name = repo if repo else self.authentication_config.repository @@ -167,7 +169,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None): raise Exception(f"Repository {repo_name} not found") runbook = repo.get_contents(md_path, branch) - response = self._format_runbook(runbook, self._format_repo(repo)) + response = self._format_runbook(runbook, self._format_repo(repo), title) return response except GithubException as e: @@ -250,7 +252,7 @@ def _query( }, ) provider = GithubProvider(context_manager, provider_id="github", config=config) - result = provider.pull_runbook() + result = provider.pull_runbook(title="test") result = provider.pull_repositories() print(result) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 480f5dbff..114f27b6f 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -228,13 +228,12 @@ def pull_repositories(self, project_id=None): raise Exception("Failed to get repositories: personal_access_token not set") - def _format_runbook(self, runbook, repo): + def _format_runbook(self, runbook, repo, title): """ Format the runbook data into a dictionary. """ - if runbook is None: - return {} - + + # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) return { "file_name": runbook.get("file_name"), "file_path": runbook.get("file_path"), @@ -244,13 +243,16 @@ def _format_runbook(self, runbook, repo): "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "gitlab", - "config": self.config.authentication.get("provider_id"), - "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", - "content": runbook.get("content"), - "encoding": runbook.get("encoding"), + "config": self.provider_id, + "contents": [{ + "content": runbook.get("content"), + "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", + "encoding": runbook.get("encoding"), + }], + "title": title, } - def pull_runbook(self, repo=None, branch=None, md_path=None): + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): """Retrieve markdown files from the GitLab repository.""" repo = repo if repo else self.authentication_config.repository branch = branch if branch else "main" @@ -270,7 +272,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None): except HTTPError as e: raise Exception(f"Failed to get runbook: {e}") - return self._format_runbook(resp.json(), repo_meta) + return self._format_runbook(resp.json(), repo_met, title) raise Exception("Failed to get runbook: repository or md_path not set") @@ -326,6 +328,6 @@ def _notify(self, id: str, title: str, description: str = "", labels: str = "", description="Test Alert Description", ) - result = provider.pull_runbook() + result = provider.pull_runbook(title="test") result = provider.pull_repositories() print(result) diff --git a/keep/runbooks/runbooks_service.py b/keep/runbooks/runbooks_service.py new file mode 100644 index 000000000..05d13c4a0 --- /dev/null +++ b/keep/runbooks/runbooks_service.py @@ -0,0 +1,51 @@ +import logging +from typing import List, Optional +from pydantic import ValidationError +from sqlalchemy.orm import joinedload, selectinload +from uuid import UUID +import json + +from sqlmodel import Session, select +from keep.api.models.db.runbook import ( + Runbook, + RunbookContent, + RunbookDtoOut +) +logger = logging.getLogger(__name__) + + +class RunbookService: + @staticmethod + def create_runbook(session: Session, tenant_id: str, runbook_dto: dict): + try: + new_runbook = Runbook( + tenant_id=tenant_id, + title=runbook_dto["title"], + repo_id=runbook_dto["repo_id"], + relative_path=runbook_dto["file_path"], + provider_type=runbook_dto["provider_type"], + provider_id=runbook_dto["provider_id"] + ) + + session.add(new_runbook) + session.flush() + contents = runbook_dto["contents"] if runbook_dto["contents"] else [] + + new_contents = [ + RunbookContent( + runbook_id=new_runbook.id, + content=content["content"], + link=content["link"], + encoding=content["encoding"] + ) + for content in contents + ] + + session.add_all(new_contents) + session.commit() + session.expire(new_runbook, ["contents"]) + session.refresh(new_runbook) # Refresh the runbook instance + result = RunbookDtoOut.from_orm(new_runbook) + return result + except ValidationError as e: + logger.exception(f"Failed to create runbook {e}") \ No newline at end of file From 1c3f04d7a756892b9708a654c312d3e4950be0df Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda <38752904+rajeshj11@users.noreply.github.com> Date: Wed, 9 Oct 2024 03:29:18 +0530 Subject: [PATCH 13/40] Revert "Feat 1780 runbooks intergation" --- keep-ui/app/runbooks/runbook-table.tsx | 1 - keep-ui/utils/hooks/useRunbook.ts | 27 +--- keep/api/models/db/migrations/env.py | 1 - .../versions/2024-10-08-16-09_8902e1a17f66.py | 55 ------- keep/api/models/db/runbook.py | 143 ++++++++++-------- keep/api/routes/runbooks.py | 47 +----- keep/providers/base/base_provider.py | 2 +- .../github_provider/github_provider.py | 24 ++- .../gitlab_provider/gitlab_provider.py | 24 ++- keep/runbooks/runbooks_service.py | 51 ------- 10 files changed, 110 insertions(+), 265 deletions(-) delete mode 100644 keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py delete mode 100644 keep/runbooks/runbooks_service.py diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index b640663dd..cee7353b6 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -230,7 +230,6 @@ function SettingsPage() {
    diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts index 14675a860..37e0e1939 100644 --- a/keep-ui/utils/hooks/useRunbook.ts +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -14,7 +14,7 @@ export const useRunBookTriggers = (values: any, refresh: number) => { const [synced, setSynced] = useState(false); const [fileData, setFileData] = useState({}); const [reposData, setRepoData] = useState([]); - const { pathToMdFile, repoName, userName, providerId, domain, runBookTitle } = values || {}; + const { pathToMdFile, repoName, userName, providerId, domain } = values || {}; const { data: session } = useSession(); const { installed_providers, providers } = (providersData?.data || {}) as ProvidersResponse; @@ -67,31 +67,18 @@ export const useRunBookTriggers = (values: any, refresh: number) => { if (repoName) { params.append("repo", repoName); } - if(runBookTitle){ - params.append("title", runBookTitle); - - } //TO DO backend runbook records needs to be created. - const response = await fetch(`${baseApiurl}/runbooks/${provider?.type}/${ + const response = await fetcher( + `${baseApiurl}/runbooks/${provider?.type}/${ provider?.id - }?${params.toString()}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${session?.accessToken}`, - }, - }); + }/runbook?${params.toString()}`, + session?.accessToken + ); if (!response) { return setError("Something went wrong. try agian after some time"); } - - if(!response.ok) { - return setError("Something went wrong. try agian after some time"); - - } - const result = await response.json(); - setFileData(result); + setFileData(response); setSynced(false); } catch (err) { return setError("Something went wrong. try agian after some time"); diff --git a/keep/api/models/db/migrations/env.py b/keep/api/models/db/migrations/env.py index 912a921b9..6aab4afad 100644 --- a/keep/api/models/db/migrations/env.py +++ b/keep/api/models/db/migrations/env.py @@ -21,7 +21,6 @@ from keep.api.models.db.topology import * from keep.api.models.db.user import * from keep.api.models.db.workflow import * -from keep.api.models.db.runbook import * target_metadata = SQLModel.metadata diff --git a/keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py b/keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py deleted file mode 100644 index 8612283ee..000000000 --- a/keep/api/models/db/migrations/versions/2024-10-08-16-09_8902e1a17f66.py +++ /dev/null @@ -1,55 +0,0 @@ -"""add runbook table - -Revision ID: 8902e1a17f66 -Revises: 01ebe17218c0 -Create Date: 2024-10-08 16:09:28.158034 - -""" - -import sqlalchemy as sa -import sqlalchemy_utils -import sqlmodel -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "8902e1a17f66" -down_revision = "01ebe17218c0" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "runbook", - sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("relative_path", sa.Text(), nullable=True), - sa.Column("title", sa.Text(), nullable=True), - sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), - sa.Column("repo_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("provider_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("provider_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["tenant_id"], ["tenant.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "runbookcontent", - sa.Column("runbook_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("link", sa.Text(), nullable=True), - sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), - sa.Column("encoding", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["runbook_id"], ["runbook.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("runbookcontent") - op.drop_table("runbook") - # ### end Alembic commands ### diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index 3d32eadae..b0f577210 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -1,82 +1,93 @@ -from uuid import UUID, uuid4 from datetime import datetime from typing import List, Optional +from uuid import UUID, uuid4 + +from sqlalchemy import ForeignKey, Column, TEXT, JSON from sqlmodel import Field, Relationship, SQLModel -from sqlalchemy import Column, ForeignKey, Text -from pydantic import BaseModel +from keep.api.models.db.tenant import Tenant +# Link Model between Runbook and Incident +class RunbookToIncident(SQLModel, table=True): + tenant_id: str = Field(foreign_key="tenant.id") + runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) + incident_id: UUID = Field(foreign_key="incident.id", primary_key=True) -# RunbookContent Model -class RunbookContent(SQLModel, table=True): - id: UUID = Field(default_factory=uuid4, primary_key=True) - runbook_id: UUID = Field( - sa_column=Column(ForeignKey("runbook.id", ondelete="CASCADE")) # Foreign key with CASCADE delete + incident_id: UUID = Field( + sa_column=Column( + UUID(binary=False), + ForeignKey("incident.id", ondelete="CASCADE"), + primary_key=True, + ) ) - runbook: Optional["Runbook"] = Relationship(back_populates="contents") - content: str = Field(sa_column=Column(Text), nullable=False) # Using SQLAlchemy's Text type - link: str = Field(sa_column=Column(Text), nullable=False) # Using SQLAlchemy's Text type - encoding: Optional[str] = None - created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation - class Config: - orm_mode = True # Runbook Model class Runbook(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) - tenant_id: str = Field( - sa_column=Column(ForeignKey("tenant.id", ondelete="CASCADE")) # Foreign key with CASCADE delete - ) - repo_id: str # Repository ID - relative_path: str = Field(sa_column=Column(Text), nullable=False) # Path in the repo, must be set - title: str = Field(sa_column=Column(Text), nullable=False) # Title of the runbook, must be set - contents: List["RunbookContent"] = Relationship(back_populates="runbook") # Relationship to RunbookContent - provider_type: str # Type of the provider - provider_id: Optional[str] = None # Optional provider ID - created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation + tenant_id: str = Field(foreign_key="tenant.id") + tenant: Tenant = Relationship() + repo_id: str = Field(nullable=False) # Github repo id + relative_path: str = Field(nullable=False) # Relative path to the .md file + title: str = Field(nullable=False) # Title of the runbook + link: str = Field(nullable=False) # Link to the .md file - class Config: - orm_mode = True # Enable ORM mode for compatibility with Pydantic models - - -class RunbookDto(BaseModel, extra="ignore"): - id: UUID - tenant_id: str - repo_id: str - relative_path: str - title: str - contents: List["RunbookContent"] = [] + incidents: List["Incident"] = Relationship( + back_populates="runbooks", link_model=RunbookToIncident + ) provider_type: str provider_id: Optional[str] = None + created_at: datetime = Field(default_factory=datetime.utcnow) -class RunbookContentDto(BaseModel, extra="ignore"): - id: UUID - content: str - link: str - encoding: Optional[str] = None - - @classmethod - def from_orm(cls, content: "RunbookContent") -> "RunbookContentDto": - return cls( - id=content.id, - content=content.content, - link=content.link, - encoding=content.encoding - ) + class Config: + arbitrary_types_allowed = True + + +# Incident Model +class Incident(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + tenant_id: str = Field(foreign_key="tenant.id") + tenant: Tenant = Relationship() -class RunbookDtoOut(RunbookDto): - contents: List[RunbookContentDto] = [] - @classmethod - def from_orm( - cls, runbook: "Runbook" - ) -> "RunbookDtoOut": - return cls( - id=runbook.id, - title=runbook.title, - tenant_id=runbook.tenant_id, - repo_id=runbook.repo_id, - relative_path=runbook.relative_path, - provider_type=runbook.provider_type, - provider_id=runbook.provider_id, - contents=[RunbookContentDto.from_orm(content) for content in runbook.contents] - ) + user_generated_name: Optional[str] = None + ai_generated_name: Optional[str] = None + + user_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) + generated_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) + + assignee: Optional[str] = None + # severity: int = Field(default=IncidentSeverity.CRITICAL.order) + + creation_time: datetime = Field(default_factory=datetime.utcnow) + + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + last_seen_time: Optional[datetime] = None + + runbooks: List["Runbook"] = Relationship( + back_populates="incidents", link_model=RunbookToIncident + ) + + is_predicted: bool = Field(default=False) + is_confirmed: bool = Field(default=False) + + alerts_count: int = Field(default=0) + affected_services: List = Field(sa_column=Column(JSON), default_factory=list) + sources: List = Field(sa_column=Column(JSON), default_factory=list) + + rule_id: Optional[UUID] = Field( + sa_column=Column( + UUID(binary=False), + ForeignKey("rule.id", ondelete="CASCADE"), + nullable=True, + ), + ) + + rule_fingerprint: str = Field(default="", sa_column=Column(TEXT)) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if "runbooks" not in kwargs: + self.runbooks = [] + + class Config: + arbitrary_types_allowed = True diff --git a/keep/api/routes/runbooks.py b/keep/api/routes/runbooks.py index 82ec5292f..b94ee3054 100644 --- a/keep/api/routes/runbooks.py +++ b/keep/api/routes/runbooks.py @@ -7,13 +7,6 @@ from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory from keep.providers.providers_factory import ProvidersFactory from keep.secretmanager.secretmanagerfactory import SecretManagerFactory -from keep.runbooks.runbooks_service import ( - RunbookService - ) -from keep.api.models.db.runbook import ( - RunbookDtoOut - ) - logger = logging.getLogger(__name__) @@ -52,8 +45,8 @@ def get_repositories( return provider.pull_repositories() -@router.get("/{provider_type}/{provider_id}") -def get_runbook( +@router.get("/{provider_type}/{provider_id}/runbook") +def get_repositories( provider_type: str, provider_id: str, authenticated_entity: AuthenticatedEntity = Depends( @@ -63,7 +56,6 @@ def get_runbook( repo: str = Query(None), branch: str = Query(None), md_path: str = Query(None), - title: str = Query(None), ): tenant_id = authenticated_entity.tenant_id logger.info("Getting runbook", extra={"provider_type": provider_type, "provider_id": provider_id}) @@ -84,37 +76,4 @@ def get_runbook( context_manager, provider_id, provider_type, provider_config ) - return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path, title=title) - - -@router.post( - "/{provider_type}/{provider_id}", - description="Create a new Runbook", - # response_model=RunbookDtoOut, -) -def create_runbook( - provider_type: str, - provider_id: str, - authenticated_entity: AuthenticatedEntity = Depends( - IdentityManagerFactory.get_auth_verifier(["write:runbook"]) - ), - session: Session = Depends(get_session), - repo: str = Query(None), - branch: str = Query(None), - md_path: str = Query(None), - title: str = Query(None), -): - tenant_id = authenticated_entity.tenant_id - logger.info("Creating Runbook", extra={tenant_id: tenant_id}) - context_manager = ContextManager(tenant_id=tenant_id) - secret_manager = SecretManagerFactory.get_secret_manager(context_manager) - provider_config = secret_manager.read_secret( - f"{tenant_id}_{provider_type}_{provider_id}", is_json=True - ) - provider = ProvidersFactory.get_provider( - context_manager, provider_id, provider_type, provider_config - ) - - runbook_dto= provider.pull_runbook(repo=repo, branch=branch, md_path=md_path, title=title) - return RunbookService.create_runbook(session, tenant_id, runbook_dto) - + return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path) diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index 18cb6d674..6c8be7e79 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -690,7 +690,7 @@ def pull_topology(self) -> list[TopologyServiceInDto]: class BaseRunBookProvider(BaseProvider): - def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): + def pull_runbook(self, repo=None, branch=None, md_path=None): raise NotImplementedError("get_runbook() method not implemented") def pull_repositories(self): diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 47ccdda61..f89db02a1 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -122,12 +122,13 @@ def pull_repositories(self, project_id=None): repos_list = self._format_repos(repos) return repos_list - def _format_runbook(self, runbook, repo, title): + def _format_runbook(self, runbook, repo): """ Format the runbook data into a dictionary. """ - - # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) + if runbook is None: + return {} + return { "file_name": runbook.name, "file_path": runbook.path, @@ -137,18 +138,15 @@ def _format_runbook(self, runbook, repo, title): "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "github", - "provider_id": self.provider_id, - "contents": [{ - "content":runbook.content, - "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", - "encoding": runbook.encoding - }], - "title": title, + "provider_id": self.config.authentication.get("provider_id"), + "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", + "content": runbook.content, + "encoding": runbook.encoding, } - def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): + def pull_runbook(self, repo=None, branch=None, md_path=None): """Retrieve markdown files from the GitHub repository using the GitHub client.""" repo_name = repo if repo else self.authentication_config.repository @@ -169,7 +167,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): raise Exception(f"Repository {repo_name} not found") runbook = repo.get_contents(md_path, branch) - response = self._format_runbook(runbook, self._format_repo(repo), title) + response = self._format_runbook(runbook, self._format_repo(repo)) return response except GithubException as e: @@ -252,7 +250,7 @@ def _query( }, ) provider = GithubProvider(context_manager, provider_id="github", config=config) - result = provider.pull_runbook(title="test") + result = provider.pull_runbook() result = provider.pull_repositories() print(result) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 114f27b6f..480f5dbff 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -228,12 +228,13 @@ def pull_repositories(self, project_id=None): raise Exception("Failed to get repositories: personal_access_token not set") - def _format_runbook(self, runbook, repo, title): + def _format_runbook(self, runbook, repo): """ Format the runbook data into a dictionary. """ - - # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) + if runbook is None: + return {} + return { "file_name": runbook.get("file_name"), "file_path": runbook.get("file_path"), @@ -243,16 +244,13 @@ def _format_runbook(self, runbook, repo, title): "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "gitlab", - "config": self.provider_id, - "contents": [{ - "content": runbook.get("content"), - "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", - "encoding": runbook.get("encoding"), - }], - "title": title, + "config": self.config.authentication.get("provider_id"), + "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", + "content": runbook.get("content"), + "encoding": runbook.get("encoding"), } - def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): + def pull_runbook(self, repo=None, branch=None, md_path=None): """Retrieve markdown files from the GitLab repository.""" repo = repo if repo else self.authentication_config.repository branch = branch if branch else "main" @@ -272,7 +270,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): except HTTPError as e: raise Exception(f"Failed to get runbook: {e}") - return self._format_runbook(resp.json(), repo_met, title) + return self._format_runbook(resp.json(), repo_meta) raise Exception("Failed to get runbook: repository or md_path not set") @@ -328,6 +326,6 @@ def _notify(self, id: str, title: str, description: str = "", labels: str = "", description="Test Alert Description", ) - result = provider.pull_runbook(title="test") + result = provider.pull_runbook() result = provider.pull_repositories() print(result) diff --git a/keep/runbooks/runbooks_service.py b/keep/runbooks/runbooks_service.py deleted file mode 100644 index 05d13c4a0..000000000 --- a/keep/runbooks/runbooks_service.py +++ /dev/null @@ -1,51 +0,0 @@ -import logging -from typing import List, Optional -from pydantic import ValidationError -from sqlalchemy.orm import joinedload, selectinload -from uuid import UUID -import json - -from sqlmodel import Session, select -from keep.api.models.db.runbook import ( - Runbook, - RunbookContent, - RunbookDtoOut -) -logger = logging.getLogger(__name__) - - -class RunbookService: - @staticmethod - def create_runbook(session: Session, tenant_id: str, runbook_dto: dict): - try: - new_runbook = Runbook( - tenant_id=tenant_id, - title=runbook_dto["title"], - repo_id=runbook_dto["repo_id"], - relative_path=runbook_dto["file_path"], - provider_type=runbook_dto["provider_type"], - provider_id=runbook_dto["provider_id"] - ) - - session.add(new_runbook) - session.flush() - contents = runbook_dto["contents"] if runbook_dto["contents"] else [] - - new_contents = [ - RunbookContent( - runbook_id=new_runbook.id, - content=content["content"], - link=content["link"], - encoding=content["encoding"] - ) - for content in contents - ] - - session.add_all(new_contents) - session.commit() - session.expire(new_runbook, ["contents"]) - session.refresh(new_runbook) # Refresh the runbook instance - result = RunbookDtoOut.from_orm(new_runbook) - return result - except ValidationError as e: - logger.exception(f"Failed to create runbook {e}") \ No newline at end of file From 061c6d63bdfb38c777b1f397e9fe2df30f9fccd7 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 04:10:05 +0530 Subject: [PATCH 14/40] feat: add runbook schema and show real runbook data. to do: incident link needs to be done --- keep-ui/app/runbooks/runbook-table.tsx | 88 ++++++----- keep-ui/utils/hooks/useRunbook.ts | 27 +++- keep/api/models/db/migrations/env.py | 1 + .../versions/2024-10-08-22-23_0796be640663.py | 54 +++++++ keep/api/models/db/runbook.py | 146 +++++++++--------- keep/api/routes/providers.py | 2 +- keep/api/routes/runbooks.py | 60 ++++++- keep/providers/base/base_provider.py | 2 +- .../github_provider/github_provider.py | 25 +-- .../gitlab_provider/gitlab_provider.py | 24 +-- keep/runbooks/runbooks_service.py | 60 +++++++ 11 files changed, 333 insertions(+), 156 deletions(-) create mode 100644 keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py create mode 100644 keep/runbooks/runbooks_service.py diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index cee7353b6..01d7d1333 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -17,6 +17,10 @@ import { useRunBookTriggers } from "utils/hooks/useRunbook"; import { useForm, get } from "react-hook-form"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { getApiURL } from "@/utils/apiUrl"; +import useSWR from "swr"; +import { fetcher } from "@/utils/fetcher"; +import { useSession } from "next-auth/react"; const customStyles = { content: { @@ -30,67 +34,49 @@ const customStyles = { }, }; -interface Incident { - id: number; - name: string; +interface Content { + id: string; + content: string; + link: string; + encoding: string|null; + file_name: string; } - -interface Runbook { +interface RunbookV2 { id: number; title: string; - incidents: Incident[]; + contents:Content[], + provider_type: string, + provider_id: string + repo_id: string + file_path: string } -const runbookData = [ - { - id: 1, - title: "Database Recovery", - incidents: [ - { id: 101, name: "DB Outage on 2024-01-01" }, - { id: 102, name: "DB Backup Failure" }, - ], - }, - { - id: 2, - title: "API Health Check", - incidents: [{ id: 201, name: "API Latency Issue" }], - }, - { - id: 3, - title: "Server Restart Guide", - incidents: [ - { id: 301, name: "Unexpected Server Crash" }, - { id: 302, name: "Scheduled Maintenance" }, - ], - }, -] as Runbook[]; - -const columnHelper = createColumnHelper(); +const columnHelperv2 = createColumnHelper(); -const columns = [ - columnHelper.display({ +const columnsv2 = [ + columnHelperv2.display({ id: "title", header: "Runbook Title", cell: ({ row }) => { return
    {row.original.title}
    ; }, }), - columnHelper.display({ - id: "incidents", - header: "Incdients", + columnHelperv2.display({ + id: "contents", + header: "Contents", cell: ({ row }) => { return (
    - {row.original.incidents?.map((incident: Incident) => ( - - {incident.name} + {row.original.contents?.map((content: Content) => ( + + {content.file_name} ))}
    ); }, }), -] as DisplayColumnDef[]; +] as DisplayColumnDef[]; // function PreviewContent({type, data}:{type:string, data:string}){ // const [isModalOpen, setIsModalOpen] = useState(open); @@ -230,6 +216,7 @@ function SettingsPage() { @@ -265,6 +252,17 @@ function SettingsPage() { function RunbookIncidentTable() { const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(10); + const { data: session, status } = useSession(); + + + let shouldFetch = session?.accessToken ? true : false; + + const { data: runbooksData, error } = useSWR( + shouldFetch ? `${getApiURL()}/runbooks` : null, + (url:string) => { + return fetcher(url, session?.accessToken!); + }, + ); // Modal state management @@ -280,17 +278,17 @@ function RunbookIncidentTable() { - - data={runbookData} - columns={columns} - rowCount={runbookData.length} + {runbooksData && + data={runbooksData} + columns={columnsv2} + rowCount={runbooksData.length} offset={offset} limit={limit} onPaginationChange={handlePaginationChange} onRowClick={(row) => { console.log("Runbook clicked:", row); }} - /> + />} ); diff --git a/keep-ui/utils/hooks/useRunbook.ts b/keep-ui/utils/hooks/useRunbook.ts index 37e0e1939..e7ba057ba 100644 --- a/keep-ui/utils/hooks/useRunbook.ts +++ b/keep-ui/utils/hooks/useRunbook.ts @@ -55,7 +55,7 @@ export const useRunBookTriggers = (values: any, refresh: number) => { }, [refresh]); const handleSubmit = async (data: any) => { - const { pathToMdFile, repoName } = data; + const { pathToMdFile, repoName, runBookTitle } = data; try { if(!provider){ return toast("Please select a provider"); @@ -67,18 +67,31 @@ export const useRunBookTriggers = (values: any, refresh: number) => { if (repoName) { params.append("repo", repoName); } + if(runBookTitle){ + params.append("title", runBookTitle); + + } //TO DO backend runbook records needs to be created. - const response = await fetcher( - `${baseApiurl}/runbooks/${provider?.type}/${ + const response = await fetch(`${baseApiurl}/runbooks/${provider?.type}/${ provider?.id - }/runbook?${params.toString()}`, - session?.accessToken - ); + }?${params.toString()}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); if (!response) { return setError("Something went wrong. try agian after some time"); } - setFileData(response); + + if(!response.ok) { + return setError("Something went wrong. try agian after some time"); + + } + const result = await response.json(); + setFileData(result); setSynced(false); } catch (err) { return setError("Something went wrong. try agian after some time"); diff --git a/keep/api/models/db/migrations/env.py b/keep/api/models/db/migrations/env.py index 6aab4afad..912a921b9 100644 --- a/keep/api/models/db/migrations/env.py +++ b/keep/api/models/db/migrations/env.py @@ -21,6 +21,7 @@ from keep.api.models.db.topology import * from keep.api.models.db.user import * from keep.api.models.db.workflow import * +from keep.api.models.db.runbook import * target_metadata = SQLModel.metadata diff --git a/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py b/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py new file mode 100644 index 000000000..80543f748 --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py @@ -0,0 +1,54 @@ +"""Add runbook tables + +Revision ID: 0796be640663 +Revises: 017d759805d9 +Create Date: 2024-10-08 22:23:47.905179 + +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0796be640663" +down_revision = "017d759805d9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "runbook", + sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("relative_path", sa.Text(), nullable=True), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("repo_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("provider_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("provider_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenant.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "runbookcontent", + sa.Column("runbook_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("link", sa.Text(), nullable=True), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("encoding", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("file_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["runbook_id"], ["runbook.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("runbookcontent") + op.drop_table("runbook") + # ### end Alembic commands ### diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index b0f577210..7302b2c70 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -1,93 +1,85 @@ +from uuid import UUID, uuid4 from datetime import datetime from typing import List, Optional -from uuid import UUID, uuid4 - -from sqlalchemy import ForeignKey, Column, TEXT, JSON from sqlmodel import Field, Relationship, SQLModel -from keep.api.models.db.tenant import Tenant +from sqlalchemy import Column, ForeignKey, Text +from pydantic import BaseModel -# Link Model between Runbook and Incident -class RunbookToIncident(SQLModel, table=True): - tenant_id: str = Field(foreign_key="tenant.id") - runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) - incident_id: UUID = Field(foreign_key="incident.id", primary_key=True) - incident_id: UUID = Field( - sa_column=Column( - UUID(binary=False), - ForeignKey("incident.id", ondelete="CASCADE"), - primary_key=True, - ) +# RunbookContent Model +class RunbookContent(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + runbook_id: UUID = Field( + sa_column=Column(ForeignKey("runbook.id", ondelete="CASCADE")) # Foreign key with CASCADE delete ) + runbook: Optional["Runbook"] = Relationship(back_populates="contents") + content: str = Field(sa_column=Column(Text), nullable=False) # Using SQLAlchemy's Text type + link: str = Field(sa_column=Column(Text), nullable=False) # Using SQLAlchemy's Text type + encoding: Optional[str] = None + file_name: str + created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation + class Config: + orm_mode = True # Runbook Model class Runbook(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) - tenant_id: str = Field(foreign_key="tenant.id") - tenant: Tenant = Relationship() - repo_id: str = Field(nullable=False) # Github repo id - relative_path: str = Field(nullable=False) # Relative path to the .md file - title: str = Field(nullable=False) # Title of the runbook - link: str = Field(nullable=False) # Link to the .md file - - incidents: List["Incident"] = Relationship( - back_populates="runbooks", link_model=RunbookToIncident + tenant_id: str = Field( + sa_column=Column(ForeignKey("tenant.id", ondelete="CASCADE")) # Foreign key with CASCADE delete ) - provider_type: str - provider_id: Optional[str] = None - created_at: datetime = Field(default_factory=datetime.utcnow) + repo_id: str # Repository ID + relative_path: str = Field(sa_column=Column(Text), nullable=False) # Path in the repo, must be set + title: str = Field(sa_column=Column(Text), nullable=False) # Title of the runbook, must be set + contents: List["RunbookContent"] = Relationship(back_populates="runbook") # Relationship to RunbookContent + provider_type: str # Type of the provider + provider_id: Optional[str] = None # Optional provider ID + created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation class Config: - arbitrary_types_allowed = True - - -# Incident Model -class Incident(SQLModel, table=True): - id: UUID = Field(default_factory=uuid4, primary_key=True) - tenant_id: str = Field(foreign_key="tenant.id") - tenant: Tenant = Relationship() - - user_generated_name: Optional[str] = None - ai_generated_name: Optional[str] = None - - user_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) - generated_summary: Optional[str] = Field(sa_column=Column(TEXT), nullable=True) - - assignee: Optional[str] = None - # severity: int = Field(default=IncidentSeverity.CRITICAL.order) - - creation_time: datetime = Field(default_factory=datetime.utcnow) - - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - last_seen_time: Optional[datetime] = None - - runbooks: List["Runbook"] = Relationship( - back_populates="incidents", link_model=RunbookToIncident - ) - - is_predicted: bool = Field(default=False) - is_confirmed: bool = Field(default=False) - - alerts_count: int = Field(default=0) - affected_services: List = Field(sa_column=Column(JSON), default_factory=list) - sources: List = Field(sa_column=Column(JSON), default_factory=list) - - rule_id: Optional[UUID] = Field( - sa_column=Column( - UUID(binary=False), - ForeignKey("rule.id", ondelete="CASCADE"), - nullable=True, - ), - ) - - rule_fingerprint: str = Field(default="", sa_column=Column(TEXT)) + orm_mode = True # Enable ORM mode for compatibility with Pydantic models + + +class RunbookDto(BaseModel, extra="ignore"): + id: UUID + tenant_id: str + repo_id: str + relative_path: str + title: str + contents: List["RunbookContent"] = [] + provider_type: str + provider_id: Optional[str] = None - def __init__(self, **kwargs): - super().__init__(**kwargs) - if "runbooks" not in kwargs: - self.runbooks = [] +class RunbookContentDto(BaseModel, extra="ignore"): + id: UUID + content: str + link: str + file_name: str + encoding: Optional[str] = None + + @classmethod + def from_orm(cls, content: "RunbookContent") -> "RunbookContentDto": + return cls( + id=content.id, + content=content.content, + link=content.link, + encoding=content.encoding, + file_name=content.file_name + ) - class Config: - arbitrary_types_allowed = True +class RunbookDtoOut(RunbookDto): + contents: List[RunbookContentDto] = [] + @classmethod + def from_orm( + cls, runbook: "Runbook" + ) -> "RunbookDtoOut": + return cls( + id=runbook.id, + title=runbook.title, + tenant_id=runbook.tenant_id, + repo_id=runbook.repo_id, + relative_path=runbook.relative_path, + provider_type=runbook.provider_type, + provider_id=runbook.provider_id, + contents=[RunbookContentDto.from_orm(content) for content in runbook.contents] + ) diff --git a/keep/api/routes/providers.py b/keep/api/routes/providers.py index 934ab5547..607a1e77e 100644 --- a/keep/api/routes/providers.py +++ b/keep/api/routes/providers.py @@ -5,7 +5,7 @@ import uuid from typing import Callable, Optional -from fastapi import APIRouter, Body, Depends, HTTPException, Request, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import JSONResponse from sqlmodel import Session, select from starlette.datastructures import UploadFile diff --git a/keep/api/routes/runbooks.py b/keep/api/routes/runbooks.py index b94ee3054..17c631cbd 100644 --- a/keep/api/routes/runbooks.py +++ b/keep/api/routes/runbooks.py @@ -7,6 +7,9 @@ from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory from keep.providers.providers_factory import ProvidersFactory from keep.secretmanager.secretmanagerfactory import SecretManagerFactory +from keep.runbooks.runbooks_service import ( + RunbookService + ) logger = logging.getLogger(__name__) @@ -45,8 +48,8 @@ def get_repositories( return provider.pull_repositories() -@router.get("/{provider_type}/{provider_id}/runbook") -def get_repositories( +@router.get("/{provider_type}/{provider_id}") +def get_runbook( provider_type: str, provider_id: str, authenticated_entity: AuthenticatedEntity = Depends( @@ -56,6 +59,7 @@ def get_repositories( repo: str = Query(None), branch: str = Query(None), md_path: str = Query(None), + title: str = Query(None), ): tenant_id = authenticated_entity.tenant_id logger.info("Getting runbook", extra={"provider_type": provider_type, "provider_id": provider_id}) @@ -76,4 +80,54 @@ def get_repositories( context_manager, provider_id, provider_type, provider_config ) - return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path) + return provider.pull_runbook(repo=repo, branch=branch, md_path=md_path, title=title) + + +@router.post( + "/{provider_type}/{provider_id}", + description="Create a new Runbook", + # response_model=RunbookDtoOut, +) +def create_runbook( + provider_type: str, + provider_id: str, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["write:runbook"]) + ), + session: Session = Depends(get_session), + repo: str = Query(None), + branch: str = Query(None), + md_path: str = Query(None), + title: str = Query(None), +): + tenant_id = authenticated_entity.tenant_id + logger.info("Creating Runbook", extra={tenant_id: tenant_id}) + context_manager = ContextManager(tenant_id=tenant_id) + secret_manager = SecretManagerFactory.get_secret_manager(context_manager) + provider_config = secret_manager.read_secret( + f"{tenant_id}_{provider_type}_{provider_id}", is_json=True + ) + provider = ProvidersFactory.get_provider( + context_manager, provider_id, provider_type, provider_config + ) + + runbook_dto= provider.pull_runbook(repo=repo, branch=branch, md_path=md_path, title=title) + return RunbookService.create_runbook(session, tenant_id, runbook_dto) + + +@router.get( + "", + description="All Runbooks", + # response_model=RunbookDtoOut, +) +def create_runbook( + + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:runbook"]) + ), + session: Session = Depends(get_session) +): + tenant_id = authenticated_entity.tenant_id + logger.info("get all Runbooks", extra={tenant_id: tenant_id}) + return RunbookService.get_all_runbooks(session, tenant_id) + diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index 6c8be7e79..18cb6d674 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -690,7 +690,7 @@ def pull_topology(self) -> list[TopologyServiceInDto]: class BaseRunBookProvider(BaseProvider): - def pull_runbook(self, repo=None, branch=None, md_path=None): + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): raise NotImplementedError("get_runbook() method not implemented") def pull_repositories(self): diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index f89db02a1..4f2b628ae 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -122,13 +122,12 @@ def pull_repositories(self, project_id=None): repos_list = self._format_repos(repos) return repos_list - def _format_runbook(self, runbook, repo): + def _format_runbook(self, runbook, repo, title): """ Format the runbook data into a dictionary. """ - if runbook is None: - return {} - + + # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) return { "file_name": runbook.name, "file_path": runbook.path, @@ -138,15 +137,19 @@ def _format_runbook(self, runbook, repo): "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "github", - "provider_id": self.config.authentication.get("provider_id"), - "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", - "content": runbook.content, - "encoding": runbook.encoding, + "provider_id": self.provider_id, + "contents": [{ + "content":runbook.content, + "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", + "encoding": runbook.encoding, + "file_name": runbook.name + }], + "title": title, } - def pull_runbook(self, repo=None, branch=None, md_path=None): + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): """Retrieve markdown files from the GitHub repository using the GitHub client.""" repo_name = repo if repo else self.authentication_config.repository @@ -167,7 +170,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None): raise Exception(f"Repository {repo_name} not found") runbook = repo.get_contents(md_path, branch) - response = self._format_runbook(runbook, self._format_repo(repo)) + response = self._format_runbook(runbook, self._format_repo(repo), title) return response except GithubException as e: @@ -250,7 +253,7 @@ def _query( }, ) provider = GithubProvider(context_manager, provider_id="github", config=config) - result = provider.pull_runbook() + result = provider.pull_runbook(title="test") result = provider.pull_repositories() print(result) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 480f5dbff..563195b80 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -228,13 +228,12 @@ def pull_repositories(self, project_id=None): raise Exception("Failed to get repositories: personal_access_token not set") - def _format_runbook(self, runbook, repo): + def _format_runbook(self, runbook, repo, title): """ Format the runbook data into a dictionary. """ - if runbook is None: - return {} - + + # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) return { "file_name": runbook.get("file_name"), "file_path": runbook.get("file_path"), @@ -244,13 +243,16 @@ def _format_runbook(self, runbook, repo): "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "gitlab", - "config": self.config.authentication.get("provider_id"), - "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", - "content": runbook.get("content"), - "encoding": runbook.get("encoding"), + "config": self.provider_id, + "contents": [{ + "content": runbook.get("content"), + "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", + "encoding": runbook.get("encoding"), + }], + "title": title, } - def pull_runbook(self, repo=None, branch=None, md_path=None): + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): """Retrieve markdown files from the GitLab repository.""" repo = repo if repo else self.authentication_config.repository branch = branch if branch else "main" @@ -270,7 +272,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None): except HTTPError as e: raise Exception(f"Failed to get runbook: {e}") - return self._format_runbook(resp.json(), repo_meta) + return self._format_runbook(resp.json(), repo_meta, title) raise Exception("Failed to get runbook: repository or md_path not set") @@ -326,6 +328,6 @@ def _notify(self, id: str, title: str, description: str = "", labels: str = "", description="Test Alert Description", ) - result = provider.pull_runbook() + result = provider.pull_runbook(title="test") result = provider.pull_repositories() print(result) diff --git a/keep/runbooks/runbooks_service.py b/keep/runbooks/runbooks_service.py new file mode 100644 index 000000000..9473bb4a7 --- /dev/null +++ b/keep/runbooks/runbooks_service.py @@ -0,0 +1,60 @@ +import logging +from typing import List +from pydantic import ValidationError +from sqlalchemy.orm import selectinload + +from sqlmodel import Session, select +from keep.api.models.db.runbook import ( + Runbook, + RunbookContent, + RunbookDtoOut +) +logger = logging.getLogger(__name__) + + +class RunbookService: + @staticmethod + def create_runbook(session: Session, tenant_id: str, runbook_dto: dict): + try: + new_runbook = Runbook( + tenant_id=tenant_id, + title=runbook_dto["title"], + repo_id=runbook_dto["repo_id"], + relative_path=runbook_dto["file_path"], + provider_type=runbook_dto["provider_type"], + provider_id=runbook_dto["provider_id"] + ) + + session.add(new_runbook) + session.flush() + contents = runbook_dto["contents"] if runbook_dto["contents"] else [] + + new_contents = [ + RunbookContent( + runbook_id=new_runbook.id, + content=content["content"], + link=content["link"], + encoding=content["encoding"], + file_name=content["file_name"] + ) + for content in contents + ] + + session.add_all(new_contents) + session.commit() + session.expire(new_runbook, ["contents"]) + session.refresh(new_runbook) # Refresh the runbook instance + result = RunbookDtoOut.from_orm(new_runbook) + return result + except ValidationError as e: + logger.exception(f"Failed to create runbook {e}") + + @staticmethod + def get_all_runbooks(session: Session, tenant_id: str) -> List[RunbookDtoOut]: + runbooks = session.exec( + select(Runbook) + .where(Runbook.tenant_id == tenant_id) + .options(selectinload(Runbook.contents)).limit(1000) + ) + + return [RunbookDtoOut.from_orm(runbook) for runbook in runbooks] \ No newline at end of file From 5130224d0fe43331ca951c5e2091c298c79f3617 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 16:17:36 +0530 Subject: [PATCH 15/40] feat: add realtion link between runbook and incident --- keep-ui/app/runbooks/models.ts | 16 ++ keep-ui/app/runbooks/runbook-actions.tsx | 57 +++++ .../runbook-associate-incident-modal.tsx | 195 ++++++++++++++++++ keep-ui/app/runbooks/runbook-table.tsx | 63 ++++-- keep-ui/components/table/GenericTable.tsx | 70 ++++++- keep/api/core/db.py | 141 ++++++++++++- keep/api/models/db/alert.py | 6 + .../versions/2024-10-09-08-55_de84655df741.py | 69 +++++++ keep/api/models/db/runbook.py | 56 ++++- keep/api/routes/incidents.py | 75 +++++++ 10 files changed, 722 insertions(+), 26 deletions(-) create mode 100644 keep-ui/app/runbooks/models.ts create mode 100644 keep-ui/app/runbooks/runbook-actions.tsx create mode 100644 keep-ui/app/runbooks/runbook-associate-incident-modal.tsx create mode 100644 keep/api/models/db/migrations/versions/2024-10-09-08-55_de84655df741.py diff --git a/keep-ui/app/runbooks/models.ts b/keep-ui/app/runbooks/models.ts new file mode 100644 index 000000000..fbb82cfb0 --- /dev/null +++ b/keep-ui/app/runbooks/models.ts @@ -0,0 +1,16 @@ +export interface ContentDto { + id: string; + content: string; + link: string; + encoding: string | null; + file_name: string; +} +export interface RunbookDto { + id: number; + title: string; + contents: ContentDto[]; + provider_type: string; + provider_id: string; + repo_id: string; + file_path: string; +} diff --git a/keep-ui/app/runbooks/runbook-actions.tsx b/keep-ui/app/runbooks/runbook-actions.tsx new file mode 100644 index 000000000..ab02d4b63 --- /dev/null +++ b/keep-ui/app/runbooks/runbook-actions.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Button } from "@tremor/react"; +import { RunbookDto } from "./models"; +import { PlusIcon } from "@radix-ui/react-icons"; +import RunbookAssociateIncidentModal from "./runbook-associate-incident-modal"; + +interface Props { + selectedRowIds: string[]; + runbooks: RunbookDto[]; + clearRowSelection: () => void; +} + +export default function RunbookActions({ + selectedRowIds, + runbooks, + clearRowSelection, +}: Props) { + const [isIncidentSelectorOpen, setIsIncidentSelectorOpen] = + useState(false); + + const selectedRunbooks = runbooks.filter((_runbook, index) => + selectedRowIds.includes(index.toString()) + ); + + const showIncidentSelector = () => { + setIsIncidentSelectorOpen(true); + }; + const hideIncidentSelector = () => { + setIsIncidentSelectorOpen(false); + }; + + const handleSuccessfulRunbooksAssociation = () => { + hideIncidentSelector(); + clearRowSelection(); + }; + + return ( +
    + + +
    + ); +} diff --git a/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx b/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx new file mode 100644 index 000000000..fd35617fc --- /dev/null +++ b/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx @@ -0,0 +1,195 @@ +import Modal from "@/components/ui/Modal"; +import { Button, Divider, SelectItem, Title } from "@tremor/react"; +import Select from "@/components/ui/Select"; +import CreateOrUpdateIncident from "app/incidents/create-or-update-incident"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import { getApiURL } from "../../utils/apiUrl"; +import { useIncidents, usePollIncidents } from "../../utils/hooks/useIncidents"; +import Loading from "../loading"; +import { RunbookDto } from "./models"; + +interface AlertAssociateIncidentModalProps { + isOpen: boolean; + handleSuccess: () => void; + handleClose: () => void; + runbooks: Array; +} + +const RunbookAssociateIncidentModal = ({ + isOpen, + handleSuccess, + handleClose, + runbooks, +}: AlertAssociateIncidentModalProps) => { + const [createIncident, setCreateIncident] = useState(false); + + const { data: incidents, isLoading, mutate } = useIncidents(true, 100); + usePollIncidents(mutate); + + const [selectedIncident, setSelectedIncident] = useState< + string | undefined + >(); + // get the token + const { data: session } = useSession(); + const router = useRouter(); + console.log("Associating runbooks is outside", runbooks.map(({ id }) => id)); + + const associateRunbooksHandler = async (incidentId: string) => { + const apiUrl = getApiURL(); + console.log("Associating runbooks with incident", incidentId); + console.log("Associating runbooks is", runbooks.map(({ id }) => id)); + console.log("Associating session?.accessToken", session?.accessToken); + + const response = await fetch(`${apiUrl}/incidents/${incidentId}/runbooks`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(runbooks.map(({ id }) => id)), + }); + if (response.ok) { + handleSuccess(); + await mutate(); + toast.success("Runbooks associated with incident successfully"); + } else { + toast.error( + "Failed to associated runbooks with incident, please contact us if this issue persists." + ); + } + }; + + const handleAssociateRunbooks = (e: FormEvent) => { + e.preventDefault(); + if (selectedIncident) associateRunbooksHandler(selectedIncident); + }; + + const showCreateIncidentForm = useCallback(() => setCreateIncident(true), []); + + const hideCreateIncidentForm = useCallback( + () => setCreateIncident(false), + [] + ); + + const onIncidentCreated = useCallback( + (incidentId: string) => { + hideCreateIncidentForm(); + handleClose(); + associateRunbooksHandler(incidentId); + }, + [associateRunbooksHandler, handleClose, hideCreateIncidentForm] + ); + + // reset modal state after closing + useEffect(() => { + if (!isOpen) { + hideCreateIncidentForm(); + setSelectedIncident(undefined); + } + }, [hideCreateIncidentForm, isOpen]); + + // if this modal should not be open, do nothing + if (!runbooks) return null; + + return ( + +
    + {isLoading ? ( + + ) : createIncident ? ( + + ) : incidents && incidents.items.length > 0 ? ( +
    + + ); + } + + export function GenericTable({ data, columns, @@ -36,6 +73,8 @@ export function GenericTable({ limit, onPaginationChange, onRowClick, + getActions, + isRowSelectable = false, }: GenericTableProps) { const [expanded, setExpanded] = useState({}); const [pagination, setPagination] = useState({ @@ -60,6 +99,26 @@ export function GenericTable({ } }, [pagination]); + if(isRowSelectable && !!data.length) { + columns = [{ + id: 'select-col', + header: ({ table }) => ( + + + ), + cell: ({ row }) => ( + + ), + }, ...columns] + } const table = useReactTable({ columns, data, @@ -72,11 +131,20 @@ export function GenericTable({ setPagination(nextPagination); }, onExpandedChange: setExpanded, + enableRowSelection: !!data.length && true, + enableMultiRowSelection: true, }); + const selectedRowIds = Object.entries( + table.getSelectedRowModel().rowsById + ).reduce((acc, [id]) => { + return acc.concat(id); + }, []); + return (
    + {!!selectedRowIds.length && getActions &&
    {getActions(table, selectedRowIds)}
    } {table.getHeaderGroups().map((headerGroup) => ( diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 901c6f7bd..09305c731 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -45,6 +45,7 @@ from keep.api.models.db.tenant import * # pylint: disable=unused-wildcard-import from keep.api.models.db.topology import * # pylint: disable=unused-wildcard-import from keep.api.models.db.workflow import * # pylint: disable=unused-wildcard-import +from keep.api.models.db.runbook import * # pylint: disable=unused-wildcard-import logger = logging.getLogger(__name__) @@ -3455,4 +3456,142 @@ def is_edge_incident_alert_resolved(incident: Incident, direction: Callable, ses return ( enriched_status == AlertStatus.RESOLVED.value or (enriched_status is None and status == AlertStatus.RESOLVED.value) - ) \ No newline at end of file + ) + +def get_runbooks_data_for_incident( + runbook_ids: list[str | UUID], session: Optional[Session] = None +) -> dict: + """ + Function to prepare aggregated data for incidents from the given list of runbook_ids + Logic is wrapped to the inner function for better usability with an optional database session + + Args: + runbook_ids (list[str | UUID]): list of runbook ids for aggregation + session (Optional[Session]): The database session or None + + Returns: dict {sources: list[str], services: list[str], count: int} + """ + + with existed_or_new_session(session) as session: + + runbooks_data = session.exec( + select(Runbook.provider_type).where( + col(Runbook.id).in_(runbook_ids), + ) + ).all() + + sources = [] + services = [] + severities = [] + + for provider_type in runbooks_data: + if provider_type: + sources.append(provider_type) + + return { + "sources": set(sources), + "services": set(services), + "max_severity": None, + "count": len(runbooks_data), + } + +def add_runbooks_to_incident( + tenant_id: str, + incident: Incident, + runbook_ids: List[UUID], + session: Optional[Session] = None, +) -> Optional[Incident]: + logger.info( + f"Adding runbooks to incident {incident.id} in database, total {len(runbook_ids)} runbooks", + extra={"tags": {"tenant_id": tenant_id, "incident_id": incident.id}}, + ) + + with existed_or_new_session(session) as session: + with session.no_autoflush: + # Use a set for faster membership checks + existing_runbook_ids = set( + session.exec( + select(RunbookToIncident.runbook_id).where( + RunbookToIncident.tenant_id == tenant_id, + RunbookToIncident.incident_id == incident.id, + col(RunbookToIncident.runbook_id).in_(runbook_ids), + ) + ).all() + ) + + new_runbook_ids = [ + runbook_id for runbook_id in runbook_ids if runbook_id not in existing_runbook_ids + ] + + if not new_runbook_ids: + return incident + + runbooks_data_for_incident = get_runbooks_data_for_incident(new_runbook_ids, session) + + incident.sources = list( + set(incident.sources) | set(runbooks_data_for_incident["sources"]) + ) + incident.affected_services = list( + set(incident.affected_services) | set(runbooks_data_for_incident["services"]) + ) + incident.runbooks_count += runbooks_data_for_incident["count"] + # incident.alerts_count += runbooks_data_for_incident["count"] + + runbook_to_incident_entries = [ + RunbookToIncident( + runbook_id=runbook_id, incident_id=incident.id, tenant_id=tenant_id + ) + for runbook_id in new_runbook_ids + ] + + for idx, entry in enumerate(runbook_to_incident_entries): + session.add(entry) + if (idx + 1) % 100 == 0: + logger.info( + f"Added {idx + 1}/{len(runbook_to_incident_entries)} runbooks to incident {incident.id} in database", + extra={ + "tags": {"tenant_id": tenant_id, "incident_id": incident.id} + }, + ) + session.flush() + session.commit() + + started_at, last_seen_at = session.exec( + select(func.min(Runbook.timestamp), func.max(Runbook.timestamp)) + .join(RunbookToIncident, RunbookToIncident.runbook_id == Runbook.id) + .where( + RunbookToIncident.tenant_id == tenant_id, + RunbookToIncident.incident_id == incident.id, + ) + ).one() + + max_severity = runbooks_data_for_incident["max_severity"].order if runbooks_data_for_incident["max_severity"] else 2 + incident.start_time = started_at + incident.last_seen_time = last_seen_at + incident.severity = max_severity + + session.add(incident) + session.commit() + session.refresh(incident) + logger.info( + f"Added runbooks to incident {incident.id} in database {incident}", + ) + return incident + + +def add_runbooks_to_incident_by_incident_id( + tenant_id: str, + incident_id: str | UUID, + runbook_ids: List[UUID], + session: Optional[Session] = None, +) -> Optional[Incident]: + with existed_or_new_session(session) as session: + query = select(Incident).where( + Incident.tenant_id == tenant_id, + Incident.id == incident_id, + ) + incident = session.exec(query).first() + + if not incident: + return None + return add_runbooks_to_incident(tenant_id, incident, runbook_ids, session) \ No newline at end of file diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py index 769777adf..af3d0ac2d 100644 --- a/keep/api/models/db/alert.py +++ b/keep/api/models/db/alert.py @@ -15,6 +15,7 @@ from keep.api.core.config import config from keep.api.models.alert import IncidentSeverity, IncidentStatus from keep.api.models.db.tenant import Tenant +from keep.api.models.db.runbook import RunbookToIncident db_connection_string = config("DATABASE_CONNECTION_STRING", default=None) logger = logging.getLogger(__name__) @@ -84,10 +85,15 @@ class Incident(SQLModel, table=True): back_populates="incidents", link_model=AlertToIncident ) + runbooks: List["Runbook"] = Relationship( + back_populates="incidents", link_model=RunbookToIncident + ) + is_predicted: bool = Field(default=False) is_confirmed: bool = Field(default=False) alerts_count: int = Field(default=0) + runbooks_count: int = Field(default=0) affected_services: list = Field(sa_column=Column(JSON), default_factory=list) sources: list = Field(sa_column=Column(JSON), default_factory=list) diff --git a/keep/api/models/db/migrations/versions/2024-10-09-08-55_de84655df741.py b/keep/api/models/db/migrations/versions/2024-10-09-08-55_de84655df741.py new file mode 100644 index 000000000..63a13b8c9 --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-10-09-08-55_de84655df741.py @@ -0,0 +1,69 @@ +"""Add incidents link to runbook and timestamp + +Revision ID: de84655df741 +Revises: 0796be640663 +Create Date: 2024-10-09 08:55:40.216359 + +""" + +import sqlalchemy as sa +import sqlalchemy_utils +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "de84655df741" +down_revision = "0796be640663" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "runbooktoincident", + sa.Column( + "incident_id", + sqlalchemy_utils.types.uuid.UUIDType(binary=False), + nullable=False, + ), + sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("runbook_id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["incident_id"], ["incident.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["runbook_id"], + ["runbook.id"], + ), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenant.id"], + ), + sa.PrimaryKeyConstraint("incident_id", "runbook_id"), + ) + + with op.batch_alter_table("incident", schema=None) as batch_op: + batch_op.add_column(sa.Column("runbooks_count", sa.Integer(), nullable=False)) + + + with op.batch_alter_table("runbook", schema=None) as batch_op: + batch_op.add_column(sa.Column("timestamp", sa.DateTime(), nullable=True)) + batch_op.create_index( + batch_op.f("ix_runbook_timestamp"), ["timestamp"], unique=False + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("runbook", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_runbook_timestamp")) + batch_op.drop_column("timestamp") + + + with op.batch_alter_table("incident", schema=None) as batch_op: + batch_op.drop_column("runbooks_count") + + op.drop_table("runbooktoincident") + # ### end Alembic commands ### diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index 7302b2c70..e60b3df61 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -1,11 +1,55 @@ +import logging from uuid import UUID, uuid4 from datetime import datetime from typing import List, Optional -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import Field, Relationship, SQLModel, DateTime from sqlalchemy import Column, ForeignKey, Text from pydantic import BaseModel +from sqlalchemy_utils import UUIDType +from keep.api.core.config import config +from sqlalchemy.dialects.mysql import DATETIME as MySQL_DATETIME +from sqlalchemy.dialects.mssql import DATETIME2 as MSSQL_DATETIME2 +from keep.api.consts import RUNNING_IN_CLOUD_RUN +db_connection_string = config("DATABASE_CONNECTION_STRING", default=None) +logger = logging.getLogger(__name__) +# managed (mysql) +if RUNNING_IN_CLOUD_RUN or db_connection_string == "impersonate": + # Millisecond precision + datetime_column_type = MySQL_DATETIME(fsp=3) +# self hosted (mysql, sql server, sqlite / postgres) +else: + try: + url = make_url(db_connection_string) + dialect = url.get_dialect().name + if dialect == "mssql": + # Millisecond precision + datetime_column_type = MSSQL_DATETIME2(precision=3) + elif dialect == "mysql": + # Millisecond precision + datetime_column_type = MySQL_DATETIME(fsp=3) + else: + datetime_column_type = DateTime + except Exception: + logger.warning( + "Could not determine the database dialect, falling back to default datetime column type" + ) + # give it a default + datetime_column_type = DateTime + +class RunbookToIncident(SQLModel, table=True): + tenant_id: str = Field(foreign_key="tenant.id") + runbook_id: UUID = Field(foreign_key="runbook.id", primary_key=True) + timestamp: datetime = Field(default_factory=datetime.utcnow) + incident_id: UUID = Field( + sa_column=Column( + UUIDType(binary=False), + ForeignKey("incident.id", ondelete="CASCADE"), + primary_key=True, + ) + ) + # RunbookContent Model class RunbookContent(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -34,8 +78,16 @@ class Runbook(SQLModel, table=True): contents: List["RunbookContent"] = Relationship(back_populates="runbook") # Relationship to RunbookContent provider_type: str # Type of the provider provider_id: Optional[str] = None # Optional provider ID + incidents: List["Incident"] = Relationship( + back_populates="runbooks", link_model=RunbookToIncident + ) created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation - + timestamp: datetime = Field( + sa_column=Column(datetime_column_type, index=True), + default_factory=lambda: datetime.utcnow().replace( + microsecond=int(datetime.utcnow().microsecond / 1000) * 1000 + ), + ) class Config: orm_mode = True # Enable ORM mode for compatibility with Pydantic models diff --git a/keep/api/routes/incidents.py b/keep/api/routes/incidents.py index 8785d8d75..b7cd6fab5 100644 --- a/keep/api/routes/incidents.py +++ b/keep/api/routes/incidents.py @@ -25,6 +25,7 @@ get_workflow_executions_for_incident_or_alert, remove_alerts_to_incident_by_incident_id, update_incident_from_dto_by_id, + add_runbooks_to_incident_by_incident_id, ) from keep.api.core.dependencies import get_pusher_client from keep.api.models.alert import ( @@ -46,6 +47,7 @@ from keep.identitymanager.authenticatedentity import AuthenticatedEntity from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory from keep.workflowmanager.workflowmanager import WorkflowManager +from keep.api.models.db.runbook import RunbookDto router = APIRouter() logger = logging.getLogger(__name__) @@ -54,6 +56,11 @@ os.environ.get("MIN_INCIDENT_ALERTS_FOR_SUMMARY_GENERATION", 5) ) +MIN_INCIDENT_RUNBOOKS_FOR_SUMMARY_GENERATION = int( + os.environ.get("MIN_INCIDENT_RUNBOOKS_FOR_SUMMARY_GENERATION", 5) +) + + ee_enabled = os.environ.get("EE_ENABLED", "false") == "true" if ee_enabled: path_with_ee = ( @@ -598,3 +605,71 @@ def change_incident_status( new_incident_dto = IncidentDto.from_db_incident(incident) return new_incident_dto + + +@router.post( + "/{incident_id}/runbooks", + description="Add runbooks to incident", + status_code=202, + response_model=List[RunbookDto], +) +async def add_runbooks_to_incident( + incident_id: UUID, + runbooks_ids: List[UUID], + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["write:incident"]) + ), + pusher_client: Pusher | None = Depends(get_pusher_client), +): + tenant_id = authenticated_entity.tenant_id + logger.info( + "Fetching incident", + extra={ + "incident_id": incident_id, + "tenant_id": tenant_id, + }, + ) + incident = get_incident_by_id(tenant_id=tenant_id, incident_id=incident_id) + if not incident: + raise HTTPException(status_code=404, detail="Incident not found") + + add_runbooks_to_incident_by_incident_id(tenant_id, incident_id, runbooks_ids) + __update_client_on_incident_change(pusher_client, tenant_id, incident_id) + + incident_dto = IncidentDto.from_db_incident(incident) + + try: + workflow_manager = WorkflowManager.get_instance() + logger.info("Adding incident to the workflow manager queue") + workflow_manager.insert_incident(tenant_id, incident_dto, "updated") + logger.info("Added incident to the workflow manager queue") + except Exception: + logger.exception( + "Failed to run workflows based on incident", + extra={"incident_id": incident_dto.id, "tenant_id": tenant_id}, + ) + + fingerprints_count = get_incident_unique_fingerprint_count(tenant_id, incident_id) + + if ( + ee_enabled + and fingerprints_count > MIN_INCIDENT_RUNBOOKS_FOR_SUMMARY_GENERATION + and not incident.user_summary + ): + pool = await get_pool() + job = await pool.enqueue_job( + "process_summary_generation", + tenant_id=tenant_id, + incident_id=incident_id, + ) + logger.info( + f"Summary generation for incident {incident_id} scheduled, job: {job}", + extra={ + "algorithm": ALGORITHM_VERBOSE_NAME, + "tenant_id": tenant_id, + "incident_id": incident_id, + }, + ) + + return Response(status_code=202) + From e7afd8e043480be27296f2204d504b6ff0df01eb Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 16:30:25 +0530 Subject: [PATCH 16/40] chore: fix lint issues --- keep/api/core/db.py | 1 - keep/api/models/db/alert.py | 3 ++- keep/api/models/db/runbook.py | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 09305c731..e7103ef5f 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -3482,7 +3482,6 @@ def get_runbooks_data_for_incident( sources = [] services = [] - severities = [] for provider_type in runbooks_data: if provider_type: diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py index af3d0ac2d..2f8e69bb4 100644 --- a/keep/api/models/db/alert.py +++ b/keep/api/models/db/alert.py @@ -15,7 +15,8 @@ from keep.api.core.config import config from keep.api.models.alert import IncidentSeverity, IncidentStatus from keep.api.models.db.tenant import Tenant -from keep.api.models.db.runbook import RunbookToIncident +from keep.api.models.db.runbook import RunbookToIncident, Runbook + db_connection_string = config("DATABASE_CONNECTION_STRING", default=None) logger = logging.getLogger(__name__) diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index e60b3df61..e8a45e9c9 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -10,6 +10,9 @@ from sqlalchemy.dialects.mysql import DATETIME as MySQL_DATETIME from sqlalchemy.dialects.mssql import DATETIME2 as MSSQL_DATETIME2 from keep.api.consts import RUNNING_IN_CLOUD_RUN +from sqlalchemy.engine.url import make_url +from keep.api.models.db.alert import Incident + db_connection_string = config("DATABASE_CONNECTION_STRING", default=None) From 84f406cb091490a8bc564cce3f1ed824dc415093 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 16:35:10 +0530 Subject: [PATCH 17/40] chore: fixed circular issue --- keep/api/models/db/runbook.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index e8a45e9c9..ffcceba01 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -11,8 +11,6 @@ from sqlalchemy.dialects.mssql import DATETIME2 as MSSQL_DATETIME2 from keep.api.consts import RUNNING_IN_CLOUD_RUN from sqlalchemy.engine.url import make_url -from keep.api.models.db.alert import Incident - db_connection_string = config("DATABASE_CONNECTION_STRING", default=None) From 74e586e43c44662cd49480521eef5f20552ce4dd Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 16:46:42 +0530 Subject: [PATCH 18/40] chore: removed the incidents relation ship in runbook --- keep/api/models/db/runbook.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/keep/api/models/db/runbook.py b/keep/api/models/db/runbook.py index ffcceba01..932dcf551 100644 --- a/keep/api/models/db/runbook.py +++ b/keep/api/models/db/runbook.py @@ -79,9 +79,6 @@ class Runbook(SQLModel, table=True): contents: List["RunbookContent"] = Relationship(back_populates="runbook") # Relationship to RunbookContent provider_type: str # Type of the provider provider_id: Optional[str] = None # Optional provider ID - incidents: List["Incident"] = Relationship( - back_populates="runbooks", link_model=RunbookToIncident - ) created_at: datetime = Field(default_factory=datetime.utcnow) # Timestamp for creation timestamp: datetime = Field( sa_column=Column(datetime_column_type, index=True), From 10aaefb3b068c99df87ebd83b61d9f3080814f95 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 16:58:24 +0530 Subject: [PATCH 19/40] chore: removed the runbooks key in incidents schema --- keep/api/models/db/alert.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py index 2f8e69bb4..48d8a2c17 100644 --- a/keep/api/models/db/alert.py +++ b/keep/api/models/db/alert.py @@ -15,7 +15,6 @@ from keep.api.core.config import config from keep.api.models.alert import IncidentSeverity, IncidentStatus from keep.api.models.db.tenant import Tenant -from keep.api.models.db.runbook import RunbookToIncident, Runbook db_connection_string = config("DATABASE_CONNECTION_STRING", default=None) @@ -86,10 +85,6 @@ class Incident(SQLModel, table=True): back_populates="incidents", link_model=AlertToIncident ) - runbooks: List["Runbook"] = Relationship( - back_populates="incidents", link_model=RunbookToIncident - ) - is_predicted: bool = Field(default=False) is_confirmed: bool = Field(default=False) From 2c6503756bb57ab89c7c3c741d7df53e5a23b022 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 18:04:16 +0530 Subject: [PATCH 20/40] chore:minor ui change and backend change --- keep-ui/app/runbooks/runbook-associate-incident-modal.tsx | 2 +- keep/api/core/db.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx b/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx index fd35617fc..b0ffe2260 100644 --- a/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx +++ b/keep-ui/app/runbooks/runbook-associate-incident-modal.tsx @@ -149,7 +149,7 @@ const RunbookAssociateIncidentModal = ({ onClick={handleAssociateRunbooks} disabled={selectedIncident === null} > - Associate {runbooks.length} alert{runbooks.length > 1 ? "s" : ""} + Associate {runbooks.length} Runbook{runbooks.length > 1 ? "s" : ""}
    - {runbooksData && ( + {!!total_count && ( - data={runbooksData} + data={runbooks} columns={columnsv2} - rowCount={runbooksData.length} + rowCount={total_count} offset={offset} limit={limit} onPaginationChange={handlePaginationChange} diff --git a/keep/api/routes/runbooks.py b/keep/api/routes/runbooks.py index 17c631cbd..86cdbb578 100644 --- a/keep/api/routes/runbooks.py +++ b/keep/api/routes/runbooks.py @@ -118,10 +118,10 @@ def create_runbook( @router.get( "", description="All Runbooks", - # response_model=RunbookDtoOut, ) -def create_runbook( - +def get_all_runbooks( + limit: int = 25, + offset: int = 0, authenticated_entity: AuthenticatedEntity = Depends( IdentityManagerFactory.get_auth_verifier(["read:runbook"]) ), @@ -129,5 +129,5 @@ def create_runbook( ): tenant_id = authenticated_entity.tenant_id logger.info("get all Runbooks", extra={tenant_id: tenant_id}) - return RunbookService.get_all_runbooks(session, tenant_id) + return RunbookService.get_all_runbooks(session, tenant_id, limit, offset) diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 4f2b628ae..76f6ad0d1 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -122,31 +122,52 @@ def pull_repositories(self, project_id=None): repos_list = self._format_repos(repos) return repos_list - def _format_runbook(self, runbook, repo, title): + + def _format_content(self, runbookContent, repo): + """ + Format the content data into a dictionary. + """ + return { + "content": runbookContent.content, + "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbookContent.path}", + "encoding": runbookContent.encoding, + "file_name": runbookContent.name + } + + def _format_runbook(self, runbook, repo, title, md_path): """ Format the runbook data into a dictionary. """ - - # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) + self.logger.info("runbook: %s", runbook) + + if runbook is None: + raise Exception("Got empty runbook. Please check the runbook path and try again.") + + # Check if the runbook is a list, then set runbook_contents accordingly + if isinstance(runbook, list): + runbook_contents = runbook + else: + runbook_contents = [runbook] + + # Filter contents where type is "file" + filtered_runbook_contents = [runbookContent for runbookContent in runbook_contents if runbookContent.type == "file"] + + # Format the contents using a helper function + contents = [self._format_content(runbookContent, repo) for runbookContent in filtered_runbook_contents] + + # Return the formatted runbook data as a dictionary return { - "file_name": runbook.name, - "file_path": runbook.path, - "file_size": runbook.size, - "file_type": runbook.type, + "relative_path": md_path, "repo_id": repo.get("id"), "repo_name": repo.get("name"), "repo_display_name": repo.get("display_name"), "provider_type": "github", "provider_id": self.provider_id, - "contents": [{ - "content":runbook.content, - "link": f"https://api.github.com/{repo.get('full_name')}/blob/{repo.get('default_branch')}/{runbook.path}", - "encoding": runbook.encoding, - "file_name": runbook.name - }], + "contents": contents, "title": title, } + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): @@ -170,7 +191,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): raise Exception(f"Repository {repo_name} not found") runbook = repo.get_contents(md_path, branch) - response = self._format_runbook(runbook, self._format_repo(repo), title) + response = self._format_runbook(runbook, self._format_repo(repo), title, md_path) return response except GithubException as e: diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 563195b80..50c776818 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -228,29 +228,49 @@ def pull_repositories(self, project_id=None): raise Exception("Failed to get repositories: personal_access_token not set") - def _format_runbook(self, runbook, repo, title): - """ - Format the runbook data into a dictionary. - """ - - # TO DO. currently we are handling the one file only. we user give folder path. then we might get multiple files as input(runbook) - return { - "file_name": runbook.get("file_name"), - "file_path": runbook.get("file_path"), - "file_size": runbook.get("size"), - "file_type": runbook.get("type"), - "repo_id": repo.get("id"), - "repo_name": repo.get("name"), - "repo_display_name": repo.get("display_name"), - "provider_type": "gitlab", - "config": self.provider_id, - "contents": [{ - "content": runbook.get("content"), - "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbook.get('file_path')}/raw", - "encoding": runbook.get("encoding"), - }], - "title": title, - } + def _format_content(self, runbookContent, repo): + """ + Format the content data into a dictionary. + """ + return { + "content": runbookContent.get("content"), + "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbookContent.get('file_path')}/raw", + "encoding": runbook.get("encoding"), + "file_name": runbookContent.get("file_name"), + } + + + def _format_runbook(self, runbook, repo, title, md_path): + """ + Format the runbook data into a dictionary. + """ + if runbook is None: + raise Exception("Got empty runbook. Please check the runbook path and try again.") + + # Check if runbook is a list, if not convert to list + if isinstance(runbook, list): + runbook_contents = runbook + else: + runbook_contents = [runbook] + + # Filter runbook contents where type is "file" + filtered_runbook_contents = [runbookContent for runbookContent in runbook_contents if runbookContent.get("type") == "file"] + + # Format the contents using a helper function + contents = [self._format_content(runbookContent, repo) for runbookContent in filtered_runbook_contents] + + # Return formatted runbook data as dictionary + return { + "relative_path": md_path, + "repo_id": repo.get("id"), + "repo_name": repo.get("name"), + "repo_display_name": repo.get("display_name"), + "provider_type": "gitlab", # This was changed from "github" to "gitlab", assuming it is intentional + "provider_id": self.provider_id, # Assuming this is supposed to be 'provider_id', not 'config' + "contents": contents, + "title": title, + } + def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): """Retrieve markdown files from the GitLab repository.""" @@ -272,7 +292,7 @@ def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): except HTTPError as e: raise Exception(f"Failed to get runbook: {e}") - return self._format_runbook(resp.json(), repo_meta, title) + return self._format_runbook(resp.json(), repo_meta, title, md_path) raise Exception("Failed to get runbook: repository or md_path not set") diff --git a/keep/runbooks/runbooks_service.py b/keep/runbooks/runbooks_service.py index 9473bb4a7..4b7a3ca3a 100644 --- a/keep/runbooks/runbooks_service.py +++ b/keep/runbooks/runbooks_service.py @@ -20,7 +20,7 @@ def create_runbook(session: Session, tenant_id: str, runbook_dto: dict): tenant_id=tenant_id, title=runbook_dto["title"], repo_id=runbook_dto["repo_id"], - relative_path=runbook_dto["file_path"], + relative_path=runbook_dto["relative_path"], provider_type=runbook_dto["provider_type"], provider_id=runbook_dto["provider_id"] ) @@ -50,11 +50,14 @@ def create_runbook(session: Session, tenant_id: str, runbook_dto: dict): logger.exception(f"Failed to create runbook {e}") @staticmethod - def get_all_runbooks(session: Session, tenant_id: str) -> List[RunbookDtoOut]: - runbooks = session.exec( - select(Runbook) - .where(Runbook.tenant_id == tenant_id) - .options(selectinload(Runbook.contents)).limit(1000) + def get_all_runbooks(session: Session, tenant_id: str, limit=25, offset=0) -> dict: + query = session.query(Runbook).filter( + Runbook.tenant_id == tenant_id, ) - - return [RunbookDtoOut.from_orm(runbook) for runbook in runbooks] \ No newline at end of file + + total_count = query.count() # Get the total count of runbooks matching the tenant_id + runbooks = query.options(selectinload(Runbook.contents)).limit(limit).offset(offset).all() # Fetch the paginated runbooks + result = [RunbookDtoOut.from_orm(runbook) for runbook in runbooks] # Convert runbooks to DTOs + + # Return total count and list of runbooks + return {"total_count": total_count, "runbooks": result} \ No newline at end of file From 1029b2ee558cdddd25e379d91ff8e4748a72fec9 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 21:21:54 +0530 Subject: [PATCH 22/40] chore: fix lint issues --- keep/providers/gitlab_provider/gitlab_provider.py | 2 +- keep/runbooks/runbooks_service.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 50c776818..78c0c65eb 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -235,7 +235,7 @@ def _format_content(self, runbookContent, repo): return { "content": runbookContent.get("content"), "link": f"{self.gitlab_host}/api/v4/projects/{repo.get('id')}/repository/files/{runbookContent.get('file_path')}/raw", - "encoding": runbook.get("encoding"), + "encoding": runbookContent.get("encoding"), "file_name": runbookContent.get("file_name"), } diff --git a/keep/runbooks/runbooks_service.py b/keep/runbooks/runbooks_service.py index 4b7a3ca3a..877144665 100644 --- a/keep/runbooks/runbooks_service.py +++ b/keep/runbooks/runbooks_service.py @@ -1,9 +1,8 @@ import logging -from typing import List from pydantic import ValidationError from sqlalchemy.orm import selectinload -from sqlmodel import Session, select +from sqlmodel import Session from keep.api.models.db.runbook import ( Runbook, RunbookContent, @@ -54,10 +53,10 @@ def get_all_runbooks(session: Session, tenant_id: str, limit=25, offset=0) -> di query = session.query(Runbook).filter( Runbook.tenant_id == tenant_id, ) - + total_count = query.count() # Get the total count of runbooks matching the tenant_id runbooks = query.options(selectinload(Runbook.contents)).limit(limit).offset(offset).all() # Fetch the paginated runbooks result = [RunbookDtoOut.from_orm(runbook) for runbook in runbooks] # Convert runbooks to DTOs - + # Return total count and list of runbooks return {"total_count": total_count, "runbooks": result} \ No newline at end of file From a49eaa02aaddf91e3b1d09466a12956e2328c744 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Wed, 9 Oct 2024 22:12:15 +0530 Subject: [PATCH 23/40] fix: gitlab file content issue --- keep/providers/gitlab_provider/gitlab_provider.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/keep/providers/gitlab_provider/gitlab_provider.py b/keep/providers/gitlab_provider/gitlab_provider.py index 78c0c65eb..18a8951ae 100644 --- a/keep/providers/gitlab_provider/gitlab_provider.py +++ b/keep/providers/gitlab_provider/gitlab_provider.py @@ -254,7 +254,7 @@ def _format_runbook(self, runbook, repo, title, md_path): runbook_contents = [runbook] # Filter runbook contents where type is "file" - filtered_runbook_contents = [runbookContent for runbookContent in runbook_contents if runbookContent.get("type") == "file"] + filtered_runbook_contents = [runbookContent for runbookContent in runbook_contents] # Format the contents using a helper function contents = [self._format_content(runbookContent, repo) for runbookContent in filtered_runbook_contents] @@ -279,9 +279,8 @@ def pull_runbook(self, repo=None, branch=None, md_path=None, title=None): md_path = md_path if md_path else self.authentication_config.md_path repo_meta = self.pull_repositories(project_id=repo) - - if repo_meta and branch and md_path: - repo_id = repo_meta.get("id") + repo_id = repo_meta.get("id") + if repo_id and branch and md_path: resp = requests.get( f"{self.gitlab_host}/api/v4/projects/{repo_id}/repository/files/{md_path}?ref={branch}", headers=self.__get_auth_header() From b5b91d9dcaad3ae3f7d9d7ccd8c522d3ef48b311 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Fri, 11 Oct 2024 00:53:58 +0530 Subject: [PATCH 24/40] chore: handle modal close on runbook creation and minor style changes --- keep-ui/app/runbooks/runbook-table.tsx | 20 ++++++++++++++------ keep-ui/components/table/GenericTable.tsx | 4 ++-- keep-ui/utils/hooks/useRunbook.ts | 22 ++++++++++++---------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index 7a83746fd..e95a9f22a 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -10,6 +10,7 @@ import { TextInput, Title, Card, + Callout, } from "@tremor/react"; import { createColumnHelper, @@ -27,6 +28,7 @@ import { fetcher } from "@/utils/fetcher"; import { useSession } from "next-auth/react"; import RunbookActions from "./runbook-actions"; import { RunbookDto, RunbookResponse } from "./models"; +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; const customStyles = { content: { @@ -123,7 +125,7 @@ function SettingsPage() { handleSubmit: submitHandler, provider, fileData, - } = useRunBookTriggers(getValues(), refresh); + } = useRunBookTriggers(getValues(), refresh, setIsModalOpen); const selectedProviderId = watch( "providerId", @@ -269,7 +271,7 @@ function RunbookIncidentTable() { let shouldFetch = session?.accessToken ? true : false; - const { data: runbooksData, error } = useSWR( + const { data: runbooksData, error, isLoading } = useSWR( shouldFetch ? `${getApiURL()}/runbooks?limit=${limit}&offset=${offset}` : null, @@ -301,13 +303,13 @@ function RunbookIncidentTable() { }; return ( -
    -
    +
    +
    Runbook
    - - {!!total_count && ( + + {!isLoading && !error && ( data={runbooks} columns={columnsv2} @@ -322,6 +324,12 @@ function RunbookIncidentTable() { isRowSelectable={true} /> )} + {error && ( + + + Something went wrong. please try again or reach out support team. + + )}
    ); diff --git a/keep-ui/components/table/GenericTable.tsx b/keep-ui/components/table/GenericTable.tsx index 7970ea230..fd60bdb48 100644 --- a/keep-ui/components/table/GenericTable.tsx +++ b/keep-ui/components/table/GenericTable.tsx @@ -143,7 +143,7 @@ export function GenericTable({ return (
    -
    +
    {!!selectedRowIds.length && getActions &&
    {getActions(table, selectedRowIds)}
    } @@ -183,7 +183,7 @@ export function GenericTable({
    -
    +
    {pagination&& { +export const useRunBookTriggers = (values: any, refresh: number, setIsModalOpen: React.Dispatch>) => { const providersData = useProviders(); - const [error, setError] = useState(""); - const [synced, setSynced] = useState(false); const [fileData, setFileData] = useState({}); const [reposData, setRepoData] = useState([]); - const { pathToMdFile, repoName, userName, providerId, domain } = values || {}; + const { providerId } = values || {}; const { data: session } = useSession(); - const { installed_providers, providers } = (providersData?.data || + const { installed_providers } = (providersData?.data || {}) as ProvidersResponse; const runBookInstalledProviders = installed_providers?.filter((provider) => @@ -41,6 +39,7 @@ export const useRunBookTriggers = (values: any, refresh: number) => { setRepoData(data); } catch (err) { console.log("error occurred while fetching data"); + toast.error("Failed to fetch repositories. Please check the provider settings."); setRepoData([]); } }; @@ -83,20 +82,23 @@ export const useRunBookTriggers = (values: any, refresh: number) => { }); if (!response) { - return setError("Something went wrong. try agian after some time"); + toast.error("Failed to create runbook. Something went wrong, please try again later."); + return; } if(!response.ok) { - return setError("Something went wrong. try agian after some time"); + toast.error("Failed to create runbook. Something went wrong, please try again later."); + return; } const result = await response.json(); setFileData(result); - setSynced(false); + setIsModalOpen(false); + toast.success("Runbook created successfully"); } catch (err) { - return setError("Something went wrong. try agian after some time"); + return; } finally { - setSynced(true); + } }; From b47e310f5352e7474b4ab60ae3ebaaf0cdb0d9b3 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Fri, 11 Oct 2024 00:58:36 +0530 Subject: [PATCH 25/40] chore:minor style change --- keep-ui/components/table/GenericTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keep-ui/components/table/GenericTable.tsx b/keep-ui/components/table/GenericTable.tsx index fd60bdb48..7692897df 100644 --- a/keep-ui/components/table/GenericTable.tsx +++ b/keep-ui/components/table/GenericTable.tsx @@ -143,7 +143,7 @@ export function GenericTable({ return (
    -
    +
    {!!selectedRowIds.length && getActions &&
    {getActions(table, selectedRowIds)}
    } From 24a4ae5ee2f22e81498ca0531a3201f20c5990f3 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Fri, 11 Oct 2024 04:49:30 +0530 Subject: [PATCH 26/40] chore:fix migration [atch issue --- .../db/migrations/versions/2024-10-08-22-23_0796be640663.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py b/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py index 80543f748..0d7e4b442 100644 --- a/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py +++ b/keep/api/models/db/migrations/versions/2024-10-08-22-23_0796be640663.py @@ -1,7 +1,7 @@ """Add runbook tables Revision ID: 0796be640663 -Revises: 017d759805d9 +Revises: bf756df80e9d Create Date: 2024-10-08 22:23:47.905179 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "0796be640663" -down_revision = "017d759805d9" +down_revision = "bf756df80e9d" branch_labels = None depends_on = None From 051e676c33a3bda5d002978b2dc77bbf9549e544 Mon Sep 17 00:00:00 2001 From: Rajesh Jonnalagadda Date: Sun, 13 Oct 2024 13:53:51 +0530 Subject: [PATCH 27/40] chore: remove unwanted code and comments --- keep-ui/app/runbooks/runbook-table.tsx | 40 ++------------------------ keep-ui/utils/hooks/useRunbook.ts | 2 -- 2 files changed, 3 insertions(+), 39 deletions(-) diff --git a/keep-ui/app/runbooks/runbook-table.tsx b/keep-ui/app/runbooks/runbook-table.tsx index e95a9f22a..204a33b14 100644 --- a/keep-ui/app/runbooks/runbook-table.tsx +++ b/keep-ui/app/runbooks/runbook-table.tsx @@ -19,9 +19,7 @@ import { } from "@tanstack/react-table"; import { GenericTable } from "@/components/table/GenericTable"; import { useRunBookTriggers } from "utils/hooks/useRunbook"; -import { useForm, get } from "react-hook-form"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { useForm } from "react-hook-form"; import { getApiURL } from "@/utils/apiUrl"; import useSWR from "swr"; import { fetcher } from "@/utils/fetcher"; @@ -93,25 +91,6 @@ const columnsv2 = [ }), ] as DisplayColumnDef[]; -// function PreviewContent({type, data}:{type:string, data:string}){ -// const [isModalOpen, setIsModalOpen] = useState(open); - -// // const closeModal = () => { -// // setIsModalOpen(false); -// // }; -// const decodeBase64 = (encodedContent:string) => { -// // Use atob to decode the Base64 string and then handle UTF-8 encoding -// return encodedContent ? decodeURIComponent(atob(encodedContent)) : ''; -// }; -// return
    - -// -// {decodeBase64(data)} -// -//
    -// } - -// TO DO: Need to work on styling function SettingsPage() { const [isModalOpen, setIsModalOpen] = useState(false); const { register, handleSubmit, reset, getValues, setValue, watch } = @@ -160,8 +139,7 @@ function SettingsPage() { }; const onSubmit = (data: any) => { - submitHandler(data); // Call the submit handler with form data - // closeModal(); // Close modal after submit + submitHandler(data); }; const handleProviderChange = (value: string) => { @@ -183,9 +161,8 @@ function SettingsPage() {
    - {/* */}