diff --git a/docs/deployment/monitoring.mdx b/docs/deployment/monitoring.mdx new file mode 100644 index 000000000..76a4d27b6 --- /dev/null +++ b/docs/deployment/monitoring.mdx @@ -0,0 +1,22 @@ +--- +title: "Monitoring" +sidebarTitle: "Monitoring" +--- + +# Healthchecks + +Keep's Backend healthcheck url: +``` +{BACKEND_API_URL}/healthcheck +``` + +Keep's Frontend healthcheck url: +``` +{FRONTEND_URL}/api/healthcheck +``` + +# Prometheus Metrics + +(TBD) + +> Please note that [/api/metrics](api-ref/metrics/get-metrics) are not designed for production instance's health monitoring, but for usage monitoring by a specific tenant. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index b085fb30d..e02154502 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -57,6 +57,7 @@ "group": "Deployment", "pages": [ "deployment/configuration", + "deployment/monitoring", { "group": "Authentication", "pages": [ diff --git a/keep-ui/app/(keep)/alerts/alert-table-utils.tsx b/keep-ui/app/(keep)/alerts/alert-table-utils.tsx index f27d5b03a..71677085d 100644 --- a/keep-ui/app/(keep)/alerts/alert-table-utils.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table-utils.tsx @@ -70,7 +70,7 @@ export const isDateWithinRange: FilterFn = (row, columnId, value) => { } if (isValid(start) && isValid(end)) { - return isWithinInterval(startOfDay(date), { start, end }); + return isWithinInterval(date, { start, end }); } if (isValid(start)) { diff --git a/keep-ui/app/(keep)/error.tsx b/keep-ui/app/(keep)/error.tsx index c92da091f..1de50c459 100644 --- a/keep-ui/app/(keep)/error.tsx +++ b/keep-ui/app/(keep)/error.tsx @@ -28,7 +28,11 @@ export default function ErrorComponent({ return (
- An error occurred while fetching data from the backend + + {error instanceof KeepApiError + ? "An error occurred while fetching data from the backend" + : error.message || "An error occurred"} +
{error instanceof KeepApiError && ( @@ -37,7 +41,7 @@ export default function ErrorComponent({
Message: {error.message}
- Url: {error.url} + URL: {error.url}
)} diff --git a/keep-ui/app/(keep)/providers/provider-tile.tsx b/keep-ui/app/(keep)/providers/provider-tile.tsx index 70974a146..68123b6d5 100644 --- a/keep-ui/app/(keep)/providers/provider-tile.tsx +++ b/keep-ui/app/(keep)/providers/provider-tile.tsx @@ -165,8 +165,12 @@ export default function ProviderTile({ provider, onClick }: Props) { "min-h-36 tile-basis text-left min-w-0 py-4 px-4 relative group flex justify-around items-center bg-white rounded-lg shadow hover:grayscale-0 gap-3" + // Add fixed height only if provider card doesn't have much content (!provider.installed && !provider.linked ? " h-32" : "") + - (!provider.linked ? "cursor-pointer hover:shadow-lg" : "") + - (provider.coming_soon ? " opacity-50 cursor-not-allowed" : "") + (!provider.linked + ? " cursor-pointer hover:shadow-lg" + : " cursor-auto") + + (provider.coming_soon && !provider.linked + ? " opacity-50 cursor-not-allowed" + : "") } onClick={provider.coming_soon ? undefined : onClick} disabled={provider.coming_soon} @@ -219,7 +223,7 @@ export default function ProviderTile({ provider, onClick }: Props) {
{provider.display_name}{" "} - {provider.coming_soon && ( + {provider.coming_soon && !provider.linked && ( <span className="text-sm">(Coming Soon)</span> )} diff --git a/keep-ui/app/(keep)/providers/providers-tiles.tsx b/keep-ui/app/(keep)/providers/providers-tiles.tsx index 9e74bc65b..29030fb9f 100644 --- a/keep-ui/app/(keep)/providers/providers-tiles.tsx +++ b/keep-ui/app/(keep)/providers/providers-tiles.tsx @@ -1,5 +1,5 @@ "use client"; -import { Icon, Title } from "@tremor/react"; +import { Title } from "@tremor/react"; import { Providers, Provider } from "./providers"; import { useEffect, useState } from "react"; // TODO: replace with custom component, package is not updated for last 4 years @@ -9,6 +9,7 @@ import ProviderTile from "./provider-tile"; import "react-sliding-side-panel/lib/index.css"; import { useSearchParams } from "next/navigation"; import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { Tooltip } from "@/shared/ui"; const ProvidersTiles = ({ providers, @@ -95,12 +96,13 @@ const ProvidersTiles = ({ {getSectionTitle()} {linkedProvidersMode && (
- + Providers that send alerts to Keep and are not installed. + } + > + +
)}
diff --git a/keep-ui/app/(keep)/topology/model/models.ts b/keep-ui/app/(keep)/topology/model/models.ts index 2d4c75a32..c6d9277f7 100644 --- a/keep-ui/app/(keep)/topology/model/models.ts +++ b/keep-ui/app/(keep)/topology/model/models.ts @@ -26,6 +26,7 @@ export interface TopologyService { application_ids: string[]; // Added on client to optimize rendering applications: TopologyApplicationMinimal[]; + incidents?: number; } // We need to convert interface to type because only types are allowed in @xyflow/react diff --git a/keep-ui/app/(keep)/topology/ui/map/getNodesAndEdgesFromTopologyData.ts b/keep-ui/app/(keep)/topology/ui/map/getNodesAndEdgesFromTopologyData.ts index be4e6954b..beb147c2f 100644 --- a/keep-ui/app/(keep)/topology/ui/map/getNodesAndEdgesFromTopologyData.ts +++ b/keep-ui/app/(keep)/topology/ui/map/getNodesAndEdgesFromTopologyData.ts @@ -11,19 +11,25 @@ import { edgeLabelBgStyleNoHover, edgeMarkerEndNoHover, } from "@/app/(keep)/topology/ui/map/styles"; +import { IncidentDto } from "@/entities/incidents/model"; export function getNodesAndEdgesFromTopologyData( topologyData: TopologyService[], - applicationsMap: Map + applicationsMap: Map, + allIncidents: IncidentDto[] ) { const nodeMap = new Map(); const edgeMap = new Map(); + const allServices = topologyData.map((data) => data.display_name); // Create nodes from service definitions for (const service of topologyData) { + const numIncidentsToService = allIncidents.filter((incident) => + incident.services.includes(service.display_name) + ); const node: ServiceNodeType = { id: service.service.toString(), type: "service", - data: service, + data: { ...service, incidents: numIncidentsToService.length }, position: { x: 0, y: 0 }, // Dagre will handle the actual positioning selectable: true, }; diff --git a/keep-ui/app/(keep)/topology/ui/map/service-node.tsx b/keep-ui/app/(keep)/topology/ui/map/service-node.tsx index 41560005b..575b4b8dc 100644 --- a/keep-ui/app/(keep)/topology/ui/map/service-node.tsx +++ b/keep-ui/app/(keep)/topology/ui/map/service-node.tsx @@ -1,7 +1,5 @@ import React, { useEffect, useState } from "react"; import { Handle, NodeProps, NodeToolbar, Position } from "@xyflow/react"; -import { useAlerts } from "@/utils/hooks/useAlerts"; -import { useAlertPolling } from "@/utils/hooks/usePusher"; import { useRouter } from "next/navigation"; import { ServiceNodeType, TopologyService } from "../../model/models"; import { Badge } from "@tremor/react"; @@ -73,30 +71,43 @@ function ServiceDetailsTooltip({ data }: { data: TopologyService }) { } export function ServiceNode({ data, selected }: NodeProps) { - const { useAllAlerts } = useAlerts(); - const { data: alerts, mutate } = useAllAlerts("feed"); - const { data: pollAlerts } = useAlertPolling(); const router = useRouter(); const [showDetails, setShowDetails] = useState(false); + const [isTooltipReady, setIsTooltipReady] = useState(false); + const [tooltipDirection, setTooltipDirection] = useState( + Position.Bottom + ); useEffect(() => { - if (pollAlerts) { - mutate(); + if (!showDetails) { + setTooltipDirection(Position.Bottom); + setIsTooltipReady(false); + return; } - }, [pollAlerts, mutate]); - const relevantAlerts = alerts?.filter( - (alert) => alert.service === data.service - ); + const node = document.querySelector(".tooltip-ref"); + if (!node) return; + + const rect = node.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + if (rect.bottom + 10 > viewportHeight) { + setTooltipDirection(Position.Top); + } else { + setTooltipDirection(Position.Bottom); + } + setIsTooltipReady(true); + }, [showDetails]); const handleClick = () => { router.push( - `/alerts/feed?cel=service%3D%3D${encodeURIComponent(`"${data.service}"`)}` + `/incidents?services={encodeURIComponent("${data.display_name}")}` ); }; - const alertCount = relevantAlerts?.length || 0; - const badgeColor = alertCount < THRESHOLD ? "bg-orange-500" : "bg-red-500"; + const incidentsCount = data.incidents ?? 0; + const badgeColor = + incidentsCount < THRESHOLD ? "bg-orange-500" : "bg-red-500"; return ( <> @@ -121,12 +132,12 @@ export function ServiceNode({ data, selected }: NodeProps) {
)} {data.display_name || data.service} - {alertCount > 0 && ( + {incidentsCount > 0 && ( - {alertCount} + {incidentsCount} )}
@@ -141,7 +152,11 @@ export function ServiceNode({ data, selected }: NodeProps) {
- + diff --git a/keep-ui/app/(keep)/topology/ui/map/topology-map.tsx b/keep-ui/app/(keep)/topology/ui/map/topology-map.tsx index 9ed9fb6fd..fc74fdbe6 100644 --- a/keep-ui/app/(keep)/topology/ui/map/topology-map.tsx +++ b/keep-ui/app/(keep)/topology/ui/map/topology-map.tsx @@ -50,6 +50,7 @@ import "@xyflow/react/dist/style.css"; import { areSetsEqual } from "@/utils/helpers"; import { getLayoutedElements } from "@/app/(keep)/topology/ui/map/getLayoutedElements"; import { getNodesAndEdgesFromTopologyData } from "@/app/(keep)/topology/ui/map/getNodesAndEdgesFromTopologyData"; +import { useIncidents } from "@/utils/hooks/useIncidents"; const defaultFitViewOptions: FitViewOptions = { padding: 0.1, @@ -223,6 +224,8 @@ export function TopologyMap({ const previousNodesIds = useRef>(new Set()); + const { data: allIncidents } = useIncidents(); + useEffect( function createAndSetLayoutedNodesAndEdges() { if (!topologyData) { @@ -231,7 +234,8 @@ export function TopologyMap({ const { nodeMap, edgeMap } = getNodesAndEdgesFromTopologyData( topologyData, - applicationMap + applicationMap, + allIncidents?.items ?? [] ); const newNodes = Array.from(nodeMap.values()); @@ -262,7 +266,7 @@ export function TopologyMap({ setNodes(layoutedElements.nodes); setEdges(layoutedElements.edges); }, - [topologyData, applicationMap] + [topologyData, applicationMap, allIncidents] ); useEffect( diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx index 427aefd89..ff2ef0fde 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/layout.tsx @@ -1,6 +1,7 @@ "use client"; -import { ArrowLeftIcon } from "@radix-ui/react-icons"; -import Link from "next/link"; +import { Link } from "@/components/ui"; +import { ArrowRightIcon } from "@heroicons/react/16/solid"; +import { Icon, Subtitle } from "@tremor/react"; export default function Layout({ children, @@ -10,16 +11,12 @@ export default function Layout({ params: { workflow_id: string }; }) { return ( - <> -
- - Back to Workflows - -
{children}
-
- +
+ + All Workflows{" "} + Workflow Details + +
{children}
+
); } diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx index 752daee02..bf5a882f6 100644 --- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx +++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-execution-table.tsx @@ -24,6 +24,7 @@ import { PiDiamondsFourFill } from "react-icons/pi"; import { FaHandPointer } from "react-icons/fa"; import { HiBellAlert } from "react-icons/hi2"; import { useRouter } from "next/navigation"; +import { CursorArrowRaysIcon } from "@heroicons/react/24/outline"; interface Pagination { limit: number; @@ -116,7 +117,7 @@ export function getIcon(status: string) { export function getTriggerIcon(triggered_by: string) { switch (triggered_by) { case "Manual": - return FaHandPointer; + return CursorArrowRaysIcon; case "Scheduler": return PiDiamondsFourFill; case "Alert": diff --git a/keep-ui/app/(keep)/workflows/builder/CustomNode.tsx b/keep-ui/app/(keep)/workflows/builder/CustomNode.tsx index 1b6a4e5a7..ee872cb83 100644 --- a/keep-ui/app/(keep)/workflows/builder/CustomNode.tsx +++ b/keep-ui/app/(keep)/workflows/builder/CustomNode.tsx @@ -8,9 +8,9 @@ import { MdNotStarted } from "react-icons/md"; import { GoSquareFill } from "react-icons/go"; import { PiDiamondsFourFill, PiSquareLogoFill } from "react-icons/pi"; import { BiSolidError } from "react-icons/bi"; -import { FaHandPointer } from "react-icons/fa"; import { toast } from "react-toastify"; -import { FlowNode, V2Step } from "@/app/(keep)/workflows/builder/types"; +import { FlowNode } from "@/app/(keep)/workflows/builder/types"; +import { CursorArrowRaysIcon } from "@heroicons/react/24/outline"; function IconUrlProvider(data: FlowNode["data"]) { const { componentType, type } = data || {}; @@ -47,7 +47,7 @@ function CustomNode({ id, data }: FlowNode) { const { type } = step; switch (type) { case "manual": - return ; + return ; case "interval": return ; } diff --git a/keep-ui/app/(keep)/workflows/builder/ToolBox.tsx b/keep-ui/app/(keep)/workflows/builder/ToolBox.tsx index 9791af1c4..6ce15b2c2 100644 --- a/keep-ui/app/(keep)/workflows/builder/ToolBox.tsx +++ b/keep-ui/app/(keep)/workflows/builder/ToolBox.tsx @@ -5,10 +5,10 @@ import { IoChevronUp, IoClose } from "react-icons/io5"; import Image from "next/image"; import { IoIosArrowDown } from "react-icons/io"; import useStore from "./builder-store"; -import { FaHandPointer } from "react-icons/fa"; import { PiDiamondsFourFill } from "react-icons/pi"; import clsx from "clsx"; import { V2Step } from "@/app/(keep)/workflows/builder/types"; +import { CursorArrowRaysIcon } from "@heroicons/react/24/outline"; const GroupedMenu = ({ name, @@ -55,7 +55,7 @@ const GroupedMenu = ({ const { type } = step; switch (type) { case "manual": - return ; + return ; case "interval": return ; } diff --git a/keep-ui/app/(keep)/workflows/builder/builder-card.tsx b/keep-ui/app/(keep)/workflows/builder/builder-card.tsx index 1a0265d1e..851f9fd00 100644 --- a/keep-ui/app/(keep)/workflows/builder/builder-card.tsx +++ b/keep-ui/app/(keep)/workflows/builder/builder-card.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Loader from "./loader"; import { Provider } from "../../providers/providers"; import { useProviders } from "utils/hooks/useProviders"; +import clsx from "clsx"; const Builder = dynamic(() => import("./builder"), { ssr: false, // Prevents server-side rendering @@ -58,7 +59,13 @@ export function BuilderCard({ ); return ( - + {error ? ( - wrapDefinitionV2({ sequence: [], properties: {}, isValid: false }) - ); + const [definition, setDefinition] = useState(INITIAL_DEFINITION); const [isLoading, setIsLoading] = useState(true); const [stepValidationError, setStepValidationError] = useState( null @@ -180,34 +185,43 @@ function Builder({ useEffect(() => { setIsLoading(true); - if (workflow) { - setDefinition( - wrapDefinitionV2({ - ...parseWorkflow(workflow, providers), - isValid: true, - }) - ); - } else if (loadedAlertFile == null) { - const alertUuid = uuidv4(); - const alertName = searchParams?.get("alertName"); - const alertSource = searchParams?.get("alertSource"); - let triggers = {}; - if (alertName && alertSource) { - triggers = { alert: { source: alertSource, name: alertName } }; + try { + if (workflow) { + setDefinition( + wrapDefinitionV2({ + ...parseWorkflow(workflow, providers), + isValid: true, + }) + ); + } else if (loadedAlertFile == null) { + const alertUuid = uuidv4(); + const alertName = searchParams?.get("alertName"); + const alertSource = searchParams?.get("alertSource"); + let triggers = {}; + if (alertName && alertSource) { + triggers = { alert: { source: alertSource, name: alertName } }; + } + setDefinition( + wrapDefinitionV2({ + ...generateWorkflow(alertUuid, "", "", false, {}, [], [], triggers), + isValid: true, + }) + ); + } else { + const parsedDefinition = parseWorkflow(loadedAlertFile!, providers); + setDefinition( + wrapDefinitionV2({ + ...parsedDefinition, + isValid: true, + }) + ); + } + } catch (error) { + if (error instanceof YAMLException) { + showErrorToast(error, "Invalid YAML: " + error.message); + } else { + showErrorToast(error, "Failed to load workflow"); } - setDefinition( - wrapDefinitionV2({ - ...generateWorkflow(alertUuid, "", "", false, {}, [], [], triggers), - isValid: true, - }) - ); - } else { - setDefinition( - wrapDefinitionV2({ - ...parseWorkflow(loadedAlertFile!, providers), - isValid: true, - }) - ); } setIsLoading(false); }, [loadedAlertFile, workflow, searchParams, providers]); diff --git a/keep-ui/app/(keep)/workflows/builder/layout.tsx b/keep-ui/app/(keep)/workflows/builder/layout.tsx new file mode 100644 index 000000000..1bad1ea6d --- /dev/null +++ b/keep-ui/app/(keep)/workflows/builder/layout.tsx @@ -0,0 +1,30 @@ +"use client"; +import { Link } from "@/components/ui"; +import { ArrowRightIcon } from "@heroicons/react/16/solid"; +import { Badge, Icon, Subtitle } from "@tremor/react"; + +export default function Layout({ + children, + params, +}: { + children: any; + params: { workflow_id: string }; +}) { + return ( +
+ + All Workflows{" "} + Workflow Builder{" "} + + Beta + + +
{children}
+
+ ); +} diff --git a/keep-ui/app/(keep)/workflows/builder/page.client.tsx b/keep-ui/app/(keep)/workflows/builder/page.client.tsx index b5b13ca1d..dd77adfcd 100644 --- a/keep-ui/app/(keep)/workflows/builder/page.client.tsx +++ b/keep-ui/app/(keep)/workflows/builder/page.client.tsx @@ -1,7 +1,7 @@ "use client"; import { Title, Button, Subtitle, Badge } from "@tremor/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { PlusIcon, ArrowDownOnSquareIcon, @@ -10,6 +10,9 @@ import { PlayIcon, } from "@heroicons/react/20/solid"; import { BuilderCard } from "./builder-card"; +import { loadWorkflowYAML } from "./utils"; +import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; +import { YAMLException } from "js-yaml"; export default function PageClient({ workflow, @@ -27,6 +30,7 @@ export default function PageClient({ const [triggerRun, setTriggerRun] = useState(0); const [fileContents, setFileContents] = useState(""); const [fileName, setFileName] = useState(""); + const fileInputRef = useRef(null); useEffect(() => { setFileContents(null); @@ -52,27 +56,30 @@ export default function PageClient({ reader.onload = (event) => { setFileName(fName); const contents = event.target!.result as string; - setFileContents(contents); + try { + const parsedWorkflow = loadWorkflowYAML(contents); + setFileContents(contents); + } catch (error) { + if (error instanceof YAMLException) { + showErrorToast(error, "Invalid YAML: " + error.message); + } else { + showErrorToast(error, "Failed to load workflow"); + } + setFileName(""); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } }; reader.readAsText(file); } const incrementState = (s: number) => s + 1; return ( -
+
- - Builder - <Badge - color="orange" - size="xs" - tooltip="Slack us if something isn't working properly :)" - > - Beta - </Badge> - - Workflow building kit + {workflow ? "Edit" : "New"} Workflow
{!workflow && ( @@ -103,6 +110,7 @@ export default function PageClient({ type="file" id="alertFile" style={{ display: "none" }} + ref={fileInputRef} onChange={handleFileChange} /> diff --git a/keep-ui/app/(keep)/workflows/builder/utils.tsx b/keep-ui/app/(keep)/workflows/builder/utils.tsx index 69e868739..e60bc6817 100644 --- a/keep-ui/app/(keep)/workflows/builder/utils.tsx +++ b/keep-ui/app/(keep)/workflows/builder/utils.tsx @@ -346,6 +346,13 @@ export function generateWorkflow( }; } +export function loadWorkflowYAML(workflowString: string): Definition { + const parsedWorkflowFile = load(workflowString, { + schema: JSON_SCHEMA, + }) as any; + return parsedWorkflowFile; +} + export function parseWorkflow( workflowString: string, providers: Provider[] diff --git a/keep-ui/app/(keep)/workflows/mockworkflows.tsx b/keep-ui/app/(keep)/workflows/mockworkflows.tsx index c4ffd3af4..7288e229c 100644 --- a/keep-ui/app/(keep)/workflows/mockworkflows.tsx +++ b/keep-ui/app/(keep)/workflows/mockworkflows.tsx @@ -121,7 +121,7 @@ export default function MockWorkflowCardSection({ return (

- Discover existing workflow templates + Discover workflow templates

{/* TODO: Implement the commented out code block */} {/* This is a placeholder comment until the commented out code block is implemented */} diff --git a/keep-ui/app/(keep)/workflows/workflow-tile.tsx b/keep-ui/app/(keep)/workflows/workflow-tile.tsx index 2068a24ea..e6627ed12 100644 --- a/keep-ui/app/(keep)/workflows/workflow-tile.tsx +++ b/keep-ui/app/(keep)/workflows/workflow-tile.tsx @@ -25,7 +25,11 @@ import SlidingPanel from "react-sliding-side-panel"; import { useFetchProviders } from "@/app/(keep)/providers/page.client"; import { Provider as FullProvider } from "@/app/(keep)/providers/providers"; import "./workflow-tile.css"; -import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"; +import { + CheckCircleIcon, + CursorArrowRaysIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; import AlertTriggerModal from "./workflow-run-with-alert-modal"; import { formatDistanceToNowStrict } from "date-fns"; import TimeAgo, { Formatter, Suffix, Unit } from "react-timeago"; @@ -475,7 +479,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { return onlyIcons ? (
- +
) : ( @@ -483,7 +487,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { key={t} size="xs" color="orange" - icon={FaHandPointer} + icon={CursorArrowRaysIcon} title={`Source: ${t}`} {...props} > @@ -526,7 +530,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) {
)} { e.stopPropagation(); e.preventDefault(); @@ -585,7 +589,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { e.stopPropagation(); setOpenTriggerModal(true); }} - icon={FaHandPointer} + icon={CursorArrowRaysIcon} > Manual diff --git a/keep-ui/app/(keep)/workflows/workflows.client.tsx b/keep-ui/app/(keep)/workflows/workflows.client.tsx index a6145d74b..43337c403 100644 --- a/keep-ui/app/(keep)/workflows/workflows.client.tsx +++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx @@ -20,11 +20,12 @@ import Modal from "@/components/ui/Modal"; import MockWorkflowCardSection from "./mockworkflows"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; +import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; +import { Input } from "@/shared/ui/Input"; export default function WorkflowsPage() { const api = useApi(); const router = useRouter(); - const [fileError, setFileError] = useState(null); const fileInputRef = useRef(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -78,14 +79,17 @@ export default function WorkflowsPage() { } const onDrop = async (files: any) => { - const fileUpload = async (formData: FormData, reload: boolean) => { + const fileUpload = async ( + formData: FormData, + fName: string, + reload: boolean + ) => { try { const response = await api.request(`/workflows`, { method: "POST", body: formData, }); - setFileError(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -94,9 +98,9 @@ export default function WorkflowsPage() { } } catch (error) { if (error instanceof KeepApiError) { - setFileError(error.message); + showErrorToast(error, `Failed to upload ${fName}: ${error.message}`); } else { - setFileError("An error occurred during file upload"); + showErrorToast(error, "Failed to upload file"); } if (fileInputRef.current) { fileInputRef.current.value = ""; @@ -109,11 +113,12 @@ export default function WorkflowsPage() { for (let i = 0; i < files.target.files.length; i++) { const file = files.target.files[i]; + const fName = file.name; formData.set("file", file); if (files.target.files.length === i + 1) { reload = true; } - await fileUpload(formData, reload); + await fileUpload(formData, fName, reload); } }; @@ -208,25 +213,32 @@ export default function WorkflowsPage() { onClose={() => setIsModalOpen(false)} title="Upload Workflow files" > -
- { - onDrop(e); - setIsModalOpen(false); // Add this line to close the modal - }} - /> - -
+
+
+ { + onDrop(e); + setIsModalOpen(false); // Add this line to close the modal + }} + /> +

+ Only .yml and .yaml files are supported. +

+
+

Or just try some from Keep examples:

- -

+

More examples at{" "}

diff --git a/keep-ui/app/api/healthcheck/route.ts b/keep-ui/app/api/healthcheck/route.ts new file mode 100644 index 000000000..126270e90 --- /dev/null +++ b/keep-ui/app/api/healthcheck/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ + status: 'ok', + timestamp: new Date().toISOString() + }) +} \ No newline at end of file diff --git a/keep-ui/components/ui/DateRangePicker.tsx b/keep-ui/components/ui/DateRangePicker.tsx index 8b8c430f5..b5f06d02d 100644 --- a/keep-ui/components/ui/DateRangePicker.tsx +++ b/keep-ui/components/ui/DateRangePicker.tsx @@ -16,7 +16,7 @@ import { format } from "date-fns"; import { type DateRange } from "react-day-picker"; const ONE_MINUTE = 60 * 1000; -const ONE_HOUR = 60 * 60 * 1000; +const ONE_HOUR = 60 * ONE_MINUTE; const ONE_DAY = 24 * ONE_HOUR; interface TimeFrame { diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index 89e482dac..3c5ed84f6 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -25,6 +25,7 @@ "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.9.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^1.0.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.4", "@sentry/nextjs": "^8.38.0", @@ -97,6 +98,7 @@ "sharp": "^0.32.6", "swr": "^2.2.5", "tailwind-merge": "^1.12.0", + "tailwind-variants": "^0.3.0", "tailwindcss": "^3.4.1", "undici": "^6.21.0", "uuid": "^8.3.2", @@ -6048,6 +6050,92 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-1.0.0.tgz", + "integrity": "sha512-k+EbxeRaVbSJ4oaR9eUYuC0cDIGRB4TAPhilbFCIMpP9pXFNcyQPQUvRaVOQBrviuArYM80xh0BQR/0y3kjUdQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-primitive": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz", + "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz", + "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", @@ -19548,6 +19636,30 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tailwind-variants": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-0.3.0.tgz", + "integrity": "sha512-ho2k5kn+LB1fT5XdNS3Clb96zieWxbStE9wNLK7D0AV64kdZMaYzAKo0fWl6fXLPY99ffF9oBJnIj5escEl/8A==", + "dependencies": { + "tailwind-merge": "^2.5.4" + }, + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwindcss": "*" + } + }, + "node_modules/tailwind-variants/node_modules/tailwind-merge": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.5.tgz", + "integrity": "sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.15", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz", diff --git a/keep-ui/package.json b/keep-ui/package.json index afc298a9a..0a23e8b2a 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -26,6 +26,7 @@ "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.9.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^1.0.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.4", "@sentry/nextjs": "^8.38.0", @@ -98,6 +99,7 @@ "sharp": "^0.32.6", "swr": "^2.2.5", "tailwind-merge": "^1.12.0", + "tailwind-variants": "^0.3.0", "tailwindcss": "^3.4.1", "undici": "^6.21.0", "uuid": "^8.3.2", diff --git a/keep-ui/public/icons/vectordev-icon.png b/keep-ui/public/icons/vectordev-icon.png new file mode 100644 index 000000000..47e7d084e Binary files /dev/null and b/keep-ui/public/icons/vectordev-icon.png differ diff --git a/keep-ui/shared/lib/tremor-utils.ts b/keep-ui/shared/lib/tremor-utils.ts new file mode 100644 index 000000000..d63bdf7fe --- /dev/null +++ b/keep-ui/shared/lib/tremor-utils.ts @@ -0,0 +1,39 @@ +// Tremor focusInput [v0.0.1] + +export const focusInput = [ + // base + "focus:ring-2", + // ring color + "focus:ring-blue-200 focus:dark:ring-blue-700/30", + // border color + "focus:border-blue-500 focus:dark:border-blue-700", +]; + +// Tremor hasErrorInput [v0.0.1] + +export const hasErrorInput = [ + // base + "ring-2", + // border color + "border-red-500 dark:border-red-700", + // ring color + "ring-red-200 dark:ring-red-700/30", +]; + +// Tremor focusRing [v0.0.1] + +export const focusRing = [ + // base + "outline outline-offset-2 outline-0 focus-visible:outline-2", + // outline color + "outline-blue-500 dark:outline-blue-500", +]; + +// Tremor cx [v0.0.0] + +import clsx, { type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cx(...args: ClassValue[]) { + return twMerge(clsx(...args)); +} diff --git a/keep-ui/shared/ui/Input/index.tsx b/keep-ui/shared/ui/Input/index.tsx new file mode 100644 index 000000000..7e6a3f924 --- /dev/null +++ b/keep-ui/shared/ui/Input/index.tsx @@ -0,0 +1,152 @@ +// Tremor Input [v1.0.5] + +import React from "react"; +import { tv, type VariantProps } from "tailwind-variants"; + +import { + cx, + focusInput, + focusRing, + hasErrorInput, +} from "@/shared/lib/tremor-utils"; +import { + EyeIcon, + EyeSlashIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/24/outline"; + +const inputStyles = tv({ + base: [ + // base + "relative block w-full appearance-none rounded-md border px-2.5 py-2 shadow-sm outline-none transition sm:text-sm", + // border color + "border-gray-300 dark:border-gray-800", + // text color + "text-gray-900 dark:text-gray-50", + // placeholder color + "placeholder-gray-400 dark:placeholder-gray-500", + // background color + "bg-white dark:bg-gray-950", + // disabled + "disabled:border-gray-300 disabled:bg-gray-100 disabled:text-gray-400", + "disabled:dark:border-gray-700 disabled:dark:bg-gray-800 disabled:dark:text-gray-500", + // file + [ + "file:-my-2 file:-ml-2.5 file:cursor-pointer file:rounded-l-[5px] file:rounded-r-none file:border-0 file:px-3 file:py-2 file:outline-none focus:outline-none disabled:pointer-events-none file:disabled:pointer-events-none", + "file:border-solid file:border-gray-300 file:bg-gray-50 file:text-gray-500 file:hover:bg-gray-100 file:dark:border-gray-800 file:dark:bg-gray-950 file:hover:dark:bg-gray-900/20 file:disabled:dark:border-gray-700", + "file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem]", + "file:disabled:bg-gray-100 file:disabled:text-gray-500 file:disabled:dark:bg-gray-800", + ], + // focus + focusInput, + // invalid (optional) + // "aria-[invalid=true]:dark:ring-red-400/20 aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-red-200 aria-[invalid=true]:border-red-500 invalid:ring-2 invalid:ring-red-200 invalid:border-red-500" + // remove search cancel button (optional) + "[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden", + ], + variants: { + hasError: { + true: hasErrorInput, + }, + // number input + enableStepper: { + false: + "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + }, + }, +}); + +interface InputProps + extends React.InputHTMLAttributes, + VariantProps { + inputClassName?: string; +} + +const Input = React.forwardRef( + ( + { + className, + inputClassName, + hasError, + enableStepper = true, + type, + ...props + }: InputProps, + forwardedRef + ) => { + const [typeState, setTypeState] = React.useState(type); + + const isPassword = type === "password"; + const isSearch = type === "search"; + + return ( +
+ + {isSearch && ( +
+
+ )} + {isPassword && ( +
+ +
+ )} +
+ ); + } +); + +Input.displayName = "Input"; + +export { Input, inputStyles, type InputProps }; diff --git a/keep-ui/shared/ui/Label/index.tsx b/keep-ui/shared/ui/Label/index.tsx new file mode 100644 index 000000000..9bcbbdbdf --- /dev/null +++ b/keep-ui/shared/ui/Label/index.tsx @@ -0,0 +1,38 @@ +// Tremor Label [v0.0.2] + +import React from "react"; +import * as LabelPrimitives from "@radix-ui/react-label"; + +import { cx } from "@/shared/lib/tremor-utils"; + +interface LabelProps + extends React.ComponentPropsWithoutRef { + disabled?: boolean; +} + +const Label = React.forwardRef< + React.ElementRef, + LabelProps +>(({ className, disabled, ...props }, forwardedRef) => ( + +)); + +Label.displayName = "Label"; + +export { Label }; diff --git a/keep-ui/shared/ui/utils/showErrorToast.tsx b/keep-ui/shared/ui/utils/showErrorToast.tsx index 3d9041694..32fb0c273 100644 --- a/keep-ui/shared/ui/utils/showErrorToast.tsx +++ b/keep-ui/shared/ui/utils/showErrorToast.tsx @@ -25,6 +25,10 @@ export function showErrorToast( } else if (error instanceof KeepApiError) { toast.error(customMessage || error.message, options); } else { - toast.error(`${customMessage + ": " || ""}Unknown error`, options); + toast.error( + customMessage || + (error instanceof Error ? error.message : "Unknown error"), + options + ); } } diff --git a/keep-ui/utils/hooks/useIncidents.ts b/keep-ui/utils/hooks/useIncidents.ts index 31684d7d3..e1c0c344d 100644 --- a/keep-ui/utils/hooks/useIncidents.ts +++ b/keep-ui/utils/hooks/useIncidents.ts @@ -15,12 +15,12 @@ interface IncidentUpdatePayload { incident_id: string | null; } -interface Filters { - status: string[]; - severity: string[]; - assignees: string[]; - sources: string[]; - affected_services: string[]; +export interface Filters { + status?: string[]; + severity?: string[]; + assignees?: string[]; + sources?: string[]; + affected_services?: string[]; } export const useIncidents = ( diff --git a/keep-ui/utils/hooks/usePusher.ts b/keep-ui/utils/hooks/usePusher.ts index db6153452..7c6eb523a 100644 --- a/keep-ui/utils/hooks/usePusher.ts +++ b/keep-ui/utils/hooks/usePusher.ts @@ -32,6 +32,18 @@ export const useWebsocket = () => { !configData.PUSHER_HOST.includes("://") && !["localhost", "127.0.0.1"].includes(configData.PUSHER_HOST); + // if relative, get the relative port: + let port = configData.PUSHER_PORT; + if (isRelativeHostAndNotLocal) { + // Handle case where port is empty string (default ports 80/443) + if (window.location.port) { + port = parseInt(window.location.port, 10); + } else { + // Use default ports based on protocol + port = window.location.protocol === "https:" ? 443 : 80; + } + } + console.log( "useWebsocket: isRelativeHostAndNotLocal:", isRelativeHostAndNotLocal @@ -42,11 +54,7 @@ export const useWebsocket = () => { ? window.location.hostname : configData.PUSHER_HOST, wsPath: isRelativeHostAndNotLocal ? configData.PUSHER_HOST : "", - wsPort: isRelativeHostAndNotLocal - ? window.location.protocol === "https:" - ? 443 - : 80 - : configData.PUSHER_PORT, + wsPort: isRelativeHostAndNotLocal ? port : configData.PUSHER_PORT, forceTLS: window.location.protocol === "https:", disableStats: true, enabledTransports: ["ws", "wss"], diff --git a/keep/api/api.py b/keep/api/api.py index e0f9cf0fd..69a5141a6 100644 --- a/keep/api/api.py +++ b/keep/api/api.py @@ -52,7 +52,6 @@ ) from keep.api.routes.auth import groups as auth_groups from keep.api.routes.auth import permissions, roles, users -from keep.api.routes.dashboard import provision_dashboards from keep.event_subscriber.event_subscriber import EventSubscriber from keep.identitymanager.identitymanagerfactory import ( IdentityManagerFactory, @@ -61,9 +60,7 @@ # load all providers into cache from keep.providers.providers_factory import ProvidersFactory -from keep.providers.providers_service import ProvidersService from keep.workflowmanager.workflowmanager import WorkflowManager -from keep.workflowmanager.workflowstore import WorkflowStore load_dotenv(find_dotenv()) keep.api.logging.setup_logging() @@ -75,7 +72,6 @@ CONSUMER = os.environ.get("CONSUMER", "true") == "true" AUTH_TYPE = os.environ.get("AUTH_TYPE", IdentityManagerTypes.NOAUTH.value).lower() -PROVISION_RESOURCES = os.environ.get("PROVISION_RESOURCES", "true") == "true" try: KEEP_VERSION = metadata.version("keep") except Exception: @@ -185,15 +181,6 @@ async def root(): async def on_startup(): logger.info("Loading providers into cache") ProvidersFactory.get_all_providers() - if PROVISION_RESOURCES: - # provision providers from env. relevant only on single tenant. - logger.info("Provisioning providers and workflows") - ProvidersService.provision_providers_from_env(SINGLE_TENANT_UUID) - logger.info("Providers loaded successfully") - WorkflowStore.provision_workflows_from_directory(SINGLE_TENANT_UUID) - logger.info("Workflows provisioned successfully") - provision_dashboards(SINGLE_TENANT_UUID) - logger.info("Dashboards provisioned successfully") # Start the services logger.info("Starting the services") # Start the scheduler diff --git a/keep/api/config.py b/keep/api/config.py index a27c4b66d..38f19b696 100644 --- a/keep/api/config.py +++ b/keep/api/config.py @@ -5,20 +5,40 @@ from keep.api.api import AUTH_TYPE from keep.api.core.db_on_start import migrate_db, try_create_single_tenant from keep.api.core.dependencies import SINGLE_TENANT_UUID +from keep.api.routes.dashboard import provision_dashboards from keep.identitymanager.identitymanagerfactory import IdentityManagerTypes from keep.providers.providers_factory import ProvidersFactory +from keep.providers.providers_service import ProvidersService +from keep.workflowmanager.workflowstore import WorkflowStore PORT = int(os.environ.get("PORT", 8080)) +PROVISION_RESOURCES = os.environ.get("PROVISION_RESOURCES", "true") == "true" keep.api.logging.setup_logging() logger = logging.getLogger(__name__) +def provision_resources(): + if PROVISION_RESOURCES: + # provision providers from env. relevant only on single tenant. + logger.info("Provisioning providers and workflows") + ProvidersService.provision_providers_from_env(SINGLE_TENANT_UUID) + logger.info("Providers loaded successfully") + WorkflowStore.provision_workflows_from_directory(SINGLE_TENANT_UUID) + logger.info("Workflows provisioned successfully") + provision_dashboards(SINGLE_TENANT_UUID) + logger.info("Dashboards provisioned successfully") + else: + logger.info("Provisioning resources is disabled") + + def on_starting(server=None): """This function is called by the gunicorn server when it starts""" logger.info("Keep server starting") migrate_db() + provision_resources() + # Load this early and use preloading # https://www.joelsleppy.com/blog/gunicorn-application-preloading/ # @tb: 👏 @Matvey-Kuk diff --git a/keep/api/models/db/migrations/versions/2024-12-01-16-40_3ad5308e7200.py b/keep/api/models/db/migrations/versions/2024-12-01-16-40_3ad5308e7200.py index e6c2140e3..f47399b6e 100644 --- a/keep/api/models/db/migrations/versions/2024-12-01-16-40_3ad5308e7200.py +++ b/keep/api/models/db/migrations/versions/2024-12-01-16-40_3ad5308e7200.py @@ -58,7 +58,10 @@ def downgrade() -> None: existing_nullable=True, ) batch_op.alter_column( - "settings", existing_type=sa.JSON(), type_=sa.VARCHAR(length=255), nullable=False + "settings", + existing_type=sa.JSON(), + type_=sa.VARCHAR(length=255), + nullable=False, ) # ### end Alembic commands ### diff --git a/keep/api/tasks/process_event_task.py b/keep/api/tasks/process_event_task.py index 28ed135fa..ec2cf276a 100644 --- a/keep/api/tasks/process_event_task.py +++ b/keep/api/tasks/process_event_task.py @@ -554,6 +554,7 @@ def process_event( provider_type is not None and isinstance(event, dict) or isinstance(event, FormData) + or isinstance(event, list) ): try: provider_class = ProvidersFactory.get_provider_class(provider_type) diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index 2a1934026..f8e430c74 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -316,7 +316,7 @@ def query(self, **kwargs: dict): @staticmethod def _format_alert( - event: dict, provider_instance: "BaseProvider" = None + event: dict | list[dict], provider_instance: "BaseProvider" = None ) -> AlertDto | list[AlertDto]: """ Format an incoming alert. @@ -335,7 +335,7 @@ def _format_alert( @classmethod def format_alert( cls, - event: dict, + event: dict | list[dict], tenant_id: str | None, provider_type: str | None, provider_id: str | None, diff --git a/keep/providers/datadog_provider/datadog_provider.py b/keep/providers/datadog_provider/datadog_provider.py index bb422578b..90873138a 100644 --- a/keep/providers/datadog_provider/datadog_provider.py +++ b/keep/providers/datadog_provider/datadog_provider.py @@ -13,28 +13,30 @@ import requests from datadog_api_client import ApiClient, Configuration from datadog_api_client.api_client import Endpoint -from datadog_api_client.exceptions import (ApiException, ForbiddenException, - NotFoundException) +from datadog_api_client.exceptions import ( + ApiException, + ForbiddenException, + NotFoundException, +) from datadog_api_client.v1.api.events_api import EventsApi from datadog_api_client.v1.api.logs_api import LogsApi from datadog_api_client.v1.api.metrics_api import MetricsApi from datadog_api_client.v1.api.monitors_api import MonitorsApi -from datadog_api_client.v1.api.webhooks_integration_api import \ - WebhooksIntegrationApi +from datadog_api_client.v1.api.webhooks_integration_api import WebhooksIntegrationApi from datadog_api_client.v1.model.monitor import Monitor from datadog_api_client.v1.model.monitor_options import MonitorOptions from datadog_api_client.v1.model.monitor_thresholds import MonitorThresholds from datadog_api_client.v1.model.monitor_type import MonitorType -from datadog_api_client.v2.api.service_definition_api import \ - ServiceDefinitionApi +from datadog_api_client.v2.api.service_definition_api import ServiceDefinitionApi from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus from keep.api.models.db.topology import TopologyServiceInDto from keep.contextmanager.contextmanager import ContextManager from keep.providers.base.base_provider import BaseTopologyProvider from keep.providers.base.provider_exceptions import GetAlertException -from keep.providers.datadog_provider.datadog_alert_format_description import \ - DatadogAlertFormatDescription +from keep.providers.datadog_provider.datadog_alert_format_description import ( + DatadogAlertFormatDescription, +) from keep.providers.models.provider_config import ProviderConfig, ProviderScope from keep.providers.models.provider_method import ProviderMethod from keep.providers.providers_factory import ProvidersFactory @@ -75,7 +77,7 @@ class DatadogProviderAuthConfig: "description": "Datadog API domain", "sensitive": False, "hint": "https://api.datadoghq.com", - "validation": "https_url" + "validation": "https_url", }, default="https://api.datadoghq.com", ) @@ -796,7 +798,12 @@ def _format_alert( tags_list.remove("monitor") try: - tags = {k: v for k, v in map(lambda tag: tag.split(":"), tags_list)} + tags = {} + for tag in tags_list: + parts = tag.split(":", 1) # Split only on first ':' + if len(parts) == 2: + key, value = parts + tags[key] = value except Exception as e: logger.error( "Failed to parse tags", extra={"error": str(e), "tags": tags_list} diff --git a/keep/providers/vectordev_provider/__init__.py b/keep/providers/vectordev_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keep/providers/vectordev_provider/vectordev_provider.py b/keep/providers/vectordev_provider/vectordev_provider.py new file mode 100644 index 000000000..c3d6f8a9d --- /dev/null +++ b/keep/providers/vectordev_provider/vectordev_provider.py @@ -0,0 +1,64 @@ +import dataclasses +import json + +import pydantic + +from keep.api.models.alert import AlertDto +from keep.contextmanager.contextmanager import ContextManager +from keep.providers.base.base_provider import BaseProvider +from keep.providers.models.provider_config import ProviderConfig + + +@pydantic.dataclasses.dataclass +class VectordevProviderAuthConfig: + api_key: str = dataclasses.field( + metadata={"required": True, "description": "API key", "sensitive": True} + ) + + +class VectordevProvider(BaseProvider): + PROVIDER_DISPLAY_NAME = "Vector" + PROVIDER_CATEGORY = ["Monitoring", "Developer Tools"] + PROVIDER_COMING_SOON = True + + def __init__( + self, context_manager: ContextManager, provider_id: str, config: ProviderConfig + ): + super().__init__(context_manager, provider_id, config) + + def validate_config(self): + self.authentication_config = VectordevProviderAuthConfig( + **self.config.authentication + ) + + def _format_alert( + event: list[dict], provider_instance: "BaseProvider" = None + ) -> AlertDto | list[AlertDto]: + events = [] + # event is a list of events + for e in event: + event_json = None + try: + event_json = json.loads(e.get("message")) + except json.JSONDecodeError: + pass + + events.append( + AlertDto( + name="", + host=e.get("host"), + message=e.get("message"), + description=e.get("message"), + lastReceived=e.get("timestamp"), + source_type=e.get("source_type"), + source=["vectordev"], + original_event=event_json, + ) + ) + return events + + def dispose(self): + """ + No need to dispose of anything, so just do nothing. + """ + pass diff --git a/pyproject.toml b/pyproject.toml index 74e84ea8d..3b537a696 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.31.6" +version = "0.31.7" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] packages = [{include = "keep"}] diff --git a/tests/fixtures/client.py b/tests/fixtures/client.py index e71a3a2c1..b1787a292 100644 --- a/tests/fixtures/client.py +++ b/tests/fixtures/client.py @@ -38,9 +38,14 @@ def test_app(monkeypatch, request): if "keep.api.api" in sys.modules: importlib.reload(sys.modules["keep.api.api"]) + if "keep.api.config" in sys.modules: + importlib.reload(sys.modules["keep.api.config"]) + # Import and return the app instance from keep.api.api import get_app + from keep.api.config import provision_resources + provision_resources() app = get_app() # Manually trigger the startup event diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py index 6603f6bf8..679c9b75d 100644 --- a/tests/test_provisioning.py +++ b/tests/test_provisioning.py @@ -141,6 +141,11 @@ def test_reprovision_workflow(monkeypatch, db_session, client, test_app): for event_handler in app.router.on_startup: asyncio.run(event_handler()) + # manually trigger the provision resources + from keep.api.config import provision_resources + + provision_resources() + client = TestClient(get_app()) response = client.get("/workflows", headers={"x-api-key": "someapikey"}) @@ -210,6 +215,11 @@ def test_reprovision_provider(monkeypatch, db_session, client, test_app): for event_handler in app.router.on_startup: asyncio.run(event_handler()) + # manually trigger the provision resources + from keep.api.config import provision_resources + + provision_resources() + client = TestClient(app) # Step 3: Verify if the new provider is provisioned after reloading @@ -293,6 +303,11 @@ def test_reprovision_dashboard(monkeypatch, db_session, client, test_app): for event_handler in app.router.on_startup: asyncio.run(event_handler()) + # manually trigger the provision resources + from keep.api.config import provision_resources + + provision_resources() + client = TestClient(app) # Step 3: Verify if the new dashboard is provisioned after reloading