diff --git a/docs/deployment/authentication/azuread-auth.mdx b/docs/deployment/authentication/azuread-auth.mdx index 2ee4fd365..3dae091ab 100644 --- a/docs/deployment/authentication/azuread-auth.mdx +++ b/docs/deployment/authentication/azuread-auth.mdx @@ -8,7 +8,7 @@ This feature is a part of Keep Enterprise. Talk to us to get access: https://www.keephq.dev/meet-keep -Keep supports enterprise authentication through Azure Active Directory (Azure AD), enabling organizations to use their existing Microsoft identity platform for secure access management. +Keep supports enterprise authentication through Azure Entre ID (formerly known as Azure AD), enabling organizations to use their existing Microsoft identity platform for secure access management. ## When to Use @@ -49,9 +49,9 @@ We use "Web" platform instead of "Single Page Application (SPA)" because Keep's -For localhost, the redirect would be http://localhost:3000/api/auth/callback/azure-ad +For localhost, the redirect would be http://localhost:3000/api/auth/callback/microsoft-entra-id -For production, it should be something like http://your_keep_frontend_domain/api/auth/callback/azure-ad +For production, it should be something like http://your_keep_frontend_domain/api/auth/callback/microsoft-entra-id diff --git a/docs/images/azuread_3.png b/docs/images/azuread_3.png index c7466ca36..4e91c7263 100644 Binary files a/docs/images/azuread_3.png and b/docs/images/azuread_3.png differ diff --git a/keep-ui/app/(keep)/alerts/alert-assignee.tsx b/keep-ui/app/(keep)/alerts/alert-assignee.tsx index c3dc67627..525d00224 100644 --- a/keep-ui/app/(keep)/alerts/alert-assignee.tsx +++ b/keep-ui/app/(keep)/alerts/alert-assignee.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { NameInitialsAvatar } from "react-name-initials-avatar"; -import { useUsers } from "utils/hooks/useUsers"; +import { useUsers } from "@/entities/users/model/useUsers"; interface Props { assignee: string | undefined; diff --git a/keep-ui/app/(keep)/alerts/alert-severity-border.tsx b/keep-ui/app/(keep)/alerts/alert-severity-border.tsx index 34b0b6d3e..f38add3d4 100644 --- a/keep-ui/app/(keep)/alerts/alert-severity-border.tsx +++ b/keep-ui/app/(keep)/alerts/alert-severity-border.tsx @@ -1,27 +1,59 @@ import clsx from "clsx"; import { Severity } from "./models"; +const getSeverityBgClassName = (severity?: Severity) => { + switch (severity) { + case "critical": + return "bg-red-500"; + case "high": + case "error": + return "bg-orange-500"; + case "warning": + return "bg-yellow-500"; + case "info": + return "bg-blue-500"; + default: + return "bg-emerald-500"; + } +}; + +const getSeverityLabelClassName = (severity?: Severity) => { + switch (severity) { + case "critical": + return "bg-red-100"; + case "high": + case "error": + return "bg-orange-100"; + case "warning": + return "bg-yellow-100"; + case "info": + return "bg-blue-100"; + default: + return "bg-emerald-100"; + } +}; + +const getSeverityTextClassName = (severity?: Severity) => { + switch (severity) { + case "critical": + return "text-red-500"; + case "high": + case "error": + return "text-orange-500"; + case "warning": + return "text-amber-900"; + case "info": + return "text-blue-500"; + default: + return "text-emerald-500"; + } +}; + export function AlertSeverityBorder({ severity, }: { severity: Severity | undefined; }) { - const getSeverityBgClassName = (severity?: Severity) => { - switch (severity) { - case "critical": - return "bg-red-500"; - case "high": - case "error": - return "bg-orange-500"; - case "warning": - return "bg-yellow-500"; - case "info": - return "bg-blue-500"; - default: - return "bg-emerald-500"; - } - }; - return (
); } + +export function AlertSeverityBorderIcon({ severity }: { severity: Severity }) { + return ( +
+ ); +} + +export function AlertSeverityLabel({ severity }: { severity: Severity }) { + return ( + +
+ + {severity} + + + ); +} diff --git a/keep-ui/app/(keep)/alerts/alert-sidebar.tsx b/keep-ui/app/(keep)/alerts/alert-sidebar.tsx index bf985960e..41ae7b46c 100644 --- a/keep-ui/app/(keep)/alerts/alert-sidebar.tsx +++ b/keep-ui/app/(keep)/alerts/alert-sidebar.tsx @@ -2,12 +2,20 @@ import { Fragment } from "react"; import Image from "next/image"; import { Dialog, Transition } from "@headlessui/react"; import { AlertDto } from "./models"; -import { Button, Title, Card, Badge } from "@tremor/react"; +import { Button, Title, Badge } from "@tremor/react"; import { IoMdClose } from "react-icons/io"; import AlertTimeline from "./alert-timeline"; import { useAlerts } from "utils/hooks/useAlerts"; import { TopologyMap } from "../topology/ui/map"; import { TopologySearchProvider } from "@/app/(keep)/topology/TopologySearchContext"; +import { + AlertSeverityBorderIcon, + AlertSeverityLabel, +} from "./alert-severity-border"; +import { FieldHeader } from "@/shared/ui/FieldHeader"; +import { QuestionMarkCircleIcon } from "@heroicons/react/20/solid"; +import { Tooltip } from "@/shared/ui/Tooltip"; +import { Link } from "@/components/ui"; type AlertSidebarProps = { isOpen: boolean; @@ -57,11 +65,14 @@ const AlertSidebar = ({ isOpen, toggle, alert }: AlertSidebarProps) => { {/*Will add soon*/} {/**/} {/**/} - - Alert Details - - Beta - + + {alert?.severity && ( + + )} + {alert?.name ? alert.name : "Alert Details"}
@@ -72,43 +83,63 @@ const AlertSidebar = ({ isOpen, toggle, alert }: AlertSidebarProps) => {
{alert && (
- -
-

- Name: {alert.name} -

-

- Service: {alert.service} -

-

- Severity: {alert.severity} -

-

- {alert.source![0]} -

-

- Description: {alert.description} -

-

- Fingerprint: {alert.fingerprint} -

-
-
- - - +
+

+ Name + {alert.name} +

+

+ Service + + {alert.service} + +

+

+ Source + {alert.source![0]} +

+

+ Description + {alert.description} +

+

+ + Fingerprint + + Fingerprints are unique identifiers associated with + alert instances in Keep. Every provider declares the + fields fingerprints are calculated upon.{" "} + + Docs + + + } + className="z-50" + > + + + + {alert.fingerprint} +

+
+ Related Services = ({ if (a.label === "n/a") return 1; if (b.label === "n/a") return -1; const orderDiff = - getSeverityOrder(a.label) - getSeverityOrder(b.label); + getSeverityOrder(b.label) - getSeverityOrder(a.label); if (orderDiff !== 0) return orderDiff; return b.count - a.count; }); @@ -253,7 +249,7 @@ export const AlertFacets: React.FC = ({ showSkeleton={showSkeleton} /> diff --git a/keep-ui/app/(keep)/alerts/alert-table-facet-utils.tsx b/keep-ui/app/(keep)/alerts/alert-table-facet-utils.tsx index a26f8ab1d..51c5ff196 100644 --- a/keep-ui/app/(keep)/alerts/alert-table-facet-utils.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table-facet-utils.tsx @@ -1,16 +1,5 @@ import { FacetFilters } from "./alert-table-facet-types"; -import { AlertDto, Severity } from "./models"; -import { - ChevronDownIcon, - ChevronRightIcon, - UserCircleIcon, - BellIcon, - ExclamationCircleIcon, - CheckCircleIcon, - CircleStackIcon, - BellSlashIcon, - FireIcon, -} from "@heroicons/react/24/outline"; +import { AlertDto } from "./models"; import { isQuickPresetRange } from "@/components/ui/DateRangePicker"; export const getFilteredAlertsForFacet = ( @@ -83,32 +72,6 @@ export const getFilteredAlertsForFacet = ( }); }; -export const getStatusIcon = (status: string) => { - switch (status.toLowerCase()) { - case "firing": - return ExclamationCircleIcon; - case "resolved": - return CheckCircleIcon; - case "acknowledged": - return CircleStackIcon; - default: - return CircleStackIcon; - } -}; - -export const getStatusColor = (status: string) => { - switch (status.toLowerCase()) { - case "firing": - return "red"; - case "resolved": - return "green"; - case "acknowledged": - return "blue"; - default: - return "gray"; - } -}; - export const getSeverityOrder = (severity: string): number => { switch (severity) { case "low": diff --git a/keep-ui/app/(keep)/alerts/alert-table-facet-value.tsx b/keep-ui/app/(keep)/alerts/alert-table-facet-value.tsx index 15bc1d991..83b588c03 100644 --- a/keep-ui/app/(keep)/alerts/alert-table-facet-value.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table-facet-value.tsx @@ -1,17 +1,22 @@ -import React from "react"; +import React, { useCallback, useMemo } from "react"; import { Icon } from "@tremor/react"; import Image from "next/image"; import { Text } from "@tremor/react"; import { FacetValueProps } from "./alert-table-facet-types"; -import { getStatusIcon, getStatusColor } from "./alert-table-facet-utils"; -import { - UserCircleIcon, - BellIcon, - BellSlashIcon, - FireIcon, -} from "@heroicons/react/24/outline"; -import AlertSeverity from "./alert-severity"; +import { getStatusIcon, getStatusColor } from "@/shared/lib/status-utils"; +import { BellIcon, BellSlashIcon, FireIcon } from "@heroicons/react/24/outline"; import { Severity } from "./models"; +import { AlertSeverityBorderIcon } from "./alert-severity-border"; +import clsx from "clsx"; +import { useIncidents } from "@/utils/hooks/useIncidents"; +import { getIncidentName } from "@/entities/incidents/lib/utils"; +import { UserStatefulAvatar } from "@/entities/users/ui"; +import { useUser } from "@/entities/users/model/useUser"; + +const AssigneeLabel = ({ email }: { email: string }) => { + const user = useUser(email); + return user ? user.name : email; +}; export const FacetValue: React.FC = ({ label, @@ -22,6 +27,31 @@ export const FacetValue: React.FC = ({ showIcon = false, facetFilters, }) => { + const { data: incidents } = useIncidents( + true, + 100, + undefined, + undefined, + undefined, + { + revalidateOnFocus: false, + } + ); + + const incidentMap = useMemo(() => { + return new Map( + incidents?.items.map((incident) => [ + incident.id.replaceAll("-", ""), + incident, + ]) || [] + ); + }, [incidents]); + + const incident = useMemo( + () => (facetKey === "incident" ? incidentMap.get(label) : null), + [incidentMap, facetKey, label] + ); + const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); onSelect(label, false, false); @@ -41,6 +71,95 @@ export const FacetValue: React.FC = ({ } }; + const getValueIcon = useCallback( + (label: string, facetKey: string) => { + if (facetKey === "source") { + return ( + {label} + ); + } + if (facetKey === "severity") { + return ; + } + if (facetKey === "assignee") { + return ; + } + if (facetKey === "status") { + return ( + + ); + } + if (facetKey === "dismissed") { + return ( + + ); + } + if (facetKey === "incident") { + if (incident) { + return ( + + ); + } + return ( + + ); + } + return null; + }, + [incident] + ); + + const humanizeLabel = useCallback( + (label: string, facetKey: string) => { + if (facetKey === "assignee") { + if (label === "n/a") { + return "Not assigned"; + } + return ; + } + if (facetKey === "incident") { + if (label === "n/a") { + return "No incident"; + } + if (incident) { + return getIncidentName(incident); + } else { + return label; + } + } + if (facetKey === "dismissed") { + return label === "true" ? "Dismissed" : "Not dismissed"; + } + return {label}; + }, + [incident] + ); + const currentFilter = facetFilters[facetKey] || []; const isValueSelected = !currentFilter?.length || currentFilter.includes(label); @@ -61,54 +180,13 @@ export const FacetValue: React.FC = ({ />
-
+
{showIcon && ( -
- {facetKey === "source" && ( - {label} - )} - {facetKey === "severity" && ( - - )} - {facetKey === "assignee" && ( - - )} - {facetKey === "status" && ( - - )} - {facetKey === "dismissed" && ( - - )} - {facetKey === "incident" && ( - - )} +
+ {getValueIcon(label, facetKey)}
)} - {label} + {humanizeLabel(label, facetKey)}
diff --git a/keep-ui/app/(keep)/alerts/alert-table-headers.tsx b/keep-ui/app/(keep)/alerts/alert-table-headers.tsx index d1606ffc9..b70e563cd 100644 --- a/keep-ui/app/(keep)/alerts/alert-table-headers.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table-headers.tsx @@ -63,8 +63,8 @@ const DraggableHeaderCell = ({ column.getIsPinned() !== false ? "default" : isDragging - ? "grabbing" - : "grab", + ? "grabbing" + : "grab", }; // TODO: fix multiple pinned columns @@ -82,8 +82,11 @@ const DraggableHeaderCell = ({ >
{/* Flex container */} + {children} {/* Column name or text */} {column.getCanSort() && ( // Sorting icon to the left <> + {/* Custom styled vertical line separator */} +
{ @@ -96,8 +99,8 @@ const DraggableHeaderCell = ({ column.getNextSortingOrder() === "asc" ? "Sort ascending" : column.getNextSortingOrder() === "desc" - ? "Sort descending" - : "Clear sort" + ? "Sort descending" + : "Clear sort" } > {/* Icon logic */} @@ -111,11 +114,8 @@ const DraggableHeaderCell = ({ )} - {/* Custom styled vertical line separator */} -
)} - {children} {/* Column name or text */}
{column.getIsPinned() === false && ( diff --git a/keep-ui/app/(keep)/alerts/alert-table-utils.tsx b/keep-ui/app/(keep)/alerts/alert-table-utils.tsx index bd41cbc22..0f5924559 100644 --- a/keep-ui/app/(keep)/alerts/alert-table-utils.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table-utils.tsx @@ -22,6 +22,7 @@ import { MdOutlineNotificationsOff, } from "react-icons/md"; import { AlertSeverityBorder } from "./alert-severity-border"; +import { getStatusIcon, getStatusColor } from "@/shared/lib/status-utils"; export const DEFAULT_COLS = [ "severity", @@ -272,6 +273,17 @@ export const useAlertTableCols = ( id: "status", minSize: 100, header: "Status", + cell: (context) => ( + + + {context.getValue()} + + ), }), columnHelper.accessor("lastReceived", { id: "lastReceived", diff --git a/keep-ui/app/(keep)/alerts/alert-table.tsx b/keep-ui/app/(keep)/alerts/alert-table.tsx index 24b66ec7e..5a27259da 100644 --- a/keep-ui/app/(keep)/alerts/alert-table.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table.tsx @@ -1,8 +1,7 @@ import { useRef, useState } from "react"; -import { Table, Callout, Card } from "@tremor/react"; +import { Table, Card } from "@tremor/react"; import { AlertsTableBody } from "./alerts-table-body"; import { AlertDto } from "./models"; -import { CircleStackIcon } from "@heroicons/react/24/outline"; import { getCoreRowModel, useReactTable, @@ -265,15 +264,20 @@ export function AlertTable({ }; return ( -
- -
- {/* Setting min-h-10 to avoid jumping when actions are shown */} + // Add h-screen to make it full height and remove the default flex-col gap +
+ {/* Add padding to account for any top nav/header */} +
+ +
+ + {/* Make actions/presets section fixed height */} +
{selectedRowIds.length ? ( )}
-
-
- -
-
- -
- {/* For dynamic preset, add alert tabs*/} - {!presetStatic && ( - - )} -
- - - -
-
- + + {/* Main content area - uses flex-grow to fill remaining space */} +
+
+ {/* Facets sidebar */} +
+ +
+ + {/* Table section */} +
+ +
+ {!presetStatic && ( +
+ +
+ )} + +
+ + {/* Make table wrapper scrollable */} +
+ + + +
+
+
+ +
-
+ + {/* Pagination footer - fixed height */} +
+ setIsSidebarOpen(false)} diff --git a/keep-ui/app/(keep)/alerts/alert-timeline.tsx b/keep-ui/app/(keep)/alerts/alert-timeline.tsx index 02fe484e0..6653ea45e 100644 --- a/keep-ui/app/(keep)/alerts/alert-timeline.tsx +++ b/keep-ui/app/(keep)/alerts/alert-timeline.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Subtitle, Button } from "@tremor/react"; +import { Subtitle, Button, Card, Title } from "@tremor/react"; import { Chrono } from "react-chrono"; import Image from "next/image"; import { ArrowPathIcon } from "@heroicons/react/24/outline"; @@ -71,9 +71,9 @@ const AlertTimeline: React.FC = ({ )); return ( -
+
- Timeline + Timeline
- {isLoading ? ( -
-

Loading...

-
- ) : ( -
- ({ - title: formatTimestamp(entry.timestamp), - })) || [] - } - hideControls - disableToolbar - borderLessCards - slideShow={false} - mode="VERTICAL" - theme={{ - primary: "orange", - secondary: "rgb(255 247 237)", - titleColor: "orange", - titleColorActive: "orange", - }} - fontSizes={{ - title: ".75rem", - }} - cardWidth={400} - cardHeight="auto" - classNames={{ - card: "hidden", - cardMedia: "hidden", - cardSubTitle: "hidden", - cardText: "hidden", - cardTitle: "hidden", - title: "mb-3", - contentDetails: "w-full !m-0", - }} - > - {content} - -
- )} + + {isLoading ? ( +
+

Loading...

+
+ ) : ( +
+ ({ + title: formatTimestamp(entry.timestamp), + })) || [] + } + hideControls + disableToolbar + borderLessCards + slideShow={false} + mode="VERTICAL" + theme={{ + primary: "orange", + secondary: "rgb(255 247 237)", + titleColor: "orange", + titleColorActive: "orange", + }} + fontSizes={{ + title: ".75rem", + }} + cardWidth={400} + cardHeight="auto" + classNames={{ + card: "hidden", + cardMedia: "hidden", + cardSubTitle: "hidden", + cardText: "hidden", + cardTitle: "hidden", + title: "mb-3", + contentDetails: "w-full !m-0", + }} + > + {content} + +
+ )} +
); }; diff --git a/keep-ui/app/(keep)/alerts/alerts-table-body.tsx b/keep-ui/app/(keep)/alerts/alerts-table-body.tsx index 0ee55b8a0..08a4c61bf 100644 --- a/keep-ui/app/(keep)/alerts/alerts-table-body.tsx +++ b/keep-ui/app/(keep)/alerts/alerts-table-body.tsx @@ -35,13 +35,15 @@ export function AlertsTableBody({ if (showEmptyState) { return ( <> -
- +
+
+ +
{modalOpen && ( - +
); } diff --git a/keep-ui/app/(keep)/incidents/[id]/alerts/incident-alerts.tsx b/keep-ui/app/(keep)/incidents/[id]/alerts/incident-alerts.tsx index 7d3d828e8..7d3f52ef2 100644 --- a/keep-ui/app/(keep)/incidents/[id]/alerts/incident-alerts.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/alerts/incident-alerts.tsx @@ -8,6 +8,7 @@ import { } from "@tanstack/react-table"; import { Card, + Icon, Table, TableBody, TableCell, @@ -33,6 +34,9 @@ import { getCommonPinningStylesAndClassNames } from "@/components/ui/table/utils import { EmptyStateCard } from "@/components/ui"; import { useRouter } from "next/navigation"; import { TablePagination } from "@/shared/ui"; +import { AlertSeverityBorder } from "@/app/(keep)/alerts/alert-severity-border"; +import { getStatusIcon } from "@/shared/lib/status-utils"; +import { getStatusColor } from "@/shared/lib/status-utils"; interface Props { incident: IncidentDto; @@ -110,15 +114,17 @@ export default function IncidentAlerts({ incident }: Props) { // /> // ), // }), - columnHelper.accessor("severity", { + columnHelper.display({ id: "severity", - header: "Severity", - minSize: 80, + maxSize: 4, + header: () => <>, cell: (context) => ( -
- -
+ ), + meta: { + tdClassName: "p-0", + thClassName: "p-0", + }, }), columnHelper.display({ id: "name", @@ -144,17 +150,28 @@ export default function IncidentAlerts({ incident }: Props) { id: "status", minSize: 100, header: "Status", + cell: (context) => ( + + + {context.getValue()} + + ), }), columnHelper.accessor("is_created_by_ai", { id: "is_created_by_ai", - header: "🔗", + header: "🔗 Correlation type", minSize: 50, cell: (context) => ( <> {context.getValue() ? ( -
🤖
+
🤖 AI
) : ( -
👨‍💻
+
👨‍💻 Manually
)} ), @@ -186,7 +203,7 @@ export default function IncidentAlerts({ incident }: Props) { }), columnHelper.display({ id: "remove", - header: "", + header: "Correlation", cell: (context) => incident.is_confirmed && (
diff --git a/keep-ui/app/(keep)/settings/auth/groups-tab.tsx b/keep-ui/app/(keep)/settings/auth/groups-tab.tsx index da420f719..dcc23901b 100644 --- a/keep-ui/app/(keep)/settings/auth/groups-tab.tsx +++ b/keep-ui/app/(keep)/settings/auth/groups-tab.tsx @@ -15,7 +15,7 @@ import { } from "@tremor/react"; import Loading from "@/app/(keep)/loading"; import { useGroups } from "utils/hooks/useGroups"; -import { useUsers } from "utils/hooks/useUsers"; +import { useUsers } from "@/entities/users/model/useUsers"; import { useRoles } from "utils/hooks/useRoles"; import { useState, useEffect, useMemo } from "react"; import GroupsSidebar from "./groups-sidebar"; diff --git a/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx b/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx index 08f69e159..69b0c4d17 100644 --- a/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx +++ b/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { Title, Subtitle, Card, TextInput } from "@tremor/react"; import { usePermissions } from "utils/hooks/usePermissions"; -import { useUsers } from "utils/hooks/useUsers"; +import { useUsers } from "@/entities/users/model/useUsers"; import { useGroups } from "utils/hooks/useGroups"; import { useRoles } from "utils/hooks/useRoles"; import { usePresets } from "utils/hooks/usePresets"; diff --git a/keep-ui/app/(keep)/settings/auth/users-settings.tsx b/keep-ui/app/(keep)/settings/auth/users-settings.tsx index 78d2952d2..54cfeb03d 100644 --- a/keep-ui/app/(keep)/settings/auth/users-settings.tsx +++ b/keep-ui/app/(keep)/settings/auth/users-settings.tsx @@ -4,7 +4,7 @@ import Loading from "@/app/(keep)/loading"; import { User as AuthUser } from "next-auth"; import { TiUserAdd } from "react-icons/ti"; import { AuthType } from "utils/authenticationType"; -import { useUsers } from "utils/hooks/useUsers"; +import { useUsers } from "@/entities/users/model/useUsers"; import { useRoles } from "utils/hooks/useRoles"; import { useGroups } from "utils/hooks/useGroups"; import { useConfig } from "utils/hooks/useConfig"; diff --git a/keep-ui/app/(signin)/signin/SignInForm.tsx b/keep-ui/app/(signin)/signin/SignInForm.tsx index d4f4dbb5c..e5a4ef1f4 100644 --- a/keep-ui/app/(signin)/signin/SignInForm.tsx +++ b/keep-ui/app/(signin)/signin/SignInForm.tsx @@ -21,7 +21,7 @@ export interface Providers { auth0?: Provider; credentials?: Provider; keycloak?: Provider; - "azure-ad"?: Provider; + "microsoft-entra-id"?: Provider; } interface SignInFormInputs { @@ -42,15 +42,11 @@ export default function SignInForm({ params }: { params?: { amt: string } }) { } = useForm(); useEffect(() => { - console.log("Fetching providers"); async function fetchProviders() { - console.log("Fetching providers 2"); const response = await getProviders(); setProviders(response as Providers); } - console.log("Fetching providers 3"); fetchProviders(); - console.log("Fetching providers 4"); }, []); useEffect(() => { @@ -69,9 +65,9 @@ export default function SignInForm({ params }: { params?: { amt: string } }) { } else if (providers.keycloak) { console.log("Signing in with keycloak provider"); signIn("keycloak", { callbackUrl: "/" }); - } else if (providers["azure-ad"]) { + } else if (providers["microsoft-entra-id"]) { console.log("Signing in with Azure AD provider"); - signIn("azure-ad", { callbackUrl: "/" }); + signIn("microsoft-entra-id", { callbackUrl: "/" }); } else if ( providers.credentials && providers.credentials.name == "NoAuth" diff --git a/keep-ui/auth.ts b/keep-ui/auth.ts index 159de7439..f0b470971 100644 --- a/keep-ui/auth.ts +++ b/keep-ui/auth.ts @@ -1,12 +1,14 @@ import NextAuth from "next-auth"; import type { NextAuthConfig } from "next-auth"; +import { customFetch } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import Keycloak from "next-auth/providers/keycloak"; import Auth0 from "next-auth/providers/auth0"; -import MicrosoftEntraID from "@auth/core/providers/microsoft-entra-id"; +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import { AuthError } from "next-auth"; import { AuthenticationError, AuthErrorCodes } from "@/errors"; -import type { JWT } from "@auth/core/jwt"; +import type { JWT } from "next-auth/jwt"; +// https://github.com/nextauthjs/next-auth/issues/11028 export class BackendRefusedError extends AuthError { static type = "BackendRefusedError"; @@ -34,6 +36,127 @@ const authType = ? AuthType.NOAUTH : (authTypeEnv as AuthType); +const proxyUrl = + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy; + +import { ProxyAgent, fetch as undici } from "undici"; +function proxyFetch( + ...args: Parameters +): ReturnType { + console.log( + "Proxy called for URL:", + args[0] instanceof Request ? args[0].url : args[0] + ); + const dispatcher = new ProxyAgent(proxyUrl!); + + if (args[0] instanceof Request) { + const request = args[0]; + // @ts-expect-error `undici` has a `duplex` option + return undici(request.url, { + ...args[1], + method: request.method, + headers: request.headers as HeadersInit, + body: request.body, + dispatcher, + }); + } + + // @ts-expect-error `undici` has a `duplex` option + return undici(args[0], { ...(args[1] || {}), dispatcher }); +} + +/** + * Creates a Microsoft Entra ID provider configuration and overrides the customFetch. + * + * SHAHAR: this is a workaround to override the customFetch symbol in the provider + * because in Microsoft entra it already has a customFetch symbol and we need to override it.s + */ +export const createAzureADProvider = () => { + if (!proxyUrl) { + console.log("Proxy is not enabled"); + } else { + console.log("Proxy is enabled:", proxyUrl); + } + + // Step 1: Create the base provider + const baseConfig = { + clientId: process.env.KEEP_AZUREAD_CLIENT_ID!, + clientSecret: process.env.KEEP_AZUREAD_CLIENT_SECRET!, + issuer: `https://login.microsoftonline.com/${process.env + .KEEP_AZUREAD_TENANT_ID!}/v2.0`, + authorization: { + params: { + scope: `api://${process.env + .KEEP_AZUREAD_CLIENT_ID!}/default openid profile email`, + }, + }, + client: { + token_endpoint_auth_method: "client_secret_post", + }, + }; + + const provider = MicrosoftEntraID(baseConfig); + // if not proxyUrl, return the provider + if (!proxyUrl) return provider; + + // Step 2: Override the `customFetch` symbol in the provider + provider[customFetch] = async (...args: Parameters) => { + const url = new URL(args[0] instanceof Request ? args[0].url : args[0]); + console.log("Custom Fetch Intercepted:", url.toString()); + + // Handle `.well-known/openid-configuration` logic + if (url.pathname.endsWith(".well-known/openid-configuration")) { + console.log("Intercepting .well-known/openid-configuration"); + const response = await proxyFetch(...args); + const json = await response.clone().json(); + const tenantRe = /microsoftonline\.com\/(\w+)\/v2\.0/; + const tenantId = baseConfig.issuer?.match(tenantRe)?.[1] ?? "common"; + const issuer = json.issuer.replace("{tenantid}", tenantId); + console.log("Modified issuer:", issuer); + return Response.json({ ...json, issuer }); + } + + // Fallback for all other requests + return proxyFetch(...args); + }; + + // Step 3: override profile since it use fetch without customFetch + provider.profile = async (profile, tokens) => { + const profilePhotoSize = 48; // Default or custom size + console.log("Fetching profile photo via proxy"); + + const response = await proxyFetch( + `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, + { headers: { Authorization: `Bearer ${tokens.access_token}` } } + ); + + let image: string | null = null; + if (response.ok && typeof Buffer !== "undefined") { + try { + const pictureBuffer = await response.arrayBuffer(); + const pictureBase64 = Buffer.from(pictureBuffer).toString("base64"); + image = `data:image/jpeg;base64,${pictureBase64}`; + } catch (error) { + console.error("Error processing profile photo:", error); + } + } + + // Ensure the returned object matches the User interface + return { + id: profile.sub, + name: profile.name, + email: profile.email, + image: image ?? null, + accessToken: tokens.access_token ?? "", // Provide empty string as fallback + }; + }; + + return provider; +}; + async function refreshAccessToken(token: any) { const issuerUrl = process.env.KEYCLOAK_ISSUER; const refreshTokenUrl = `${issuerUrl}/protocol/openid-connect/token`; @@ -159,19 +282,7 @@ const providerConfigs = { authorization: { params: { scope: "openid email profile roles" } }, }), ], - [AuthType.AZUREAD]: [ - MicrosoftEntraID({ - clientId: process.env.KEEP_AZUREAD_CLIENT_ID!, - clientSecret: process.env.KEEP_AZUREAD_CLIENT_SECRET!, - issuer: process.env.KEEP_AZUREAD_TENANT_ID!, - authorization: { - params: { - scope: `api://${process.env - .KEEP_AZUREAD_CLIENT_ID!}/default openid profile email`, - }, - }, - }), - ], + [AuthType.AZUREAD]: [createAzureADProvider()], }; // Create the config @@ -203,7 +314,25 @@ const config = { let tenantId: string | undefined = user.tenantId; let role: string | undefined = user.role; // Ensure we always have an accessToken - if (authType == AuthType.AUTH0) { + // https://github.com/nextauthjs/next-auth/discussions/4255 + if (authType === AuthType.AZUREAD) { + // Properly handle Azure AD tokens + accessToken = account.access_token; + // You might want to extract additional claims from the id_token if needed + if (account.id_token) { + try { + // Basic JWT decode (you might want to use a proper JWT library here) + const payload = JSON.parse( + Buffer.from(account.id_token.split(".")[1], "base64").toString() + ); + // Extract any additional claims you need + role = payload.roles?.[0] || "user"; + tenantId = payload.tid || undefined; + } catch (e) { + console.warn("Failed to decode id_token:", e); + } + } + } else if (authType == AuthType.AUTH0) { accessToken = account.id_token; if ((profile as any)?.keep_tenant_id) { tenantId = (profile as any).keep_tenant_id; diff --git a/keep-ui/components/navbar/UserAvatar.tsx b/keep-ui/components/navbar/UserAvatar.tsx index d90300363..c2ae3c996 100644 --- a/keep-ui/components/navbar/UserAvatar.tsx +++ b/keep-ui/components/navbar/UserAvatar.tsx @@ -1,8 +1,10 @@ +import clsx from "clsx"; import Image from "next/image"; interface Props { image: string | null | undefined; name: string; + size?: "sm" | "xs"; } export const getInitials = (name: string) => @@ -10,17 +12,30 @@ export const getInitials = (name: string) => .join("") .toUpperCase(); -export default function UserAvatar({ image, name }: Props) { +export default function UserAvatar({ image, name, size = "sm" }: Props) { + const sizeClass = (function (size: "sm" | "xs") { + if (size === "sm") return "w-7 h-7"; + if (size === "xs") return "w-5 h-5"; + })(size); + const sizeValue = (function (size: "sm" | "xs") { + if (size === "sm") return 28; + if (size === "xs") return 20; + })(size); return image ? ( user avatar ) : ( - + {getInitials(name)} diff --git a/keep-ui/entities/users/model/useUser.ts b/keep-ui/entities/users/model/useUser.ts new file mode 100644 index 000000000..f765314cd --- /dev/null +++ b/keep-ui/entities/users/model/useUser.ts @@ -0,0 +1,6 @@ +import { useUsers } from "./useUsers"; + +export function useUser(email: string) { + const { data: users = [] } = useUsers(); + return users.find((user) => user.email === email) ?? null; +} diff --git a/keep-ui/utils/hooks/useUsers.ts b/keep-ui/entities/users/model/useUsers.ts similarity index 73% rename from keep-ui/utils/hooks/useUsers.ts rename to keep-ui/entities/users/model/useUsers.ts index 48368b77c..d87bb235f 100644 --- a/keep-ui/utils/hooks/useUsers.ts +++ b/keep-ui/entities/users/model/useUsers.ts @@ -2,7 +2,7 @@ import { User } from "@/app/(keep)/settings/models"; import { useHydratedSession as useSession } from "@/shared/lib/hooks/useHydratedSession"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { useApiUrl } from "./useConfig"; +import { useApiUrl } from "../../../utils/hooks/useConfig"; import { fetcher } from "utils/fetcher"; export const useUsers = (options: SWRConfiguration = {}) => { @@ -10,8 +10,8 @@ export const useUsers = (options: SWRConfiguration = {}) => { const { data: session } = useSession(); return useSWRImmutable( - () => (session ? `${apiUrl}/auth/users` : null), - (url) => fetcher(url, session?.accessToken), + () => (session ? "/auth/users" : null), + (url) => fetcher(apiUrl + url, session?.accessToken), options ); }; diff --git a/keep-ui/entities/users/ui/UserStatefulAvatar.tsx b/keep-ui/entities/users/ui/UserStatefulAvatar.tsx new file mode 100644 index 000000000..53d9bf43d --- /dev/null +++ b/keep-ui/entities/users/ui/UserStatefulAvatar.tsx @@ -0,0 +1,28 @@ +import UserAvatar from "@/components/navbar/UserAvatar"; +import { useUser } from "../model/useUser"; +import { Icon } from "@tremor/react"; +import { UserCircleIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; + +export function UserStatefulAvatar({ + email, + size = "sm", +}: { + email: string; + size?: "sm" | "xs"; +}) { + const user = useUser(email); + const sizeClass = (function (size: "sm" | "xs") { + if (size === "sm") return "[&>svg]:w-7 [&>svg]:h-7"; + if (size === "xs") return "[&>svg]:w-5 [&>svg]:h-5"; + })(size); + if (!user) { + return ( + + ); + } + return ; +} diff --git a/keep-ui/entities/users/ui/index.ts b/keep-ui/entities/users/ui/index.ts new file mode 100644 index 000000000..b419f1b44 --- /dev/null +++ b/keep-ui/entities/users/ui/index.ts @@ -0,0 +1 @@ +export { UserStatefulAvatar } from "./UserStatefulAvatar"; diff --git a/keep-ui/features/create-or-update-incident/ui/create-or-update-incident-form.tsx b/keep-ui/features/create-or-update-incident/ui/create-or-update-incident-form.tsx index ddf50f976..0458f6007 100644 --- a/keep-ui/features/create-or-update-incident/ui/create-or-update-incident-form.tsx +++ b/keep-ui/features/create-or-update-incident/ui/create-or-update-incident-form.tsx @@ -10,7 +10,7 @@ import { SelectItem, } from "@tremor/react"; import { FormEvent, useEffect, useState } from "react"; -import { useUsers } from "utils/hooks/useUsers"; +import { useUsers } from "@/entities/users/model/useUsers"; import { useIncidentActions } from "@/entities/incidents/model"; import type { IncidentDto } from "@/entities/incidents/model"; import { getIncidentName } from "@/entities/incidents/lib/utils"; diff --git a/keep-ui/features/incident-list/ui/incidents-table.tsx b/keep-ui/features/incident-list/ui/incidents-table.tsx index f71c702a5..7f34c5813 100644 --- a/keep-ui/features/incident-list/ui/incidents-table.tsx +++ b/keep-ui/features/incident-list/ui/incidents-table.tsx @@ -35,6 +35,7 @@ import { useIncidentActions } from "@/entities/incidents/model"; import { IncidentSeverityBadge } from "@/entities/incidents/ui"; import { getIncidentName } from "@/entities/incidents/lib/utils"; import { DateTimeField, TablePagination } from "@/shared/ui"; +import { UserStatefulAvatar } from "@/entities/users/ui"; function SelectedRowActions({ selectedRowIds, @@ -228,7 +229,9 @@ export default function IncidentsTable({ columnHelper.display({ id: "assignee", header: "Assignee", - cell: ({ row }) => row.original.assignee, + cell: ({ row }) => ( + + ), }), columnHelper.accessor("creation_time", { id: "creation_time", diff --git a/keep-ui/middleware.tsx b/keep-ui/middleware.tsx index 052c05792..78e82ef4b 100644 --- a/keep-ui/middleware.tsx +++ b/keep-ui/middleware.tsx @@ -1,14 +1,20 @@ -import { auth } from "@/auth"; -import { NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; +import type { JWT } from "next-auth/jwt"; import { getApiURL } from "@/utils/apiUrl"; -// Use auth as a wrapper for middleware logic -export default auth(async (req) => { - const { pathname, searchParams } = req.nextUrl; +export async function middleware(request: NextRequest) { + const { pathname, searchParams } = request.nextUrl; // Keep it on header so it can be used in server components - const requestHeaders = new Headers(req.headers); - requestHeaders.set("x-url", req.url); + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-url", request.url); + + // Get the token using next-auth/jwt with the correct type + const token = (await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + })) as JWT | null; // Handle legacy /backend/ redirects if (pathname.startsWith("/backend/")) { @@ -25,36 +31,37 @@ export default auth(async (req) => { if (pathname.startsWith("/api/")) { return NextResponse.next(); } + // If not authenticated and not on signin page, redirect to signin - if (!req.auth && !pathname.startsWith("/signin")) { + if (!token && !pathname.startsWith("/signin")) { console.log("Redirecting to signin page because user is not authenticated"); - return NextResponse.redirect(new URL("/signin", req.url)); + return NextResponse.redirect(new URL("/signin", request.url)); } - // else if authenticated and on signin page, redirect to dashboard - if (req.auth && pathname.startsWith("/signin")) { + // If authenticated and on signin page, redirect to dashboard + if (token && pathname.startsWith("/signin")) { console.log( "Redirecting to incidents because user try to get /signin but already authenticated" ); - return NextResponse.redirect(new URL("/incidents", req.url)); + return NextResponse.redirect(new URL("/incidents", request.url)); } // Role-based routing (NOC users) - if (req.auth?.user?.role === "noc" && !pathname.startsWith("/alerts")) { - return NextResponse.redirect(new URL("/alerts/feed", req.url)); + if (token?.role === "noc" && !pathname.startsWith("/alerts")) { + return NextResponse.redirect(new URL("/alerts/feed", request.url)); } // Allow all other authenticated requests console.log("Allowing request to pass through"); - console.log("Request URL: ", req.url); - // console.log("Request headers: ", requestHeaders) + console.log("Request URL: ", request.url); + return NextResponse.next({ request: { // Apply new request headers headers: requestHeaders, }, }); -}); +} // Update the matcher to handle static files and public routes export const config = { diff --git a/keep-ui/next.config.js b/keep-ui/next.config.js index 47f5c2ca4..6d4ace32f 100644 --- a/keep-ui/next.config.js +++ b/keep-ui/next.config.js @@ -3,6 +3,29 @@ const { withSentryConfig } = require("@sentry/nextjs"); /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: false, + webpack: ( + config, + { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack } + ) => { + // Only apply proxy configuration for Node.js server runtime + if (isServer && nextRuntime === "nodejs") { + // Add environment variables for proxy at build time + config.plugins.push( + new webpack.DefinePlugin({ + "process.env.IS_NODEJS_RUNTIME": JSON.stringify(true), + }) + ); + } else { + // For edge runtime and client + config.plugins.push( + new webpack.DefinePlugin({ + "process.env.IS_NODEJS_RUNTIME": JSON.stringify(false), + }) + ); + } + + return config; + }, transpilePackages: ["next-auth"], images: { remotePatterns: [ @@ -29,12 +52,7 @@ const nextConfig = { ], }, compiler: { - removeConsole: - process.env.NODE_ENV === "production" - ? { - exclude: ["error"], - } - : process.env.REMOVE_CONSOLE === "true", + removeConsole: false, }, output: "standalone", productionBrowserSourceMaps: diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index 551791941..c6e0749ac 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@auth/core": "^0.37.4", "@boiseitguru/cookie-cutter": "^0.2.3", "@copilotkit/react-core": "^1.3.15", "@copilotkit/react-ui": "^1.3.15", @@ -24,6 +25,7 @@ "@heroicons/react": "^2.1.5", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.4", "@sentry/nextjs": "^8.38.0", "@svgr/webpack": "^8.0.1", "@tanstack/react-table": "^8.11.0", @@ -47,6 +49,7 @@ "eslint-scope": "^7.2.0", "eslint-utils": "^3.0.0", "eslint-visitor-keys": "^3.4.1", + "https-proxy-agent": "^7.0.5", "js-yaml": "^4.1.0", "lodash.debounce": "^4.0.8", "lucide-react": "^0.460.0", @@ -96,6 +99,7 @@ "swr": "^2.2.5", "tailwind-merge": "^1.12.0", "tailwindcss": "^3.4.1", + "undici": "^6.21.0", "uuid": "^8.3.2", "yaml": "^2.2.2", "zustand": "^5.0.1" @@ -195,18 +199,16 @@ } }, "node_modules/@auth/core": { - "version": "0.37.2", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz", - "integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==", + "version": "0.37.4", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.4.tgz", + "integrity": "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", - "@types/cookie": "0.6.0", - "cookie": "0.7.1", - "jose": "^5.9.3", - "oauth4webapi": "^3.0.0", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" + "jose": "^5.9.6", + "oauth4webapi": "^3.1.1", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", @@ -225,16 +227,6 @@ } } }, - "node_modules/@auth/core/node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -6331,6 +6323,39 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", + "integrity": "sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -6930,6 +6955,31 @@ "node": ">=10" } }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@sentry/core": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.38.0.tgz", @@ -8647,14 +8697,15 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "license": "MIT", "dependencies": { - "debug": "4" + "debug": "^4.3.4" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/agentkeepalive": { @@ -12085,6 +12136,31 @@ "node": ">=12" } }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gcp-metadata": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", @@ -12826,27 +12902,17 @@ "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/humanize-ms": { @@ -13658,29 +13724,6 @@ } } }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/jsdom/node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -15749,6 +15792,59 @@ } } }, + "node_modules/next-auth/node_modules/@auth/core": { + "version": "0.37.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz", + "integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "@types/cookie": "0.6.0", + "cookie": "0.7.1", + "jose": "^5.9.3", + "oauth4webapi": "^3.0.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/next-auth/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -16689,13 +16785,10 @@ } }, "node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", "license": "MIT", - "dependencies": { - "pretty-format": "^3.8.0" - }, "peerDependencies": { "preact": ">=10" } @@ -19977,6 +20070,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", + "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/keep-ui/package.json b/keep-ui/package.json index 42a3e8395..4678a06dc 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -10,6 +10,7 @@ "lint": "next lint" }, "dependencies": { + "@auth/core": "^0.37.4", "@boiseitguru/cookie-cutter": "^0.2.3", "@copilotkit/react-core": "^1.3.15", "@copilotkit/react-ui": "^1.3.15", @@ -25,6 +26,7 @@ "@heroicons/react": "^2.1.5", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.4", "@sentry/nextjs": "^8.38.0", "@svgr/webpack": "^8.0.1", "@tanstack/react-table": "^8.11.0", @@ -48,6 +50,7 @@ "eslint-scope": "^7.2.0", "eslint-utils": "^3.0.0", "eslint-visitor-keys": "^3.4.1", + "https-proxy-agent": "^7.0.5", "js-yaml": "^4.1.0", "lodash.debounce": "^4.0.8", "lucide-react": "^0.460.0", @@ -97,6 +100,7 @@ "swr": "^2.2.5", "tailwind-merge": "^1.12.0", "tailwindcss": "^3.4.1", + "undici": "^6.21.0", "uuid": "^8.3.2", "yaml": "^2.2.2", "zustand": "^5.0.1" diff --git a/keep-ui/proxyFetch.node.ts b/keep-ui/proxyFetch.node.ts new file mode 100644 index 000000000..76defb940 --- /dev/null +++ b/keep-ui/proxyFetch.node.ts @@ -0,0 +1,24 @@ +// proxyFetch.node.ts +import { ProxyAgent, fetch as undici } from "undici"; +import type { ProxyFetchFn } from "./proxyFetch"; + +export const createProxyFetch = async (): Promise => { + const proxyUrl = + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy; + + if (!proxyUrl) { + return undefined; + } + + const dispatcher = new ProxyAgent(proxyUrl); + + return function proxy( + ...args: Parameters + ): ReturnType { + // @ts-expect-error `undici` has a `duplex` option + return undici(args[0], { ...args[1], dispatcher }); + }; +}; diff --git a/keep-ui/proxyFetch.ts b/keep-ui/proxyFetch.ts new file mode 100644 index 000000000..d0d1c936e --- /dev/null +++ b/keep-ui/proxyFetch.ts @@ -0,0 +1,11 @@ +// proxyFetch.ts + +// We only export the type from this file +export type ProxyFetchFn = ( + ...args: Parameters +) => ReturnType; + +// This function will be imported dynamically only in Node.js environment +export const createProxyFetch = async (): Promise => { + return undefined; +}; diff --git a/keep-ui/shared/lib/status-utils.ts b/keep-ui/shared/lib/status-utils.ts new file mode 100644 index 000000000..7eabd2f8f --- /dev/null +++ b/keep-ui/shared/lib/status-utils.ts @@ -0,0 +1,37 @@ +import { + ExclamationCircleIcon, + CheckCircleIcon, + CircleStackIcon, + PauseIcon, +} from "@heroicons/react/24/outline"; +import { IoIosGitPullRequest } from "react-icons/io"; + +export const getStatusIcon = (status: string) => { + switch (status.toLowerCase()) { + case "firing": + return ExclamationCircleIcon; + case "resolved": + return CheckCircleIcon; + case "acknowledged": + return PauseIcon; + case "merged": + return IoIosGitPullRequest; + default: + return CircleStackIcon; + } +}; + +export const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "firing": + return "red"; + case "resolved": + return "green"; + case "acknowledged": + return "gray"; + case "merged": + return "purple"; + default: + return "gray"; + } +}; diff --git a/keep-ui/shared/ui/FieldHeader.tsx b/keep-ui/shared/ui/FieldHeader.tsx index 5c99f4c23..c40813331 100644 --- a/keep-ui/shared/ui/FieldHeader.tsx +++ b/keep-ui/shared/ui/FieldHeader.tsx @@ -1,3 +1,13 @@ -export const FieldHeader = ({ children }: { children: React.ReactNode }) => ( -

{children}

+import clsx from "clsx"; + +export const FieldHeader = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => ( +

+ {children} +

); diff --git a/keep-ui/shared/ui/Tooltip/Tooltip.tsx b/keep-ui/shared/ui/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..7da4c271c --- /dev/null +++ b/keep-ui/shared/ui/Tooltip/Tooltip.tsx @@ -0,0 +1,93 @@ +// Tremor Tooltip [v0.1.0] + +import React from "react"; +import * as TooltipPrimitives from "@radix-ui/react-tooltip"; + +import clsx from "clsx"; + +interface TooltipProps + extends Omit, + Pick< + TooltipPrimitives.TooltipProps, + "open" | "defaultOpen" | "onOpenChange" | "delayDuration" + > { + content: React.ReactNode; + onClick?: React.MouseEventHandler; + side?: "bottom" | "left" | "top" | "right"; + showArrow?: boolean; +} + +const Tooltip = React.forwardRef< + React.ElementRef, + TooltipProps +>( + ( + { + children, + className, + content, + delayDuration, + defaultOpen, + open, + onClick, + onOpenChange, + showArrow = true, + side, + sideOffset = 10, + asChild, + ...props + }: TooltipProps, + forwardedRef + ) => { + return ( + + + + {children} + + + + {content} + {showArrow ? ( + + + + + ); + } +); + +Tooltip.displayName = "Tooltip"; + +export { Tooltip, type TooltipProps }; diff --git a/keep-ui/shared/ui/Tooltip/index.ts b/keep-ui/shared/ui/Tooltip/index.ts new file mode 100644 index 000000000..cf5cc016b --- /dev/null +++ b/keep-ui/shared/ui/Tooltip/index.ts @@ -0,0 +1,2 @@ +export { Tooltip } from "./Tooltip"; +export type { TooltipProps } from "./Tooltip"; diff --git a/keep-ui/shared/ui/index.ts b/keep-ui/shared/ui/index.ts index 307db1bbb..fc196b355 100644 --- a/keep-ui/shared/ui/index.ts +++ b/keep-ui/shared/ui/index.ts @@ -3,3 +3,6 @@ export { TabLinkNavigation, TabNavigationLink } from "./TabLinkNavigation"; export { DateTimeField } from "./DateTimeField"; export { FieldHeader } from "./FieldHeader"; export { EmptyStateCard } from "./EmptyState"; +export { Tooltip } from "./Tooltip"; + +export type { TooltipProps } from "./Tooltip"; diff --git a/keep-ui/tailwind.config.js b/keep-ui/tailwind.config.js index da4514760..9553ecaf2 100644 --- a/keep-ui/tailwind.config.js +++ b/keep-ui/tailwind.config.js @@ -105,11 +105,42 @@ module.exports = { "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], }, + keyframes: { + hide: { + from: { opacity: "1" }, + to: { opacity: "0" }, + }, + slideDownAndFade: { + from: { opacity: "0", transform: "translateY(-6px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + slideLeftAndFade: { + from: { opacity: "0", transform: "translateX(6px)" }, + to: { opacity: "1", transform: "translateX(0)" }, + }, + slideUpAndFade: { + from: { opacity: "0", transform: "translateY(6px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + slideRightAndFade: { + from: { opacity: "0", transform: "translateX(-6px)" }, + to: { opacity: "1", transform: "translateX(0)" }, + }, + }, animation: { "scroll-shadow-left": "auto linear 0s 1 normal none running scroll-shadow-left", "scroll-shadow-right": "auto linear 0s 1 normal none running scroll-shadow-right", + // Tremor tooltip + hide: "hide 150ms cubic-bezier(0.16, 1, 0.3, 1)", + slideDownAndFade: + "slideDownAndFade 150ms cubic-bezier(0.16, 1, 0.3, 1)", + slideLeftAndFade: + "slideLeftAndFade 150ms cubic-bezier(0.16, 1, 0.3, 1)", + slideUpAndFade: "slideUpAndFade 150ms cubic-bezier(0.16, 1, 0.3, 1)", + slideRightAndFade: + "slideRightAndFade 150ms cubic-bezier(0.16, 1, 0.3, 1)", }, }, }, diff --git a/keep-ui/tsconfig.json b/keep-ui/tsconfig.json index 2087f066f..7aceabb38 100644 --- a/keep-ui/tsconfig.json +++ b/keep-ui/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "sourceMap": true, "target": "es6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/keep-ui/types/auth.d.ts b/keep-ui/types/auth.d.ts index 22237903d..6dd72458c 100644 --- a/keep-ui/types/auth.d.ts +++ b/keep-ui/types/auth.d.ts @@ -1,6 +1,7 @@ -import type { DefaultSession } from "@auth/core/types"; +import type { DefaultSession } from "next-auth"; +import type { JWT } from "next-auth/jwt"; -declare module "@auth/core/types" { +declare module "next-auth" { interface Session { accessToken: string; tenantId?: string; @@ -13,7 +14,7 @@ declare module "@auth/core/types" { accessToken: string; tenantId?: string; role?: string; - }; + } & DefaultSession["user"]; } interface User { @@ -26,9 +27,9 @@ declare module "@auth/core/types" { } } -declare module "@auth/core/jwt" { +declare module "next-auth/jwt" { interface JWT { - accessToken: string; // Changed to required + accessToken: string; tenantId?: string; role?: string; } diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 000000000..524e922e5 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,137 @@ +# Development Proxy Setup + +This directory contains the configuration files and Docker services needed to run Keep with a proxy setup, primarily used for testing and development scenarios requiring proxy configurations (e.g., corporate environments, Azure AD authentication). + +## Directory Structure + +``` +proxy/ +├── docker-compose-proxy.yml # Docker Compose configuration for proxy setup +├── squid.conf # Squid proxy configuration +├── nginx.conf # Nginx reverse proxy configuration +└── README.md # This file +``` + +## Components + +The setup consists of several services: + +- **Squid Proxy**: Acts as a forward proxy for HTTP/HTTPS traffic +- **Nginx**: Serves as a reverse proxy/tunnel +- **Keep Frontend**: The Keep UI service configured to use the proxy +- **Keep Backend**: The Keep API service +- **Keep WebSocket**: The WebSocket server for real-time updates + +## Network Architecture + +The setup uses two Docker networks: + +- `proxy-net`: External network for proxy communication +- `internal`: Internal network with no external access (secure network for inter-service communication) + +## Configuration + +### Environment Variables + +The Keep Frontend service is preconfigured with proxy-related environment variables: + +```env +http_proxy=http://proxy:3128 +https_proxy=http://proxy:3128 +HTTP_PROXY=http://proxy:3128 +HTTPS_PROXY=http://proxy:3128 +npm_config_proxy=http://proxy:3128 +npm_config_https_proxy=http://proxy:3128 +``` + +### Usage + +1. Start the proxy environment: + +```bash +docker compose -f docker-compose-proxy.yml up +``` + +2. To run in detached mode: + +```bash +docker compose -f docker-compose-proxy.yml up -d +``` + +3. To stop all services: + +```bash +docker compose -f docker-compose-proxy.yml down +``` + +### Accessing Services + +- Keep Frontend: http://localhost:3000 +- Keep Backend: http://localhost:8080 +- Squid Proxy: localhost:3128 + +## Custom Configuration + +### Modifying Proxy Settings + +To modify the Squid proxy configuration: + +1. Edit `squid.conf` +2. Restart the proxy service: + +```bash +docker compose -f docker-compose-proxy.yml restart proxy +``` + +### Modifying Nginx Settings + +To modify the Nginx reverse proxy configuration: + +1. Edit `nginx.conf` +2. Restart the nginx service: + +```bash +docker compose -f docker-compose-proxy.yml restart tunnel +``` + +## Troubleshooting + +If you encounter connection issues: + +1. Verify proxy is running: + +```bash +docker compose -f docker-compose-proxy.yml ps +``` + +2. Check proxy logs: + +```bash +docker compose -f docker-compose-proxy.yml logs proxy +``` + +3. Test proxy connection: + +```bash +curl -x http://localhost:3128 https://www.google.com +``` + +## Development Notes + +- The proxy setup is primarily intended for development and testing +- When using Azure AD authentication, ensure the proxy configuration matches your environment's requirements +- SSL certificate validation is disabled by default for development purposes (`npm_config_strict_ssl=false`) + +## Security Considerations + +- This setup is intended for development environments only +- The internal network is isolated from external access for security +- Modify security settings in `squid.conf` and `nginx.conf` according to your requirements + +## Contributing + +When modifying the proxy setup: + +1. Document any changes to configuration files +2. Test the setup with both proxy and non-proxy environments +3. Update this README if adding new features or configurations diff --git a/proxy/docker-compose-proxy.yml b/proxy/docker-compose-proxy.yml index ac4a61689..a75ead324 100644 --- a/proxy/docker-compose-proxy.yml +++ b/proxy/docker-compose-proxy.yml @@ -26,7 +26,7 @@ services: ports: - "3000:3000" extends: - file: docker-compose.common.yml + file: ../docker-compose.common.yml service: keep-frontend-common image: us-central1-docker.pkg.dev/keephq/keep/keep-ui:feature_proxy environment: @@ -49,15 +49,15 @@ services: depends_on: - keep-backend - proxy - # networks: - # - proxy-net - # - internal + networks: + # - proxy-net + - internal keep-backend: ports: - "8080:8080" extends: - file: docker-compose.common.yml + file: ../docker-compose.common.yml service: keep-backend-common image: us-central1-docker.pkg.dev/keephq/keep/keep-api environment: @@ -70,7 +70,7 @@ services: keep-websocket-server: extends: - file: docker-compose.common.yml + file: ../docker-compose.common.yml service: keep-websocket-server-common networks: - internal diff --git a/pyproject.toml b/pyproject.toml index 5002c864b..b9494f570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.29.3" +version = "0.29.4" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] readme = "README.md"