-
- {/*
Providers */}
- {/*
- */}
-
-
-
setProvidersSearchString(e.target.value)}
- />
-
- Alert
- Messaging
- Ticketing
- Data
-
+
+
+
-
-
{children}
-
-
+
+
);
}
diff --git a/keep-ui/app/providers/page.client.tsx b/keep-ui/app/providers/page.client.tsx
index b67ca6d09..5db731a03 100644
--- a/keep-ui/app/providers/page.client.tsx
+++ b/keep-ui/app/providers/page.client.tsx
@@ -1,6 +1,5 @@
"use client";
import {
- Providers,
defaultProvider,
Provider,
ProvidersResponse,
@@ -13,7 +12,7 @@ import ProvidersTiles from "./providers-tiles";
import React, { useState, Suspense, useContext, useEffect } from "react";
import useSWR from "swr";
import Loading from "../loading";
-import { LayoutContext } from "./context";
+import { useFilterContext } from "./filter-context";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
@@ -140,7 +139,7 @@ export default function ProvidersPage({
isSlowLoading,
isLocalhost,
} = useFetchProviders();
- const { searchProviderString, selectedTags } = useContext(LayoutContext);
+ const { providersSearchString, providersSelectedTags } = useFilterContext();
const router = useRouter();
useEffect(() => {
if (searchParams?.oauth === "failure") {
@@ -154,14 +153,14 @@ export default function ProvidersPage({
});
}
}, [searchParams]);
-
+ if (error) {
+ throw new KeepApiError(error.message, `${getApiURL()}/providers`, `Failed to query ${getApiURL()}/providers, is Keep API up?`);
+ }
if (status === "loading") return
;
if (status === "unauthenticated") router.push("/signin");
if (!providers || !installedProviders || providers.length <= 0)
return
;
- if (error) {
- throw new KeepApiError(error.message, `${getApiURL()}/providers`);
- }
+
const addProvider = (provider: Provider) => {
setInstalledProviders((prevProviders) => {
@@ -183,15 +182,15 @@ export default function ProvidersPage({
const searchProviders = (provider: Provider) => {
return (
- !searchProviderString ||
- provider.type?.toLowerCase().includes(searchProviderString.toLowerCase())
+ !providersSearchString ||
+ provider.type?.toLowerCase().includes(providersSearchString.toLowerCase())
);
};
const searchTags = (provider: Provider) => {
return (
- selectedTags.length === 0 ||
- provider.tags.some((tag) => selectedTags.includes(tag))
+ providersSelectedTags.length === 0 ||
+ provider.tags.some((tag) => providersSelectedTags.includes(tag))
);
};
diff --git a/keep-ui/app/providers/provider-tile.tsx b/keep-ui/app/providers/provider-tile.tsx
index e25c6a636..d2cc54101 100644
--- a/keep-ui/app/providers/provider-tile.tsx
+++ b/keep-ui/app/providers/provider-tile.tsx
@@ -14,6 +14,7 @@ import {
CircleStackIcon,
QueueListIcon,
TicketIcon,
+ MapIcon,
} from "@heroicons/react/20/solid";
import "./provider-tile.css";
import moment from "moment";
@@ -199,6 +200,8 @@ export default function ProviderTile({ provider, onClick }: Props) {
? TicketIcon
: tag === "queue"
? QueueListIcon
+ : tag === "topology"
+ ? MapIcon
: ChatBubbleBottomCenterIcon;
return (
{
+ const { useAllAlerts } = useAlerts();
+ const { data: alerts, mutate } = useAllAlerts("feed");
+ const { data: pollAlerts } = useAlertPolling();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (pollAlerts) {
+ mutate();
+ }
+ }, [pollAlerts, mutate]);
+
+ const relevantAlerts = alerts?.filter((alert) => alert.service === data.service);
+
+ const handleClick = () => {
+ router.push(
+ `/alerts/feed?cel=service%3D%3D${encodeURIComponent(`"${data.service}"`)}`
+ );
+ };
+
+ const alertCount = relevantAlerts?.length || 0;
+ const badgeColor = alertCount < THRESHOLD ? "bg-orange-500" : "bg-red-500";
+
+ return (
+
+ {data.service}
+ {alertCount > 0 && (
+
+ {alertCount}
+
+ )}
+
+
+
+ );
+};
+
+export default CustomNode;
diff --git a/keep-ui/app/topology/layout.tsx b/keep-ui/app/topology/layout.tsx
new file mode 100644
index 000000000..ab479c40f
--- /dev/null
+++ b/keep-ui/app/topology/layout.tsx
@@ -0,0 +1,5 @@
+export default function Layout({ children }: { children: any }) {
+ return (
+ {children}
+ );
+}
diff --git a/keep-ui/app/topology/models.tsx b/keep-ui/app/topology/models.tsx
new file mode 100644
index 000000000..ebd15ebed
--- /dev/null
+++ b/keep-ui/app/topology/models.tsx
@@ -0,0 +1,20 @@
+export interface TopologyServiceDependency {
+ serviceId: string;
+ serviceName: string;
+ protocol?: string;
+}
+
+export interface TopologyService {
+ id: string;
+ source_provider_id?: string;
+ repository?: string;
+ tags?: string[];
+ service: string;
+ display_name: string;
+ description?: string;
+ team?: string;
+ application?: string;
+ email?: string;
+ slack?: string;
+ dependencies: TopologyServiceDependency[];
+}
diff --git a/keep-ui/app/topology/page.tsx b/keep-ui/app/topology/page.tsx
new file mode 100644
index 000000000..497f57a3f
--- /dev/null
+++ b/keep-ui/app/topology/page.tsx
@@ -0,0 +1,16 @@
+import { Title } from "@tremor/react";
+import TopologyPage from "./topology";
+
+export default function Page() {
+ return (
+ <>
+ Service Topology
+
+ >
+ );
+}
+
+export const metadata = {
+ title: "Keep - Service Topology",
+ description: "See service topology and information about your services",
+};
diff --git a/keep-ui/app/topology/styles.tsx b/keep-ui/app/topology/styles.tsx
new file mode 100644
index 000000000..36090ebcc
--- /dev/null
+++ b/keep-ui/app/topology/styles.tsx
@@ -0,0 +1,28 @@
+import { MarkerType } from "@xyflow/react";
+
+export const nodeWidth = 220;
+export const nodeHeight = 80;
+
+// Edge No Hover
+export const edgeLabelBgStyleNoHover = {
+ strokeWidth: 1,
+ strokeDasharray: "5,5",
+ stroke: "#b1b1b7", // default graph stroke line color
+};
+export const edgeLabelBgBorderRadiusNoHover = 10;
+export const edgeLabelBgPaddingNoHover: [number, number] = [10, 5];
+export const edgeMarkerEndNoHover = {
+ type: MarkerType.ArrowClosed,
+};
+
+// Edge Hover
+export const edgeLabelBgStyleHover = {
+ ...edgeLabelBgStyleNoHover,
+ stroke: "none",
+ fill: "orange",
+ color: "white",
+};
+export const edgeMarkerEndHover = {
+ ...edgeMarkerEndNoHover,
+ color: "orange",
+};
diff --git a/keep-ui/app/topology/topology.css b/keep-ui/app/topology/topology.css
new file mode 100644
index 000000000..dcea1c6a6
--- /dev/null
+++ b/keep-ui/app/topology/topology.css
@@ -0,0 +1,10 @@
+.react-flow__handle-left,
+.react-flow__handle-right,
+.react-flow__handle-top,
+.react-flow__handle-bottom {
+ opacity: 0;
+}
+
+.react-flow__edge.selectable {
+ cursor: default;
+}
diff --git a/keep-ui/app/topology/topology.tsx b/keep-ui/app/topology/topology.tsx
new file mode 100644
index 000000000..ba7c26e57
--- /dev/null
+++ b/keep-ui/app/topology/topology.tsx
@@ -0,0 +1,236 @@
+"use client";
+import React, { useCallback, useEffect, useState } from "react";
+import {
+ Background,
+ BackgroundVariant,
+ Controls,
+ Edge,
+ Node,
+ ReactFlow,
+ ReactFlowInstance,
+ ReactFlowProvider,
+} from "@xyflow/react";
+import dagre, { graphlib } from "@dagrejs/dagre";
+import "@xyflow/react/dist/style.css";
+import CustomNode from "./custom-node";
+import { Card, TextInput } from "@tremor/react";
+import {
+ edgeLabelBgPaddingNoHover,
+ edgeLabelBgStyleNoHover,
+ edgeLabelBgBorderRadiusNoHover,
+ edgeMarkerEndNoHover,
+ edgeLabelBgStyleHover,
+ edgeMarkerEndHover,
+ nodeHeight,
+ nodeWidth,
+} from "./styles";
+import "./topology.css";
+import { useTopology } from "utils/hooks/useTopology";
+import Loading from "app/loading";
+import { EmptyStateCard } from "@/components/ui/EmptyStateCard";
+import { useRouter } from "next/navigation";
+
+interface Props {
+ providerId?: string;
+ service?: string;
+ environment?: string;
+ showSearch?: boolean;
+}
+
+// Function to create a Dagre layout
+const dagreGraph = new graphlib.Graph();
+dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+const getLayoutedElements = (nodes: any[], edges: any[]) => {
+ dagreGraph.setGraph({ rankdir: "LR", nodesep: 50, ranksep: 200 });
+
+ nodes.forEach((node) => {
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+ });
+
+ edges.forEach((edge) => {
+ dagreGraph.setEdge(edge.source, edge.target);
+ });
+
+ dagre.layout(dagreGraph);
+
+ nodes.forEach((node) => {
+ const nodeWithPosition = dagreGraph.node(node.id);
+ node.targetPosition = "left";
+ node.sourcePosition = "right";
+
+ node.position = {
+ x: nodeWithPosition.x - nodeWidth / 2,
+ y: nodeWithPosition.y - nodeHeight / 2,
+ };
+
+ return node;
+ });
+
+ return { nodes, edges };
+};
+
+const TopologyPage = ({
+ providerId,
+ service,
+ environment,
+ showSearch = true,
+}: Props) => {
+ const router = useRouter();
+ // State for nodes and edges
+ const [nodes, setNodes] = useState([]);
+ const [edges, setEdges] = useState([]);
+ const [serviceInput, setServiceInput] = useState("");
+ const [reactFlowInstance, setReactFlowInstance] =
+ useState>();
+
+ const { topologyData, error, isLoading } = useTopology(
+ providerId,
+ service,
+ environment
+ );
+
+ const onEdgeHover = (eventType: "enter" | "leave", edge: Edge) => {
+ const newEdges = [...edges];
+ const currentEdge = newEdges.find((e) => e.id === edge.id);
+ if (currentEdge) {
+ currentEdge.style = eventType === "enter" ? { stroke: "orange" } : {};
+ currentEdge.labelBgStyle =
+ eventType === "enter" ? edgeLabelBgStyleHover : edgeLabelBgStyleNoHover;
+ currentEdge.markerEnd =
+ eventType === "enter" ? edgeMarkerEndHover : edgeMarkerEndNoHover;
+ currentEdge.labelStyle = eventType === "enter" ? { fill: "white" } : {};
+ setEdges(newEdges);
+ }
+ };
+
+ const zoomToNode = useCallback(
+ (nodeId: string) => {
+ const node = reactFlowInstance?.getNode(nodeId);
+ if (node && reactFlowInstance) {
+ reactFlowInstance.setCenter(node.position.x, node.position.y);
+ }
+ },
+ [reactFlowInstance]
+ );
+
+ useEffect(() => {
+ if (serviceInput) {
+ zoomToNode(serviceInput);
+ }
+ }, [serviceInput, zoomToNode]);
+
+ useEffect(() => {
+ if (!topologyData) return;
+
+ // Create nodes from service definitions
+ const newNodes = topologyData.map((service) => ({
+ id: service.service.toString(),
+ type: "customNode",
+ data: service,
+ position: { x: 0, y: 0 }, // Dagre will handle the actual positioning
+ }));
+
+ // Create edges from service dependencies
+ const edgeMap = new Map();
+
+ topologyData.forEach((service) => {
+ service.dependencies.forEach((dependency) => {
+ const dependencyService = topologyData.find(
+ (s) => s.service === dependency.serviceName
+ );
+ const edgeId = `${service.service}_${dependency.protocol}_${
+ dependencyService
+ ? dependencyService.service
+ : dependency.serviceId.toString()
+ }`;
+ if (!edgeMap.has(edgeId)) {
+ edgeMap.set(edgeId, {
+ id: edgeId,
+ source: service.service.toString(),
+ target: dependency.serviceName.toString(),
+ label: dependency.protocol === "unknown" ? "" : dependency.protocol,
+ animated: false,
+ labelBgPadding: edgeLabelBgPaddingNoHover,
+ labelBgStyle: edgeLabelBgStyleNoHover,
+ labelBgBorderRadius: edgeLabelBgBorderRadiusNoHover,
+ markerEnd: edgeMarkerEndNoHover,
+ });
+ }
+ });
+ });
+
+ const newEdges = Array.from(edgeMap.values());
+ const layoutedElements = getLayoutedElements(newNodes, newEdges);
+ setNodes(layoutedElements.nodes);
+ setEdges(layoutedElements.edges);
+ }, [topologyData]);
+
+ if (isLoading) return ;
+ if (error)
+ return (
+
+ {
+ window.open("https://slack.keephq.dev/", "_blank");
+ }}
+ />
+
+ );
+
+ return (
+
+ {showSearch && (
+
+
+
+ )}
+
+ onEdgeHover("enter", edge)}
+ onEdgeMouseLeave={(_event, edge) => onEdgeHover("leave", edge)}
+ nodeTypes={{ customNode: CustomNode }}
+ onInit={(instance) => {
+ setReactFlowInstance(instance);
+ }}
+ >
+
+
+
+
+ {!topologyData ||
+ (topologyData?.length === 0 && (
+ <>
+
+
+
+ router.push("/providers?labels=topology")}
+ />
+
+
+ >
+ ))}
+
+ );
+};
+
+export default TopologyPage;
diff --git a/keep-ui/app/workflows/builder/builder-card.tsx b/keep-ui/app/workflows/builder/builder-card.tsx
index e27e60541..b1bf5310c 100644
--- a/keep-ui/app/workflows/builder/builder-card.tsx
+++ b/keep-ui/app/workflows/builder/builder-card.tsx
@@ -50,7 +50,8 @@ export function BuilderCard({
if (error) {
throw new KeepApiError(
"The builder has failed to load providers",
- `${apiUrl}/providers`
+ `${apiUrl}/providers`,
+ `Failed to query ${apiUrl}/providers, is Keep API up?`
);
}
diff --git a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx
index ff104f7d4..b08d66061 100644
--- a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx
+++ b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx
@@ -112,7 +112,14 @@ export const CustomPresetAlertLinks = ({
const [presetsOrder, setPresetsOrder] = useState([]);
// Check for noisy presets and control sound playback
- const anyNoisyNow = presets.some(preset => preset.should_do_noise_now);
+ const anyNoisyNow = presets.some((preset) => preset.should_do_noise_now);
+
+ const checkValidPreset = (preset: Preset) => {
+ if (!preset.is_private) {
+ return true;
+ }
+ return preset && preset.created_by == session?.user?.email;
+ };
useEffect(() => {
const filteredLS = presetsOrderFromLS.filter(
@@ -120,11 +127,11 @@ export const CustomPresetAlertLinks = ({
);
// Combine live presets and local storage order
- const combinedOrder = presets.reduce((acc, preset) => {
- if (!acc.find(p => p.id === preset.id)) {
+ const combinedOrder = presets.reduce((acc, preset: Preset) => {
+ if (!acc.find((p) => p.id === preset.id)) {
acc.push(preset);
}
- return acc;
+ return acc.filter((preset) => checkValidPreset(preset));
}, [...filteredLS]);
// Only update state if there's an actual change to prevent infinite loops
diff --git a/keep-ui/components/navbar/DashboardLinks.tsx b/keep-ui/components/navbar/DashboardLinks.tsx
index 4e1591abf..7f8fb865a 100644
--- a/keep-ui/components/navbar/DashboardLinks.tsx
+++ b/keep-ui/components/navbar/DashboardLinks.tsx
@@ -136,14 +136,15 @@ export const DashboardLinks = ({ session }: DashboardProps) => {
)): Dashboards will appear here when saved. }
+
+ />
);
diff --git a/keep-ui/components/navbar/IncidentLinks.tsx b/keep-ui/components/navbar/IncidentLinks.tsx
index d0ee861f6..15a310d76 100644
--- a/keep-ui/components/navbar/IncidentLinks.tsx
+++ b/keep-ui/components/navbar/IncidentLinks.tsx
@@ -8,7 +8,7 @@ import { Session } from "next-auth";
import { Disclosure } from "@headlessui/react";
import { IoChevronUp } from "react-icons/io5";
import classNames from "classnames";
-import { useIncidents } from "utils/hooks/useIncidents";
+import { useIncidents, usePollIncidents } from "utils/hooks/useIncidents";
import { MdNearbyError } from "react-icons/md";
type IncidentsLinksProps = { session: Session | null };
@@ -16,7 +16,8 @@ const SHOW_N_INCIDENTS = 3;
export const IncidentsLinks = ({ session }: IncidentsLinksProps) => {
const isNOCRole = session?.userRole === "noc";
- const { data: incidents } = useIncidents();
+ const { data: incidents, mutate } = useIncidents();
+ usePollIncidents(mutate)
const currentPath = usePathname();
if (isNOCRole) {
diff --git a/keep-ui/components/navbar/Navbar.tsx b/keep-ui/components/navbar/Navbar.tsx
index 074d7d956..c7bddd903 100644
--- a/keep-ui/components/navbar/Navbar.tsx
+++ b/keep-ui/components/navbar/Navbar.tsx
@@ -19,8 +19,8 @@ export default async function NavbarInner() {