diff --git a/docs/deployment/gke.mdx b/docs/deployment/gke.mdx deleted file mode 100644 index dad98f3cd..000000000 --- a/docs/deployment/gke.mdx +++ /dev/null @@ -1,318 +0,0 @@ ---- -title: "GKE" -sidebarTitle: "GKE" ---- - -## Step 0: Prerequisites - -1. GKE cluster (**required**) -2. kubectl and helm installed (**required**) -3. Domain + Certificate (**optional**, for TLS) - - - -## Step 1: Configure Keep's helm repo -```bash -# configure the helm repo -helm repo add keephq https://keephq.github.io/helm-charts -helm pull keephq/keep - - -# make sure you are going to install Keep -helm search repo keep -NAME CHART VERSION APP VERSION DESCRIPTION -keephq/keep 0.1.20 0.25.4 Keep Helm Chart -``` - -## Step 2: Install Keep - -Do not install Keep in your default namespace. Its best practice to create a dedicated namespace. - -Let's create a dedicated namespace and install Keep in it: -```bash -# create a dedicated namespace for Keep -kubectl create ns keep - -# Install keep -helm install -n keep keep keephq/keep --set isGKE=true --set namespace=keep - -# You should see something like: -NAME: keep -LAST DEPLOYED: Thu Oct 10 11:31:07 2024 -NAMESPACE: keep -STATUS: deployed -REVISION: 1 -TEST SUITE: None -``` - - -As number of cofiguration change from the vanilla helm chart increase, it may be more convient to create a `values.yaml` and use it: - - -```bash -cat values.yaml -isGke=true -namespace=keep - -helm install -n keep keep keephq/keep -f values.yaml -``` - - - -Now, let's make sure everything installed correctly: - -```bash -# Note: it can take few minutes until GKE assign the public IP's to the ingresses -helm-charts % kubectl -n keep get ingress,svc,pod,backendconfig -NAME CLASS HOSTS ADDRESS PORTS AGE -ingress.networking.k8s.io/keep-backend * 34.54.XXX.XXX 80 5m27s -ingress.networking.k8s.io/keep-frontend * 34.49.XXX.XXX 80 5m27s - -NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE -service/keep-backend ClusterIP 34.118.239.9 8080/TCP 5m28s -service/keep-database ClusterIP 34.118.228.60 3306/TCP 5m28s -service/keep-frontend ClusterIP 34.118.230.132 3000/TCP 5m28s -service/keep-websocket ClusterIP 34.118.227.128 6001/TCP 5m28s - -NAME READY STATUS RESTARTS AGE -pod/keep-backend-7466b5fcbb-5vst4 1/1 Running 0 5m27s -pod/keep-database-7c65c996f7-nl59n 1/1 Running 0 5m27s -pod/keep-frontend-6dd6897bbb-mbddn 1/1 Running 0 5m27s -pod/keep-websocket-7fc496997b-bz68z 1/1 Running 0 5m27s - -NAME AGE -backendconfig.cloud.google.com/keep-backend-backendconfig 5m28s -backendconfig.cloud.google.com/keep-frontend-backendconfig 5m28s -``` - -You can access Keep by browsing the frontend IP: -``` -frontend_ip=$(kubectl -n keep get ingress | grep frontend | awk '{ print $4 }') -``` - -Keep is now running with its vanilla configuration. This tutorial focus on how to spin up Keep on GKE using Keep's helm chart and doesn't cover all Keep's environment variables and configuration. - - - - - - - -## Step 3: Configure domain and certificate (TLS) - -### Background - -Keep has three ingresses that allow external access to its various components: - - -In this tutorial we focus om exposing the frontend, but exposing the backend and the websocket server is basically the same. - - -#### Frontend Ingress (Required) -This ingress serves the main UI of Keep. It is required for users to access the dashboard and interact with the platform. The frontend is exposed on port 80 by default (or 443 when TLS is configured) and typically points to the public-facing interface of your Keep installation. - -#### Backend Ingress (Optional, enabled by default in `values.yaml`) -This ingress provides access to the backend API, which powers all the business logic, integrations, and alerting services of Keep. The backend ingress is usually accessed by frontend components or other services through internal or external API calls. By default, this ingress is enabled in the Helm chart and exposed internally unless explicitly configured with external domain access. - -#### Websocket Ingress (Optional, disabled by default in `values.yaml`) -This ingress supports real-time communication and push updates for the frontend without requiring page reloads. It is essential for use cases where live alert updates or continuous status changes need to be reflected immediately on the dashboard. Since not every deployment requires real-time updates, the WebSocket ingress is disabled by default but can be enabled as needed by updating the Helm chart configuration. - - - -### Prerequisites - -#### Domain -e.g. keep.yourcomapny.com will be used to access Keep UI. - -#### Certificate -Both private key (.pem) and certificate (.crt) - - -There are other ways to assign the certificate to the ingress, which are not covered by this tutorial, contributions are welcomed here, just open a PR and we will review and merge. - - -1. Google's Managed Certificate - if you domain is managed by Google Cloud DNS, you can spin up the ceritificate automatically using Google's Managed Certificate. -2. Using cert-manager - you can install cert-manager and use LetsEncrypt to spin up ceritificate for Keep. - - - - -### Add an A record for the domain to point to the frontend IP -You can get the frontend IP by: -``` -frontend_ip=$(kubectl -n keep get ingress | grep frontend | awk '{ print $4 }') -``` -Now go into the domain controller and add the A record that points to that IP. - -At this stage, you should be able to access your Keep UI via http://keep.yourcomapny.com - -### Store the certificate as kubernetes secret -Assuming the private key stored as `tls.key` and the certificate stored as `tls.crt`: - -```bash -kubectl create secret tls frontend-tls --cert=./tls.crt --key=./tls.key -n keep - -# you should see: -secret/frontend-tls created -``` - - -### Upgrade Keep to use TLS - -Create this `values.yaml`: -** Note to change keep.yourcomapny.com to your domain ** - -```yaml -namespace: keep -isGKE: true -frontend: - ingress: - enabled: true - hosts: - - host: keep.yourcompany.com - paths: - - path: / - pathType: Prefix - tls: - - hosts: - - keep.yourcompany.com - secretName: frontend-tls - env: - - name: NEXTAUTH_SECRET - value: secret - # Changed the NEXTAUTH_URL - - name: NEXTAUTH_URL - value: https://keep.yourcompany.com - # https://github.com/nextauthjs/next-auth/issues/600 - - name: VERCEL - value: 1 - - name: API_URL - value: http://keep-backend:8080 - - name: NEXT_PUBLIC_POSTHOG_KEY - value: "phc_muk9qE3TfZsX3SZ9XxX52kCGJBclrjhkP9JxAQcm1PZ" - - name: NEXT_PUBLIC_POSTHOG_HOST - value: https://app.posthog.com - - name: ENV - value: development - - name: NODE_ENV - value: development - - name: HOSTNAME - value: 0.0.0.0 - - name: PUSHER_HOST - value: keep-websocket.default.svc.cluster.local - - name: PUSHER_PORT - value: 6001 - - name: PUSHER_APP_KEY - value: "keepappkey" - -backend: - env: - # Added the KEEP_API_URL - - name: KEEP_API_URL - value: https://keep.yourcompany.com/backend - - name: DATABASE_CONNECTION_STRING - value: mysql+pymysql://root@keep-database:3306/keep - - name: SECRET_MANAGER_TYPE - value: k8s - - name: PORT - value: "8080" - - name: PUSHER_APP_ID - value: 1 - - name: PUSHER_APP_KEY - value: keepappkey - - name: PUSHER_APP_SECRET - value: keepappsecret - - name: PUSHER_HOST - value: keep-websocket - - name: PUSHER_PORT - value: 6001 -database: - # this is needed since o/w helm install fails. if you are using different storageClass, edit the value here. - pvc: - storageClass: "standard-rwo" -``` - -Now, update Keep: -``` -helm upgrade -n keep keep keephq/keep -f values.yaml -``` - -### Validate everything works - -First, you should be able to access Keep's UI with https now, using https://keep.yourcompany.com if that's working - you can skip the other validations. -The "Not Secure" in the screenshot is due to self-signed certificate. - - - - - -#### Validate ingress host - -```bash -kubectl -n keep get ingress - -# You should see now the HOST underyour ingress, now with port 443: -NAME CLASS HOSTS ADDRESS PORTS AGE -keep-backend * 34.54.XXX.XXX 80 2d16h -keep-frontend keep.yourcompany.com 34.49.XXX.XXX 80, 443 2d16h -``` - -#### Validate the ingress using the TLS - -You should see `frontend-tls terminates keep.yourcompany.com`: - -```bash -kubectl -n keep describe ingress.networking.k8s.io/keep-frontend -Name: keep-frontend -Labels: app.kubernetes.io/instance=keep - app.kubernetes.io/managed-by=Helm - app.kubernetes.io/name=keep - app.kubernetes.io/version=0.25.4 - helm.sh/chart=keep-0.1.21 -Namespace: keep -Address: 34.54.XXX.XXX -Ingress Class: -Default backend: -TLS: - frontend-tls terminates keep.yourcompany.com -Rules: - Host Path Backends - ---- ---- -------- - gkefrontend.keephq.dev - / keep-frontend:3000 (10.24.8.93:3000) -Annotations: ingress.kubernetes.io/backends: - {"k8s1-0864ab44-keep-keep-frontend-3000-98c56664":"HEALTHY","k8s1-0864ab44-kube-system-default-http-backend-80-2d92bedb":"HEALTHY"} - ingress.kubernetes.io/forwarding-rule: k8s2-fr-h7ydn1yg-keep-keep-frontend-ldr6qtxe - ingress.kubernetes.io/https-forwarding-rule: k8s2-fs-h7ydn1yg-keep-keep-frontend-ldr6qtxe - ingress.kubernetes.io/https-target-proxy: k8s2-ts-h7ydn1yg-keep-keep-frontend-ldr6qtxe - ingress.kubernetes.io/ssl-cert: k8s2-cr-h7ydn1yg-7taujpdzbehr1ghm-64d2ca9e282d3ef5 - ingress.kubernetes.io/static-ip: k8s2-fr-h7ydn1yg-keep-keep-frontend-ldr6qtxe - ingress.kubernetes.io/target-proxy: k8s2-tp-h7ydn1yg-keep-keep-frontend-ldr6qtxe - ingress.kubernetes.io/url-map: k8s2-um-h7ydn1yg-keep-keep-frontend-ldr6qtxe - meta.helm.sh/release-name: keep - meta.helm.sh/release-namespace: keep -Events: - Type Reason Age From Message - ---- ------ ---- ---- ------- - Normal Sync 8m49s loadbalancer-controller UrlMap "k8s2-um-h7ydn1yg-keep-keep-frontend-ldr6qtxe" created - Normal Sync 8m46s loadbalancer-controller TargetProxy "k8s2-tp-h7ydn1yg-keep-keep-frontend-ldr6qtxe" created - Normal Sync 8m33s loadbalancer-controller ForwardingRule "k8s2-fr-h7ydn1yg-keep-keep-frontend-ldr6qtxe" created - Normal Sync 8m25s loadbalancer-controller TargetProxy "k8s2-ts-h7ydn1yg-keep-keep-frontend-ldr6qtxe" created - Normal Sync 8m12s loadbalancer-controller ForwardingRule "k8s2-fs-h7ydn1yg-keep-keep-frontend-ldr6qtxe" created - Normal IPChanged 8m11s loadbalancer-controller IP is now 34.54.XXX.XXX - Normal Sync 7m39s loadbalancer-controller UrlMap "k8s2-um-h7ydn1yg-keep-keep-frontend-ldr6qtxe" updated - Normal Sync 116s (x6 over 9m47s) loadbalancer-controller Scheduled for sync - ``` - -## Uninstall Keep - -### Uninstall the helm package -```bash -helm uninstall -n keep keep -``` - -### Delete the namespace - -```bash -kubectl delete ns keep -``` diff --git a/docs/deployment/kubernetes.mdx b/docs/deployment/kubernetes/architecture.mdx similarity index 57% rename from docs/deployment/kubernetes.mdx rename to docs/deployment/kubernetes/architecture.mdx index 3a7dc2902..5d1051270 100644 --- a/docs/deployment/kubernetes.mdx +++ b/docs/deployment/kubernetes/architecture.mdx @@ -1,26 +1,28 @@ --- -title: "Kubernetes" -sidebarTitle: "Kubernetes" +title: "Architecture" +sidebarTitle: "Architecture" --- -## Overview - -### High Level Architecture +## High Level Architecture Keep architecture composes of two main components: -1. **Keep API** (aka keep backend) - a pythonic server (FastAPI) which serves as Keep's backend -2. **Keep Frontend** - (aka keep ui) - a nextjs server which serves as Keep's frontend +1. **Keep API** - A FastAPI-based backend server that handles business logic and API endpoints. +2. **Keep Frontend** - A Next.js-based frontend interface for user interaction. +3. **Websocket Server** - A Soketi server for real-time updates without page refreshes. +4. **Database Server** - A database used to store and manage persistent data. Supported databases include SQLite, PostgreSQL, MySQL, and SQL Server. + +## Kubernetes Architecture -Keep is also using the following (optional) components: +Keep uses a single unified NGINX ingress controller to route traffic to all components (frontend, backend, and websocket). The ingress handles path-based routing: -3. **Websocket Server** - a soketi server serves as the websocket server to allow real time updates from the server to the browser without refreshing the page -4. **Database Server** - a database which Keep reads/writes for persistency. Keep currently supports sqlite, postgres, mysql and sql server (enterprise) +By default: +- `/` routed to **Frontend** (configurable via `global.ingress.frontendPrefix`) +- `/v2` routed to **Backend** (configurable via `global.ingress.backendPrefix`) +- `/websocket` routed to **WebSocket** (configurable via `global.ingress.websocketPrefix`) -### Kubernetes Architecture -Keep's Kubernetes architecture is composed of several components, each with its own set of Kubernetes resources. Here's a detailed breakdown of each component and its associated resources: +### General Components -#### General Components Keep uses kubernetes secret manager to store secrets such as integrations credentials. | Kubernetes Resource | Purpose | Required/Optional | Source | @@ -30,16 +32,19 @@ Keep's Kubernetes architecture is composed of several components, each with its | RoleBinding | Associates the Role with the ServiceAccount | Required | [role-binding-secret-manager.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/role-binding-secret-manager.yaml) | | Secret Deletion Job | Cleans up Keep-related secrets when the Helm release is deleted | Required | [delete-secret-job.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/delete-secret-job.yaml) | -#### Frontend Components +### Ingress Component +| Kubernetes Resource | Purpose | Required/Optional | Source | +|:-------------------:|:-------:|:-----------------:|:------:| +| Shared NGINX Ingress | Routes all external traffic via one entry point | Optional | [nginx-ingress.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/nginx-ingress.yaml) | + +### Frontend Components | Kubernetes Resource | Purpose | Required/Optional | Source | |:-------------------:|:-------:|:-----------------:|:------:| | Frontend Deployment | Manages the frontend application containers | Required | [frontend.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/frontend.yaml) | | Frontend Service | Exposes the frontend deployment within the cluster | Required | [frontend-service.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/frontend-service.yaml) | -| Frontend Ingress | Exposes the frontend service to external traffic | Optional | [frontend-ingress.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/frontend-ingress.yaml) | | Frontend Route (OpenShift) | Exposes the frontend service to external traffic on OpenShift | Optional | [frontend-route.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/frontend-route.yaml) | | Frontend HorizontalPodAutoscaler | Automatically scales the number of frontend pods | Optional | [frontend-hpa.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/frontend-hpa.yaml) | -| Frontend BackendConfig (GKE) | Configures health checks for Google Cloud Load Balancing | Optional (GKE only) | [backendconfig.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/gke/frontend-gke-healthcheck-config.yaml) | #### Backend Components @@ -47,10 +52,8 @@ Keep's Kubernetes architecture is composed of several components, each with its |:-------------------:|:-------:|:-----------------:|:------:| | Backend Deployment | Manages the backend application containers | Required (if backend enabled) | [backend.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/backend.yaml) | | Backend Service | Exposes the backend deployment within the cluster | Required (if backend enabled) | [backend-service.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/backend-service.yaml) | -| Backend Ingress | Exposes the backend service to external traffic | Optional | [backend-ingress.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/backend-ingress.yaml) | | Backend Route (OpenShift) | Exposes the backend service to external traffic on OpenShift | Optional | [backend-route.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/backend-route.yaml) | | Backend HorizontalPodAutoscaler | Automatically scales the number of backend pods | Optional | [backend-hpa.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/backend-hpa.yaml) | -| BackendConfig (GKE) | Configures health checks for Google Cloud Load Balancing | Optional (GKE only) | [backendconfig.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/gke/backend-gke-healthcheck-config.yaml) | #### Database Components Database components are optional. You can spin up Keep with your own database. @@ -69,13 +72,11 @@ Keep's Kubernetes architecture is composed of several components, each with its |:-------------------:|:-------:|:-----------------:|:------:| | WebSocket Deployment | Manages the WebSocket server containers (Soketi) | Optional | [websocket-server.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/websocket-server.yaml) | | WebSocket Service | Exposes the WebSocket deployment within the cluster | Required (if WebSocket enabled) | [websocket-server-service.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/websocket-server-service.yaml) | -| WebSocket Ingress | Exposes the WebSocket service to external traffic | Optional | [websocket-server-ingress.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/websocket-server-ingress.yaml) | | WebSocket Route (OpenShift) | Exposes the WebSocket service to external traffic on OpenShift | Optional | [websocket-server-route.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/websocket-server-route.yaml) | | WebSocket HorizontalPodAutoscaler | Automatically scales the number of WebSocket server pods | Optional | [websocket-server-hpa.yaml](https://github.com/keephq/helm-charts/blob/main/charts/keep/templates/websocket-server-hpa.yaml) | These tables provide a comprehensive overview of the Kubernetes resources used in the Keep architecture, organized by component type. Each table describes the purpose of each resource, indicates whether it's required or optional, and provides a direct link to the source template in the Keep Helm charts GitHub repository. - ### Kubernetes Configuration This sections covers only kubernetes-specific configuration. To learn about Keep-specific configuration, controlled by environment variables, see [Keep Configuration](/deployment/configuration) @@ -102,18 +103,6 @@ frontend: service: type: ClusterIP # Service type (ClusterIP, NodePort, LoadBalancer). port: 3000 # Port on which the frontend service is exposed. - # Enable or disable frontend ingress. - ingress: - enabled: true - hosts: - - host: keep.yourcompany.com - paths: - - path: / - pathType: Prefix - tls: - - hosts: - - keep.yourcompany.com - secretName: frontend-tls # Secret for TLS certificates. ``` #### 2. Backend Configuration @@ -133,12 +122,6 @@ backend: service: type: ClusterIP # Service type (ClusterIP, NodePort, LoadBalancer). port: 8080 # Port on which the backend API is exposed. - ingress: - enabled: true # Enable or disable backend ingress. - hosts: - - paths: - - path: / - pathType: Prefix ``` #### 3. WebSocket Server Configuration @@ -147,142 +130,3 @@ Keep uses Soketi as its websocket server. To learn how to configure it, please s #### 4. Database Configuration Keep supports plenty of database (e.g. postgresql, mysql, sqlite, etc). It is out of scope to describe here how to deploy all of them to k8s. If you have specific questions - [contact us](https://slack.keephq.dev) and we will be happy to help. - - - -## Installation -The recommended way to install Keep in kubernetes is via Helm Chart. - -First, add the Helm repository of Keep and pull the latest version of the chart: -```bash -helm repo add keephq https://keephq.github.io/helm-charts -helm pull keephq/keep -``` - -Next, install Keep using: -```bash - -# it is always recommended to install Keep in a seperate namespace -kubectl create ns keep - -helm install -n keep keep keephq/keep --set namespace=keep -``` - - -## Expose Keep with port-forward -Notice for it to work locally, you'll need this port forwarding: -``` -# expose the UI -kubectl -n keep port-forward svc/keep-frontend 3000:3000 -``` - -## Expose Keep with ingress (HTTP) -Once you are ready to expose Keep to the outer world, Keep's helm chart comes with pre-configured ingress - -```bash -kubectl -n keep get ingress -NAME CLASS HOSTS ADDRESS PORTS AGE -keep-backend 34.54.XXX.XXX 80 75m -keep-frontend 34.54.XXX.XXX 80 70m -``` - -## Expose Keep with ingress (HTTPS) - -#### Prerequisites - -1. Domain -e.g. keep.yourcomapny.com will be used to access Keep UI. -2. Certificate - both private key (.pem) and certificate (.crt) - -#### Store the certificate as kubernetes secret -Assuming the private key stored as `tls.key` and the certificate stored as `tls.crt`: - -```bash -kubectl create secret tls frontend-tls --cert=./tls.crt --key=./tls.key -n keep - -# you should see: -secret/frontend-tls created -``` - -#### Upgrade Keep to use TLS - -Create this `values.yaml`: -** Note to change keep.yourcomapny.com to your domain ** - -```yaml -namespace: keep -frontend: - ingress: - enabled: true - hosts: - - host: keep.yourcompany.com - paths: - - path: / - pathType: Prefix - tls: - - hosts: - - keep.yourcompany.com - secretName: frontend-tls - env: - - name: NEXTAUTH_SECRET - value: secret - # Changed the NEXTAUTH_URL - - name: NEXTAUTH_URL - value: https://keep.yourcompany.com - # https://github.com/nextauthjs/next-auth/issues/600 - - name: VERCEL - value: 1 - - name: API_URL - value: http://keep-backend:8080 - - name: NEXT_PUBLIC_POSTHOG_KEY - value: "phc_muk9qE3TfZsX3SZ9XxX52kCGJBclrjhkP9JxAQcm1PZ" - - name: NEXT_PUBLIC_POSTHOG_HOST - value: https://app.posthog.com - - name: ENV - value: development - - name: NODE_ENV - value: development - - name: HOSTNAME - value: 0.0.0.0 - - name: PUSHER_HOST - value: keep-websocket.default.svc.cluster.local - - name: PUSHER_PORT - value: 6001 - - name: PUSHER_APP_KEY - value: "keepappkey" - -backend: - env: - # Added the KEEP_API_URL - - name: KEEP_API_URL - value: https://keep.yourcompany.com/backend - - name: DATABASE_CONNECTION_STRING - value: mysql+pymysql://root@keep-database:3306/keep - - name: SECRET_MANAGER_TYPE - value: k8s - - name: PORT - value: "8080" - - name: PUSHER_APP_ID - value: 1 - - name: PUSHER_APP_KEY - value: keepappkey - - name: PUSHER_APP_SECRET - value: keepappsecret - - name: PUSHER_HOST - value: keep-websocket - - name: PUSHER_PORT - value: 6001 -database: - # this is needed since o/w helm install fails. if you are using different storageClass, edit the value here. - pvc: - storageClass: "standard-rwo" -``` - -Now, update Keep: -``` -helm upgrade -n keep keep keephq/keep -f values.yaml -``` - - -To learn more about Keep's helm chart, see https://github.com/keephq/helm-charts/blob/main/README.md - -To discover about how to configure Keep using Helm, see auto generated helm-docs at https://github.com/keephq/helm-charts/blob/main/charts/keep/README.md diff --git a/docs/deployment/kubernetes/installation.mdx b/docs/deployment/kubernetes/installation.mdx new file mode 100644 index 000000000..5b3010ffd --- /dev/null +++ b/docs/deployment/kubernetes/installation.mdx @@ -0,0 +1,167 @@ +--- +title: "Installation" +sidebarTitle: "Installation" +--- + + +The recommended way to install Keep on Kubernetes is via Helm Chart.

+Follow these steps to set it up. +
+ +## Prerequisites + +### Helm CLI +See the [Helm documentation](https://helm.sh/docs/intro/install/) for instructions about installing helm. + +### Ingress Controller (Optional) + +You can skip this step if: +1. You already have **ingress-nginx** installed. +2. You don't need to expose Keep to the internet/network. + + +#### Overview +An ingress controller is essential for managing external access to services in your Kubernetes cluster. It acts as a smart router and load balancer, allowing you to expose multiple services through a single entry point while handling SSL termination and routing rules. +**Keep works the best with** [ingress-nginx](https://github.com/kubernetes/ingress-nginx) **but you can customize the helm chart for other ingress controllers too.** + + +#### Check ingress-nginx Installed +You check if you already have ingress-nginx installed: +```bash +# By default, the ingress-nginx will be installed under the ingress-nginx namespace +kubectl -n ingress-nginx get pods +NAME READY STATUS RESTARTS AGE +ingress-nginx-controller-d49697d5f-hjhbj 1/1 Running 0 4h19m + +# Or check for the ingress class +kubectl get ingressclass +NAME CONTROLLER PARAMETERS AGE +nginx k8s.io/ingress-nginx 4h19m + +``` + +#### Install ingress-nginx + +To read about more installation options, see [ingress-nginx installation docs](https://kubernetes.github.io/ingress-nginx/deploy/). + +```bash +# simplest way to install +helm upgrade --install ingress-nginx ingress-nginx \ + --repo https://kubernetes.github.io/ingress-nginx \ + --namespace ingress-nginx --create-namespace +``` + +Verify installation: +```bash +kubectl get ingressclass +NAME CONTROLLER PARAMETERS AGE +nginx k8s.io/ingress-nginx 4h19m +``` + +Verify if snippet annotations are enabled: +```bash +kubectl get configmap -n ingress-nginx ingress-nginx-controller -o yaml | grep allow-snippet-annotations +allow-snippet-annotations: "true" +``` + +## Installation + +### With Ingress-NGINX (Recommended) + +```bash +# Add the Helm repository +helm repo add keephq https://keephq.github.io/helm-charts + +# Install Keep with ingress enabled +helm install keep keephq/keep -n keep --create-namespace +``` + +### Without Ingress-NGINX (Not Recommended) + +```bash +# Add the Helm repository +helm repo add keephq https://keephq.github.io/helm-charts + +# Install Keep without ingress enabled. +# You won't be able to access Keep from the network. +helm install keep keephq/keep -n keep --create-namespace \ + --set global.ingress.enabled=false +``` + +## Accessing Keep + +### Ingress +If you installed Keep with ingress, you should be able to access Keep. + +```bash +kubectl -n keep get ingress +NAME CLASS HOSTS ADDRESS PORTS AGE +keep-ingress nginx * X.X.X.X 80 4h16m +``` + +Keep is available at http://X.X.X.X :) + +### Without Ingress (Port-Forwarding) + +Use the following commands to access Keep locally without ingress: +```bash +# Forward the UI +kubectl port-forward svc/keep-frontend 3000:3000 -n keep & + +# Forward the Backend +kubectl port-forward svc/keep-backend 8080:8080 -n keep & + +# Forward WebSocket server (optional) +kubectl port-forward svc/keep-websocket 6001:6001 -n keep & +``` + +Keep is available at http://localhost:3000 :) + +## Configuring HTTPS + +### Prerequisites +1. Domain Name: Example - keep.yourcompany.com +2. TLS Certificate: Private key (tls.key) and certificate (tls.crt) + +### Create the TLS Secret + +Assuming: +- `tls.crt` contains the certificate. +- `tls.key` contains the private key. + +```bash +# create the secret with kubectl +kubectl create secret tls keep-tls --cert=./tls.crt --key=./tls.key -n keep +``` + +### Update Helm Values for TLS +```bash +helm upgrade -n keep keep keephq/keep \ + --set "global.ingress.hosts[0]=keep.example.com" \ + --set "global.ingress.tls[0].hosts[0]=keep.example.com" \ + --set "global.ingress.tls[0].secretName=keep-tls" +``` + + + +Alternatively, update your `values.yaml`: +```bash +... +global: + ingress: + hosts: + - host: keep.example.com + tls: + - hosts: + - keep.example.com + secretName: keep-tls +... +``` + + +## Uninstallation +To remove Keep and clean up: +```bash +helm uninstall keep -n keep +kubectl delete namespace keep +``` diff --git a/docs/deployment/openshift.mdx b/docs/deployment/kubernetes/openshift.mdx similarity index 100% rename from docs/deployment/openshift.mdx rename to docs/deployment/kubernetes/openshift.mdx diff --git a/docs/deployment/kubernetes/overview.mdx b/docs/deployment/kubernetes/overview.mdx new file mode 100644 index 000000000..b40d9c771 --- /dev/null +++ b/docs/deployment/kubernetes/overview.mdx @@ -0,0 +1,18 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + + If you need help deploying Keep on Kubernetes or have any feedback or suggestions, feel free to open a ticket in our [GitHub repo](https://github.com/keephq/keep) or say hello in our [Slack](https://slack.keephq.dev). + + +Keep is designed as a Kubernetes-native application. + +We maintain an opinionated, batteries-included Helm chart, but you can customize it as needed. + + +## Next steps +- Install Keep on [Kubernetes](/deployment/kubernetes/installation). +- Keep's [Helm Chart](https://github.com/keephq/helm-charts). +- Deep dive to Keep's kubernetes [Architecture](/deployment/kubernetes/architecture). +- Install Keep on [OpenShift](/deployment/kubernetes/openshift). diff --git a/docs/mint.json b/docs/mint.json index 2504bd46e..89f69ba23 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -79,10 +79,17 @@ }, "deployment/secret-manager", "deployment/docker", - "deployment/kubernetes", + { + "group": "Kubernetes", + "pages": [ + "deployment/kubernetes/overview", + "deployment/kubernetes/installation", + "deployment/kubernetes/architecture", + "deployment/kubernetes/openshift" + ] + }, "deployment/openshift", "deployment/ecs", - "deployment/gke", "deployment/stress-testing" ] }, @@ -107,97 +114,96 @@ { "group": "Supported Providers", "pages": [ - "providers/documentation/aks-provider", - "providers/documentation/appdynamics-provider", - "providers/documentation/auth0-provider", - "providers/documentation/axiom-provider", - "providers/documentation/azuremonitoring-provider", - "providers/documentation/bash-provider", - "providers/documentation/bigquery-provider", - "providers/documentation/centreon-provider", - "providers/documentation/clickhouse-provider", - "providers/documentation/cloudwatch-provider", - "providers/documentation/console-provider", - "providers/documentation/coralogix-provider", - "providers/documentation/datadog-provider", - "providers/documentation/discord-provider", - "providers/documentation/dynatrace-provider", - "providers/documentation/elastic-provider", - "providers/documentation/gcpmonitoring-provider", - "providers/documentation/github-provider", - "providers/documentation/github_workflows_provider", - "providers/documentation/gitlab-provider", - "providers/documentation/gitlabpipelines-provider", - "providers/documentation/gke-provider", - "providers/documentation/google_chat-provider", - "providers/documentation/grafana-provider", - "providers/documentation/grafana_incident-provider", - "providers/documentation/grafana_oncall-provider", - "providers/documentation/http-provider", - "providers/documentation/ilert-provider", - "providers/documentation/incidentio-provider", - "providers/documentation/incidentmanager-provider", - "providers/documentation/jira-on-prem-provider", - "providers/documentation/jira-provider", - "providers/documentation/kafka-provider", - "providers/documentation/keep-provider", - "providers/documentation/kibana-provider", - "providers/documentation/kubernetes-provider", - "providers/documentation/linear_provider", - "providers/documentation/linearb-provider", - "providers/documentation/mailchimp-provider", - "providers/documentation/mailgun-provider", - "providers/documentation/mattermost-provider", - "providers/documentation/microsoft-planner-provider", - "providers/documentation/mock-provider", - "providers/documentation/mongodb-provider", - "providers/documentation/mysql-provider", - "providers/documentation/netdata-provider", - "providers/documentation/new-relic-provider", - "providers/documentation/ntfy-provider", - "providers/documentation/openobserve-provider", - "providers/documentation/openshift-provider", - "providers/documentation/opsgenie-provider", - "providers/documentation/pagerduty-provider", - "providers/documentation/pagertree-provider", - "providers/documentation/parseable-provider", - "providers/documentation/pingdom-provider", - "providers/documentation/planner-provider", - "providers/documentation/postgresql-provider", - "providers/documentation/prometheus-provider", - "providers/documentation/pushover-provider", - "providers/documentation/python-provider", - "providers/documentation/quickchart-provider", - "providers/documentation/redmine-provider", - "providers/documentation/resend-provider", - "providers/documentation/rollbar-provider", - "providers/documentation/sendgrid-provider", - "providers/documentation/sentry-provider", - "providers/documentation/service-now-provider", - "providers/documentation/signalfx-provider", - "providers/documentation/signl4-provider", - "providers/documentation/site24x7-provider", - "providers/documentation/slack-provider", - "providers/documentation/smtp-provider", - "providers/documentation/snowflake-provider", - "providers/documentation/splunk-provider", - "providers/documentation/squadcast-provider", - "providers/documentation/ssh-provider", - "providers/documentation/statuscake-provider", - "providers/documentation/sumologic-provider", - "providers/documentation/teams-provider", - "providers/documentation/telegram-provider", - "providers/documentation/template", - "providers/documentation/trello-provider", - "providers/documentation/twilio-provider", - "providers/documentation/uptimekuma-provider", - "providers/documentation/victoriametrics-provider", - "providers/documentation/webhook-provider", - "providers/documentation/websocket-provider", - "providers/documentation/zabbix-provider", - "providers/documentation/zenduty-provider" - ] - + "providers/documentation/aks-provider", + "providers/documentation/appdynamics-provider", + "providers/documentation/auth0-provider", + "providers/documentation/axiom-provider", + "providers/documentation/azuremonitoring-provider", + "providers/documentation/bash-provider", + "providers/documentation/bigquery-provider", + "providers/documentation/centreon-provider", + "providers/documentation/clickhouse-provider", + "providers/documentation/cloudwatch-provider", + "providers/documentation/console-provider", + "providers/documentation/coralogix-provider", + "providers/documentation/datadog-provider", + "providers/documentation/discord-provider", + "providers/documentation/dynatrace-provider", + "providers/documentation/elastic-provider", + "providers/documentation/gcpmonitoring-provider", + "providers/documentation/github-provider", + "providers/documentation/github_workflows_provider", + "providers/documentation/gitlab-provider", + "providers/documentation/gitlabpipelines-provider", + "providers/documentation/gke-provider", + "providers/documentation/google_chat-provider", + "providers/documentation/grafana-provider", + "providers/documentation/grafana_incident-provider", + "providers/documentation/grafana_oncall-provider", + "providers/documentation/http-provider", + "providers/documentation/ilert-provider", + "providers/documentation/incidentio-provider", + "providers/documentation/incidentmanager-provider", + "providers/documentation/jira-on-prem-provider", + "providers/documentation/jira-provider", + "providers/documentation/kafka-provider", + "providers/documentation/keep-provider", + "providers/documentation/kibana-provider", + "providers/documentation/kubernetes-provider", + "providers/documentation/linear_provider", + "providers/documentation/linearb-provider", + "providers/documentation/mailchimp-provider", + "providers/documentation/mailgun-provider", + "providers/documentation/mattermost-provider", + "providers/documentation/microsoft-planner-provider", + "providers/documentation/mock-provider", + "providers/documentation/mongodb-provider", + "providers/documentation/mysql-provider", + "providers/documentation/netdata-provider", + "providers/documentation/new-relic-provider", + "providers/documentation/ntfy-provider", + "providers/documentation/openobserve-provider", + "providers/documentation/openshift-provider", + "providers/documentation/opsgenie-provider", + "providers/documentation/pagerduty-provider", + "providers/documentation/pagertree-provider", + "providers/documentation/parseable-provider", + "providers/documentation/pingdom-provider", + "providers/documentation/planner-provider", + "providers/documentation/postgresql-provider", + "providers/documentation/prometheus-provider", + "providers/documentation/pushover-provider", + "providers/documentation/python-provider", + "providers/documentation/quickchart-provider", + "providers/documentation/redmine-provider", + "providers/documentation/resend-provider", + "providers/documentation/rollbar-provider", + "providers/documentation/sendgrid-provider", + "providers/documentation/sentry-provider", + "providers/documentation/service-now-provider", + "providers/documentation/signalfx-provider", + "providers/documentation/signl4-provider", + "providers/documentation/site24x7-provider", + "providers/documentation/slack-provider", + "providers/documentation/smtp-provider", + "providers/documentation/snowflake-provider", + "providers/documentation/splunk-provider", + "providers/documentation/squadcast-provider", + "providers/documentation/ssh-provider", + "providers/documentation/statuscake-provider", + "providers/documentation/sumologic-provider", + "providers/documentation/teams-provider", + "providers/documentation/telegram-provider", + "providers/documentation/template", + "providers/documentation/trello-provider", + "providers/documentation/twilio-provider", + "providers/documentation/uptimekuma-provider", + "providers/documentation/victoriametrics-provider", + "providers/documentation/webhook-provider", + "providers/documentation/websocket-provider", + "providers/documentation/zabbix-provider", + "providers/documentation/zenduty-provider" + ] } ] }, diff --git a/keep-ui/app/ai/ai.tsx b/keep-ui/app/ai/ai.tsx index 69f331629..7e9d23aa7 100644 --- a/keep-ui/app/ai/ai.tsx +++ b/keep-ui/app/ai/ai.tsx @@ -2,7 +2,7 @@ import { Card, List, ListItem, Title, Subtitle } from "@tremor/react"; import { useAIStats, usePollAILogs } from "utils/hooks/useAI"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { toast } from "react-toastify"; import { useEffect, useState, useRef, FormEvent } from "react"; import { AILogs } from "./model"; @@ -15,6 +15,7 @@ export default function Ai() { const [newText, setNewText] = useState("Mine incidents"); const [animate, setAnimate] = useState(false); const onlyOnce = useRef(false); + const apiUrl = useApiUrl(); const mutateAILogs = (logs: AILogs) => { setBasicAlgorithmLog(logs.log); @@ -42,7 +43,6 @@ export default function Ai() { e.preventDefault(); setAnimate(true); setNewText("Mining 🚀🚀🚀 ..."); - const apiUrl = getApiURL(); const response = await fetch(`${apiUrl}/incidents/mine`, { method: "POST", headers: { diff --git a/keep-ui/app/alerts/ViewAlertModal.tsx b/keep-ui/app/alerts/ViewAlertModal.tsx index 9284d9474..6bbeca28f 100644 --- a/keep-ui/app/alerts/ViewAlertModal.tsx +++ b/keep-ui/app/alerts/ViewAlertModal.tsx @@ -1,12 +1,12 @@ import { AlertDto } from "./models"; // Adjust the import path as needed import Modal from "@/components/ui/Modal"; // Ensure this path matches your project structure -import {Button, Icon, Switch, Text} from "@tremor/react"; +import { Button, Icon, Switch, Text } from "@tremor/react"; import { toast } from "react-toastify"; -import { getApiURL } from "../../utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { XMarkIcon } from "@heroicons/react/24/outline"; import "./ViewAlertModal.css"; -import React, {useState} from "react"; +import React, { useState } from "react"; interface ViewAlertModalProps { alert: AlertDto | null | undefined; @@ -14,14 +14,19 @@ interface ViewAlertModalProps { mutate: () => void; } -const objectToJSONLine = (obj: any) => { +const objectToJSONLine = (obj: any) => { return JSON.stringify(obj, null, 2).slice(2, -2); -} +}; -export const ViewAlertModal: React.FC = ({ alert, handleClose, mutate}) => { +export const ViewAlertModal: React.FC = ({ + alert, + handleClose, + mutate, +}) => { const isOpen = !!alert; const [showHighlightedOnly, setShowHighlightedOnly] = useState(false); - const {data: session} = useSession(); + const { data: session } = useSession(); + const apiUrl = useApiUrl(); const unEnrichAlert = async (key: string) => { if (confirm(`Are you sure you want to un-enrich ${key}?`)) { @@ -30,7 +35,7 @@ export const ViewAlertModal: React.FC = ({ alert, handleClo enrichments: [key], fingerprint: alert!.fingerprint, }; - const response = await fetch(`${getApiURL()}/alerts/unenrich`, { + const response = await fetch(`${apiUrl}/alerts/unenrich`, { method: "POST", headers: { "Content-Type": "application/json", @@ -52,35 +57,46 @@ export const ViewAlertModal: React.FC = ({ alert, handleClo toast.error("An unexpected error occurred"); } } - } + }; const highlightKeys = (json: any, keys: string[]) => { - const lines = Object.keys(json).length; - const isLast = (index: number) => index == lines - 1 + const isLast = (index: number) => index == lines - 1; return Object.keys(json).map((key: string, index: number) => { if (keys.includes(key)) { - return

unEnrichAlert(key)}> - - - - {objectToJSONLine({[key]: json[key]})}{isLast(index) ? null : ","} -

+ return ( +

unEnrichAlert(key)} + > + + + + {objectToJSONLine({ [key]: json[key] })} + {isLast(index) ? null : ","} +

+ ); } else { if (!showHighlightedOnly || keys.length == 0) { - return

{objectToJSONLine({[key]: json[key]})}{isLast(index) ? null : ","}

+ return ( +

+ {objectToJSONLine({ [key]: json[key] })} + {isLast(index) ? null : ","} +

+ ); } } - }) - } + }); + }; const handleCopy = async () => { if (alert) { @@ -94,23 +110,31 @@ export const ViewAlertModal: React.FC = ({ alert, handleClo }; return ( - +

Alert Details

-
{/* Adjust gap as needed */} +
+ {" "} + {/* Adjust gap as needed */}
setShowHighlightedOnly(!showHighlightedOnly)} - /> -
- diff --git a/keep-ui/app/alerts/alert-actions.tsx b/keep-ui/app/alerts/alert-actions.tsx index 87a7be34c..5a4366944 100644 --- a/keep-ui/app/alerts/alert-actions.tsx +++ b/keep-ui/app/alerts/alert-actions.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Button } from "@tremor/react"; import { getSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { AlertDto } from "./models"; import { PlusIcon } from "@radix-ui/react-icons"; import { toast } from "react-toastify"; @@ -23,14 +23,16 @@ export default function AlertActions({ alerts, clearRowSelection, setDismissModalAlert, - mutateAlerts + mutateAlerts, }: Props) { const router = useRouter(); const { useAllPresets } = usePresets(); + const apiUrl = useApiUrl(); const { mutate: presetsMutator } = useAllPresets({ revalidateOnFocus: false, }); - const [isIncidentSelectorOpen, setIsIncidentSelectorOpen] = useState(false); + const [isIncidentSelectorOpen, setIsIncidentSelectorOpen] = + useState(false); const selectedAlerts = alerts.filter((_alert, index) => selectedRowIds.includes(index.toString()) @@ -54,7 +56,6 @@ export default function AlertActions({ ); const options = [{ value: formattedCel, label: "CEL" }]; const session = await getSession(); - const apiUrl = getApiURL(); const response = await fetch(`${apiUrl}/preset`, { method: "POST", headers: { @@ -82,10 +83,10 @@ export default function AlertActions({ const showIncidentSelector = () => { setIsIncidentSelectorOpen(true); - } + }; const hideIncidentSelector = () => { setIsIncidentSelectorOpen(false); - } + }; const handleSuccessfulAlertsAssociation = () => { hideIncidentSelector(); @@ -93,7 +94,7 @@ export default function AlertActions({ if (mutateAlerts) { mutateAlerts(); } - } + }; return (
@@ -130,10 +131,11 @@ export default function AlertActions({ Associate with incident + isOpen={isIncidentSelectorOpen} + alerts={selectedAlerts} + handleSuccess={handleSuccessfulAlertsAssociation} + handleClose={hideIncidentSelector} + />
); } diff --git a/keep-ui/app/alerts/alert-assign-ticket-modal.tsx b/keep-ui/app/alerts/alert-assign-ticket-modal.tsx index 4b6c99cfc..f3920c764 100644 --- a/keep-ui/app/alerts/alert-assign-ticket-modal.tsx +++ b/keep-ui/app/alerts/alert-assign-ticket-modal.tsx @@ -5,7 +5,7 @@ import { PlusIcon } from "@heroicons/react/20/solid"; import { useForm, Controller, SubmitHandler } from "react-hook-form"; import { Providers } from "./../providers/providers"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { AlertDto } from "./models"; import Modal from "@/components/ui/Modal"; @@ -45,6 +45,7 @@ const AlertAssignTicketModal = ({ } = useForm(); // get the token const { data: session } = useSession(); + const apiUrl = useApiUrl(); // if this modal should not be open, do nothing if (!alert) return null; @@ -61,7 +62,7 @@ const AlertAssignTicketModal = ({ fingerprint: alert.fingerprint, }; - const response = await fetch(`${getApiURL()}/alerts/enrich`, { + const response = await fetch(`${apiUrl}/alerts/enrich`, { method: "POST", headers: { "Content-Type": "application/json", @@ -225,18 +226,14 @@ const AlertAssignTicketModal = ({
diff --git a/keep-ui/app/alerts/alert-associate-incident-modal.tsx b/keep-ui/app/alerts/alert-associate-incident-modal.tsx index d0124e824..622e8c893 100644 --- a/keep-ui/app/alerts/alert-associate-incident-modal.tsx +++ b/keep-ui/app/alerts/alert-associate-incident-modal.tsx @@ -6,7 +6,7 @@ import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { FormEvent, useCallback, useEffect, useState } from "react"; import { toast } from "react-toastify"; -import { getApiURL } from "../../utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useIncidents, usePollIncidents } from "../../utils/hooks/useIncidents"; import Loading from "../loading"; import { AlertDto } from "./models"; @@ -34,10 +34,10 @@ const AlertAssociateIncidentModal = ({ >(); // get the token const { data: session } = useSession(); + const apiUrl = useApiUrl(); const router = useRouter(); const associateAlertsHandler = async (incidentId: string) => { - const apiUrl = getApiURL(); const response = await fetch(`${apiUrl}/incidents/${incidentId}/alerts`, { method: "POST", headers: { diff --git a/keep-ui/app/alerts/alert-change-status-modal.tsx b/keep-ui/app/alerts/alert-change-status-modal.tsx index 6dcb5bcf6..d090599dc 100644 --- a/keep-ui/app/alerts/alert-change-status-modal.tsx +++ b/keep-ui/app/alerts/alert-change-status-modal.tsx @@ -8,7 +8,7 @@ import Select, { } from "react-select"; import { useState } from "react"; import { AlertDto, Status } from "./models"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { toast } from "react-toastify"; import { @@ -79,6 +79,7 @@ export default function AlertChangeStatusModal({ const { useAllPresets } = usePresets(); const { mutate: presetsMutator } = useAllPresets(); const { useAllAlerts } = useAlerts(); + const apiUrl = useApiUrl(); const { mutate: alertsMutator } = useAllAlerts(presetName, { revalidateOnMount: false, }); @@ -109,23 +110,26 @@ export default function AlertChangeStatusModal({ } try { - const response = await fetch(`${getApiURL()}/alerts/enrich?dispose_on_new_alert=true`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${session?.accessToken}`, - }, - body: JSON.stringify({ - enrichments: { - status: selectedStatus, - ...(selectedStatus !== Status.Suppressed && { - dismissed: false, - dismissUntil: "", - }), + const response = await fetch( + `${apiUrl}/alerts/enrich?dispose_on_new_alert=true`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.accessToken}`, }, - fingerprint: alert.fingerprint, - }), - }); + body: JSON.stringify({ + enrichments: { + status: selectedStatus, + ...(selectedStatus !== Status.Suppressed && { + dismissed: false, + dismissUntil: "", + }), + }, + fingerprint: alert.fingerprint, + }), + } + ); if (response.ok) { toast.success("Alert status changed successfully!"); diff --git a/keep-ui/app/alerts/alert-dismiss-modal.tsx b/keep-ui/app/alerts/alert-dismiss-modal.tsx index 33b4b9968..11bf3d63f 100644 --- a/keep-ui/app/alerts/alert-dismiss-modal.tsx +++ b/keep-ui/app/alerts/alert-dismiss-modal.tsx @@ -1,12 +1,22 @@ import { useState, useEffect } from "react"; -import { Button, Title, Subtitle, Card, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; +import { + Button, + Title, + Subtitle, + Card, + Tab, + TabGroup, + TabList, + TabPanel, + TabPanels, +} from "@tremor/react"; import Modal from "@/components/ui/Modal"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import "react-quill/dist/quill.snow.css"; import { AlertDto } from "./models"; import { format, set, isSameDay, isAfter, addMinutes } from "date-fns"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { usePresets } from "utils/hooks/usePresets"; import { useAlerts } from "utils/hooks/useAlerts"; @@ -34,15 +44,21 @@ export default function AlertDismissModal({ const { useAllPresets } = usePresets(); const { mutate: presetsMutator } = useAllPresets(); const { usePresetAlerts } = useAlerts(); - const { mutate: alertsMutator } = usePresetAlerts(presetName, { revalidateOnMount: false }); + const { mutate: alertsMutator } = usePresetAlerts(presetName, { + revalidateOnMount: false, + }); const { data: session } = useSession(); - + const apiUrl = useApiUrl(); // Ensuring that the useEffect hook is called consistently useEffect(() => { const now = new Date(); const roundedMinutes = Math.ceil(now.getMinutes() / 15) * 15; - const defaultTime = set(now, { minutes: roundedMinutes, seconds: 0, milliseconds: 0 }); + const defaultTime = set(now, { + minutes: roundedMinutes, + seconds: 0, + milliseconds: 0, + }); setSelectedDateTime(defaultTime); }, []); @@ -69,7 +85,8 @@ export default function AlertDismissModal({ return; } - const dismissUntil = selectedTab === 0 ? null : selectedDateTime?.toISOString(); + const dismissUntil = + selectedTab === 0 ? null : selectedDateTime?.toISOString(); const requests = alerts.map((alert: AlertDto) => { const requestData = { enrichments: { @@ -80,7 +97,7 @@ export default function AlertDismissModal({ }, fingerprint: alert.fingerprint, }; - return fetch(`${getApiURL()}/alerts/enrich`, { + return fetch(`${apiUrl}/alerts/enrich`, { method: "POST", headers: { "Content-Type": "application/json", @@ -181,9 +198,12 @@ export default function AlertDismissModal({ filterTime={filterPassedTime} inline calendarClassName="custom-datepicker" - /> - {showError &&
Must choose a date
} + {showError && ( +
+ Must choose a date +
+ )}
Dismiss Comment diff --git a/keep-ui/app/alerts/alert-menu.tsx b/keep-ui/app/alerts/alert-menu.tsx index 0c106ebe6..997be6959 100644 --- a/keep-ui/app/alerts/alert-menu.tsx +++ b/keep-ui/app/alerts/alert-menu.tsx @@ -13,7 +13,7 @@ import { import { IoNotificationsOffOutline } from "react-icons/io5"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import Link from "next/link"; import { ProviderMethod } from "app/providers/providers"; import { AlertDto } from "./models"; @@ -44,8 +44,7 @@ export default function AlertMenu({ isInSidebar, }: Props) { const router = useRouter(); - - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: { installed_providers: installedProviders } = { installed_providers: [], @@ -256,9 +255,7 @@ export default function AlertMenu({ {({ active }) => ( )} @@ -303,7 +303,10 @@ export default function AlertMenu({ active ? "bg-slate-200" : "text-gray-900" } group flex w-full items-center rounded-md px-2 py-2 text-xs`} > -
{layout.length === 0 ? ( diff --git a/keep-ui/app/deduplication/DeduplicationSidebar.tsx b/keep-ui/app/deduplication/DeduplicationSidebar.tsx index ddc2e6a8f..0a3bf8943 100644 --- a/keep-ui/app/deduplication/DeduplicationSidebar.tsx +++ b/keep-ui/app/deduplication/DeduplicationSidebar.tsx @@ -23,7 +23,7 @@ import { ExclamationTriangleIcon, InformationCircleIcon, } from "@heroicons/react/24/outline"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { KeyedMutator } from "swr"; @@ -75,6 +75,7 @@ const DeduplicationSidebar: React.FC = ({ } = useProviders(); const { data: deduplicationFields = {} } = useDeduplicationFields(); const { data: session } = useSession(); + const apiUrl = useApiUrl(); const alertProviders = useMemo( () => @@ -150,7 +151,6 @@ const DeduplicationSidebar: React.FC = ({ setIsSubmitting(true); clearErrors(); try { - const apiUrl = getApiURL(); let url = `${apiUrl}/deduplications`; if (selectedDeduplicationRule && selectedDeduplicationRule.id) { @@ -535,6 +535,7 @@ const DeduplicationSidebar: React.FC = ({ color="orange" variant="secondary" onClick={handleToggle} + type="button" className="border border-orange-500 text-orange-500" > Cancel diff --git a/keep-ui/app/deduplication/DeduplicationTable.tsx b/keep-ui/app/deduplication/DeduplicationTable.tsx index 5bef79f40..05d634451 100644 --- a/keep-ui/app/deduplication/DeduplicationTable.tsx +++ b/keep-ui/app/deduplication/DeduplicationTable.tsx @@ -24,7 +24,7 @@ import { DeduplicationRule } from "app/deduplication/models"; import DeduplicationSidebar from "app/deduplication/DeduplicationSidebar"; import { TrashIcon, PauseIcon, PlusIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; const columnHelper = createColumnHelper(); @@ -43,6 +43,7 @@ export const DeduplicationTable: React.FC = ({ const router = useRouter(); const { data: session } = useSession(); const searchParams = useSearchParams(); + const apiUrl = useApiUrl(); let selectedId = searchParams ? searchParams.get("id") : null; const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -72,7 +73,7 @@ export const DeduplicationTable: React.FC = ({ window.confirm("Are you sure you want to delete this deduplication rule?") ) { try { - const url = `${getApiURL()}/deduplications/${rule.id}`; + const url = `${apiUrl}/deduplications/${rule.id}`; const response = await fetch(url, { method: "DELETE", headers: { diff --git a/keep-ui/app/extraction/create-or-update-extraction-rule.tsx b/keep-ui/app/extraction/create-or-update-extraction-rule.tsx index 43b959322..a770f68c9 100644 --- a/keep-ui/app/extraction/create-or-update-extraction-rule.tsx +++ b/keep-ui/app/extraction/create-or-update-extraction-rule.tsx @@ -16,7 +16,7 @@ import { import { useSession } from "next-auth/react"; import { FormEvent, useEffect, useState } from "react"; import { toast } from "react-toastify"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { ExtractionRule } from "./model"; import { extractNamedGroups } from "./extractions-table"; import { useExtractions } from "utils/hooks/useExtractionRules"; @@ -41,6 +41,7 @@ export default function CreateOrUpdateExtractionRule({ const [regex, setRegex] = useState(""); const [extractedAttributes, setExtractedAttributes] = useState([]); const [priority, setPriority] = useState(0); + const apiUrl = useApiUrl(); const editMode = extractionToEdit !== null; useEffect(() => { @@ -75,7 +76,6 @@ export default function CreateOrUpdateExtractionRule({ const addExtraction = async (e: FormEvent) => { e.preventDefault(); - const apiUrl = getApiURL(); const response = await fetch(`${apiUrl}/extraction`, { method: "POST", headers: { @@ -106,7 +106,6 @@ export default function CreateOrUpdateExtractionRule({ // This is the function that will be called on submitting the form in the editMode, it sends a PUT request to the backennd. const updateExtraction = async (e: FormEvent) => { e.preventDefault(); - const apiUrl = getApiURL(); const response = await fetch( `${apiUrl}/extraction/${extractionToEdit?.id}`, { diff --git a/keep-ui/app/extraction/extractions-table.tsx b/keep-ui/app/extraction/extractions-table.tsx index a7183bcde..48fcb7f51 100644 --- a/keep-ui/app/extraction/extractions-table.tsx +++ b/keep-ui/app/extraction/extractions-table.tsx @@ -19,7 +19,7 @@ import { } from "@tanstack/react-table"; import { MdRemoveCircle, MdModeEdit } from "react-icons/md"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useMappings } from "utils/hooks/useMappingRules"; import { toast } from "react-toastify"; import { ExtractionRule } from "./model"; @@ -53,6 +53,7 @@ export default function RulesTable({ }: Props) { const { data: session } = useSession(); const { mutate } = useMappings(); + const apiUrl = useApiUrl(); const [expanded, setExpanded] = useState({}); const columns = [ @@ -181,7 +182,6 @@ export default function RulesTable({ }); const deleteExtraction = (extractionId: number) => { - const apiUrl = getApiURL(); if (confirm("Are you sure you want to delete this rule?")) { fetch(`${apiUrl}/extraction/${extractionId}`, { method: "DELETE", diff --git a/keep-ui/app/incidents/[id]/incident-alert-menu.tsx b/keep-ui/app/incidents/[id]/incident-alert-menu.tsx index aaa43ab28..c5e32bed1 100644 --- a/keep-ui/app/incidents/[id]/incident-alert-menu.tsx +++ b/keep-ui/app/incidents/[id]/incident-alert-menu.tsx @@ -3,7 +3,7 @@ import { Icon } from "@tremor/react"; import { AlertDto } from "app/alerts/models"; import { useSession } from "next-auth/react"; import { toast } from "react-toastify"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useIncidentAlerts } from "utils/hooks/useIncidents"; interface Props { @@ -11,7 +11,7 @@ interface Props { alert: AlertDto; } export default function IncidentAlertMenu({ incidentId, alert }: Props) { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const { mutate } = useIncidentAlerts(incidentId); diff --git a/keep-ui/app/incidents/[id]/incident-chat.tsx b/keep-ui/app/incidents/[id]/incident-chat.tsx index 338a0a9d6..31634f2f8 100644 --- a/keep-ui/app/incidents/[id]/incident-chat.tsx +++ b/keep-ui/app/incidents/[id]/incident-chat.tsx @@ -14,6 +14,7 @@ import { useRouter } from "next/navigation"; import Loading from "app/loading"; import { useCopilotAction, useCopilotReadable } from "@copilotkit/react-core"; import { updateIncidentRequest } from "../create-or-update-incident"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { toast } from "react-toastify"; import "@copilotkit/react-ui/styles.css"; @@ -21,6 +22,7 @@ import "./incident-chat.css"; export default function IncidentChat({ incident }: { incident: IncidentDto }) { const router = useRouter(); + const apiUrl = useApiUrl(); const { mutate } = useIncidents(true, 20); const { mutate: mutateIncident } = useIncident(incident.id); const { data: alerts, isLoading: alertsLoading } = useIncidentAlerts( @@ -84,6 +86,7 @@ export default function IncidentChat({ incident }: { incident: IncidentDto }) { incidentAssignee: incident.assignee, incidentSameIncidentInThePastId: incident.same_incident_in_the_past_id, generatedByAi: true, + apiUrl: apiUrl!, }); if (response.ok) { diff --git a/keep-ui/app/incidents/[id]/incident-info.tsx b/keep-ui/app/incidents/[id]/incident-info.tsx index 87c9c35fe..cc484e06a 100644 --- a/keep-ui/app/incidents/[id]/incident-info.tsx +++ b/keep-ui/app/incidents/[id]/incident-info.tsx @@ -15,6 +15,7 @@ import { } from "../incident-candidate-actions"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; +import { useApiUrl } from "utils/hooks/useConfig"; import { format } from "date-fns"; import { ArrowUturnLeftIcon } from "@heroicons/react/24/outline"; import { Disclosure } from "@headlessui/react"; @@ -95,6 +96,7 @@ export default function IncidentInformation({ incident }: Props) { const { data: session } = useSession(); const { mutate } = useIncident(incident.id); const [isFormOpen, setIsFormOpen] = useState(false); + const apiUrl = useApiUrl(); const [runWorkflowModalIncident, setRunWorkflowModalIncident] = useState(); @@ -198,6 +200,7 @@ export default function IncidentInformation({ incident }: Props) { e.preventDefault(); e.stopPropagation(); handleConfirmPredictedIncident({ + apiUrl: apiUrl!, incidentId: incident.id!, mutate, session, @@ -216,6 +219,7 @@ export default function IncidentInformation({ incident }: Props) { e.preventDefault(); e.stopPropagation(); const success = await deleteIncident({ + apiUrl: apiUrl!, incidentId: incident.id!, mutate, session, diff --git a/keep-ui/app/incidents/create-or-update-incident.tsx b/keep-ui/app/incidents/create-or-update-incident.tsx index 2ae75422b..dfaf52940 100644 --- a/keep-ui/app/incidents/create-or-update-incident.tsx +++ b/keep-ui/app/incidents/create-or-update-incident.tsx @@ -13,7 +13,7 @@ import { import { useSession } from "next-auth/react"; import { FormEvent, useEffect, useState } from "react"; import { toast } from "react-toastify"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { IncidentDto } from "./models"; import { useIncidents } from "utils/hooks/useIncidents"; import { Session } from "next-auth"; @@ -36,6 +36,7 @@ export const updateIncidentRequest = async ({ incidentAssignee, incidentSameIncidentInThePastId, generatedByAi, + apiUrl, }: { session: Session | null; incidentId: string; @@ -44,21 +45,24 @@ export const updateIncidentRequest = async ({ incidentAssignee: string; incidentSameIncidentInThePastId: string | null; generatedByAi: boolean; + apiUrl: string; }) => { - const apiUrl = getApiURL(); - const response = await fetch(`${apiUrl}/incidents/${incidentId}?generatedByAi=${generatedByAi}`, { - method: "PUT", - headers: { - Authorization: `Bearer ${session?.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - user_generated_name: incidentName, - user_summary: incidentUserSummary, - assignee: incidentAssignee, - same_incident_in_the_past_id: incidentSameIncidentInThePastId, - }), - }); + const response = await fetch( + `${apiUrl}/incidents/${incidentId}?generatedByAi=${generatedByAi}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_generated_name: incidentName, + user_summary: incidentUserSummary, + assignee: incidentAssignee, + same_incident_in_the_past_id: incidentSameIncidentInThePastId, + }), + } + ); return response; }; @@ -73,6 +77,7 @@ export default function CreateOrUpdateIncident({ const [incidentUserSummary, setIncidentUserSummary] = useState(""); const [incidentAssignee, setIncidentAssignee] = useState(""); const { data: users = [] } = useUsers(); + const apiUrl = useApiUrl(); const editMode = incidentToEdit !== null; // Display cancel btn if editing or we need to cancel for another reason (eg. going one step back in the modal etc.) @@ -100,7 +105,6 @@ export default function CreateOrUpdateIncident({ const addIncident = async (e: FormEvent) => { e.preventDefault(); - const apiUrl = getApiURL(); const response = await fetch(`${apiUrl}/incidents`, { method: "POST", headers: { @@ -136,9 +140,11 @@ export default function CreateOrUpdateIncident({ incidentName: incidentName, incidentUserSummary: incidentUserSummary, incidentAssignee: incidentAssignee, - incidentSameIncidentInThePastId: incidentToEdit?.same_incident_in_the_past_id!, + incidentSameIncidentInThePastId: + incidentToEdit?.same_incident_in_the_past_id!, generatedByAi: false, - }) + apiUrl: apiUrl!, + }); if (response.ok) { exitEditMode(); await mutate(); diff --git a/keep-ui/app/incidents/incident-candidate-actions.tsx b/keep-ui/app/incidents/incident-candidate-actions.tsx index 1a8a33e8f..89dbb53a1 100644 --- a/keep-ui/app/incidents/incident-candidate-actions.tsx +++ b/keep-ui/app/incidents/incident-candidate-actions.tsx @@ -1,26 +1,27 @@ -import {getApiURL} from "../../utils/apiUrl"; -import {toast} from "react-toastify"; -import {IncidentDto, PaginatedIncidentsDto} from "./models"; -import {Session} from "next-auth"; +import { toast } from "react-toastify"; +import { IncidentDto, PaginatedIncidentsDto } from "./models"; +import { Session } from "next-auth"; interface Props { incidentId: string; mutate: () => void; session: Session | null; + apiUrl: string; } -export const handleConfirmPredictedIncident = async ({incidentId, mutate, session}: Props) => { - const apiUrl = getApiURL(); - const response = await fetch( - `${apiUrl}/incidents/${incidentId}/confirm`, - { - method: "POST", - headers: { - Authorization: `Bearer ${session?.accessToken}`, - "Content-Type": "application/json", - }, - } - ); +export const handleConfirmPredictedIncident = async ({ + incidentId, + mutate, + session, + apiUrl, +}: Props) => { + const response = await fetch(`${apiUrl}/incidents/${incidentId}/confirm`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + }); if (response.ok) { await mutate(); toast.success("Predicted incident confirmed successfully"); @@ -29,25 +30,29 @@ export const handleConfirmPredictedIncident = async ({incidentId, mutate, sessio "Failed to confirm predicted incident, please contact us if this issue persists." ); } -} +}; -export const deleteIncident = async ({incidentId, mutate, session}: Props) => { - const apiUrl = getApiURL(); +export const deleteIncident = async ({ + incidentId, + mutate, + session, + apiUrl, +}: Props) => { if (confirm("Are you sure you want to delete this incident?")) { const response = await fetch(`${apiUrl}/incidents/${incidentId}`, { method: "DELETE", headers: { Authorization: `Bearer ${session?.accessToken}`, }, - }) + }); if (response.ok) { - await mutate(); - toast.success("Incident deleted successfully"); - return true - } else { - toast.error("Failed to delete incident, contact us if this persists"); - return false - } + await mutate(); + toast.success("Incident deleted successfully"); + return true; + } else { + toast.error("Failed to delete incident, contact us if this persists"); + return false; + } } }; diff --git a/keep-ui/app/incidents/incident-change-same-in-the-past.tsx b/keep-ui/app/incidents/incident-change-same-in-the-past.tsx index bddd4fee1..eb44026bf 100644 --- a/keep-ui/app/incidents/incident-change-same-in-the-past.tsx +++ b/keep-ui/app/incidents/incident-change-same-in-the-past.tsx @@ -8,6 +8,7 @@ import { useIncidents, usePollIncidents } from "../../utils/hooks/useIncidents"; import Loading from "../loading"; import { updateIncidentRequest } from "./create-or-update-incident"; import { IncidentDto } from "./models"; +import { useApiUrl } from "../../utils/hooks/useConfig"; interface ChangeSameIncidentInThePast { incident: IncidentDto; @@ -28,6 +29,7 @@ const ChangeSameIncidentInThePast = ({ >(); const { data: session } = useSession(); const router = useRouter(); + const apiUrl = useApiUrl(); const associateIncidentHandler = async ( selectedIncidentId: string | null @@ -40,6 +42,7 @@ const ChangeSameIncidentInThePast = ({ incidentUserSummary: incident.user_summary, incidentAssignee: incident.assignee, generatedByAi: false, + apiUrl: apiUrl!, }); if (response.ok) { mutate(); diff --git a/keep-ui/app/incidents/incident-change-status-modal.tsx b/keep-ui/app/incidents/incident-change-status-modal.tsx index 886d205b8..b8c205c05 100644 --- a/keep-ui/app/incidents/incident-change-status-modal.tsx +++ b/keep-ui/app/incidents/incident-change-status-modal.tsx @@ -8,7 +8,7 @@ import Select, { } from "react-select"; import { useState } from "react"; import { IncidentDto, Status } from "./models"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { toast } from "react-toastify"; import { @@ -71,6 +71,7 @@ export default function IncidentChangeStatusModal({ const { data: session } = useSession(); const [selectedStatus, setSelectedStatus] = useState(null); const [comment, setComment] = useState(""); + const apiUrl = useApiUrl(); if (!incident) return null; @@ -98,17 +99,20 @@ export default function IncidentChangeStatusModal({ } try { - const response = await fetch(`${getApiURL()}/incidents/${incident.id}/status`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${session?.accessToken}`, - }, - body: JSON.stringify({ + const response = await fetch( + `${apiUrl}/incidents/${incident.id}/status`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.accessToken}`, + }, + body: JSON.stringify({ status: selectedStatus, comment: comment, - }), - }); + }), + } + ); if (response.ok) { toast.success("Incident status changed successfully!"); @@ -126,7 +130,8 @@ export default function IncidentChangeStatusModal({ Change Incident Status - Change status from {incident.status} to: + Change status from {incident.status}{" "} + to:
+
{isLocalhost && ( @@ -929,21 +982,45 @@ const ProviderForm = ({ {provider.can_setup_webhook && installedProvidersMode && ( - + <> +
+ + +
+ + )} {provider.supports_webhook && ( { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data, error, isLoading } = useSWR( `${apiUrl}/providers/${provider.type}/webhook`, (url: string) => fetcher(url, accessToken) @@ -43,15 +43,16 @@ export const ProviderSemiAutomated = ({ provider, accessToken }: Props) => { codeBlock: true, }; - const isMultiline = data!.webhookDescription.includes('\n'); - const descriptionLines = data!.webhookDescription.split('\n'); + const isMultiline = data!.webhookDescription.includes("\n"); + const descriptionLines = data!.webhookDescription.split("\n"); const settingsNotEmpty = settings.text.trim().length > 0; const webhookMarkdown = data!.webhookMarkdown; return (
Push alerts from{" "} - {provider.type.charAt(0).toLocaleUpperCase() + provider.display_name.slice(1)} + {provider.type.charAt(0).toLocaleUpperCase() + + provider.display_name.slice(1)}
{ />
- Seamlessly push alerts without actively connecting {provider.display_name} + Seamlessly push alerts without actively connecting{" "} + {provider.display_name} {isMultiline ? ( - descriptionLines.map((line, index) => ( - - {line} - - )) - ) : ( - {data!.webhookDescription} - )} - {settingsNotEmpty && } - {webhookMarkdown && ( -
- - {webhookMarkdown} - -
- )} + descriptionLines.map((line, index) => ( + + {line} + + )) + ) : ( + {data!.webhookDescription} + )} + {settingsNotEmpty && } + {webhookMarkdown && ( +
+ {webhookMarkdown} +
+ )}
); }; diff --git a/keep-ui/app/providers/providers.tsx b/keep-ui/app/providers/providers.tsx index 403fde0c8..c3bcc9ae9 100644 --- a/keep-ui/app/providers/providers.tsx +++ b/keep-ui/app/providers/providers.tsx @@ -95,6 +95,8 @@ export interface Provider { validatedScopes: { [scopeName: string]: boolean | string }; methods?: ProviderMethod[]; tags: TProviderLabels[]; + last_pull_time?: Date; + pulling_enabled: boolean; alertsDistribution?: AlertDistritbuionData[]; alertExample?: { [key: string]: string }; provisioned?: boolean; @@ -115,4 +117,5 @@ export const defaultProvider: Provider = { type: "", tags: [], validatedScopes: {}, + pulling_enabled: true, }; diff --git a/keep-ui/app/rules/CorrelationSidebar/CorrelationSidebarBody.tsx b/keep-ui/app/rules/CorrelationSidebar/CorrelationSidebarBody.tsx index 147648be0..ffb225c04 100644 --- a/keep-ui/app/rules/CorrelationSidebar/CorrelationSidebarBody.tsx +++ b/keep-ui/app/rules/CorrelationSidebar/CorrelationSidebarBody.tsx @@ -1,5 +1,5 @@ import { Button, Callout, Icon } from "@tremor/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { formatQuery } from "react-querybuilder"; import { useLocalStorage } from "utils/hooks/useLocalStorage"; import { IoMdClose } from "react-icons/io"; @@ -30,7 +30,7 @@ export const CorrelationSidebarBody = ({ toggle, defaultValue, }: CorrelationSidebarBodyProps) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const methods = useForm({ defaultValues: defaultValue, diff --git a/keep-ui/app/rules/CorrelationSidebar/DeleteRule.tsx b/keep-ui/app/rules/CorrelationSidebar/DeleteRule.tsx index 2efbb2779..feabbeb1d 100644 --- a/keep-ui/app/rules/CorrelationSidebar/DeleteRule.tsx +++ b/keep-ui/app/rules/CorrelationSidebar/DeleteRule.tsx @@ -2,7 +2,7 @@ import { TrashIcon } from "@radix-ui/react-icons"; import { Button } from "@tremor/react"; import { useSession } from "next-auth/react"; import { MouseEvent } from "react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useRules } from "utils/hooks/useRules"; type DeleteRuleCellProps = { @@ -10,7 +10,7 @@ type DeleteRuleCellProps = { }; export const DeleteRuleCell = ({ ruleId }: DeleteRuleCellProps) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const { mutate } = useRules(); diff --git a/keep-ui/app/settings/auth/api-key-settings.tsx b/keep-ui/app/settings/auth/api-key-settings.tsx index 34e04f14b..aa8e5621f 100644 --- a/keep-ui/app/settings/auth/api-key-settings.tsx +++ b/keep-ui/app/settings/auth/api-key-settings.tsx @@ -15,7 +15,7 @@ import { import Loading from "app/loading"; import { CopyBlock, a11yLight } from "react-code-blocks"; import useSWR from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { KeyIcon, TrashIcon } from "@heroicons/react/24/outline"; import { fetcher } from "utils/fetcher"; import { useState } from "react"; @@ -49,7 +49,7 @@ interface Config { } export default function ApiKeySettings({ accessToken, selectedTab }: Props) { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data, error, isLoading } = useSWR( selectedTab === "api-key" ? `${apiUrl}/settings/apikeys` : null, async (url) => { @@ -83,7 +83,10 @@ export default function ApiKeySettings({ accessToken, selectedTab }: Props) { const authType = configData?.AUTH_TYPE as AuthenticationType; const createApiKeyEnabled = authType !== AuthenticationType.NOAUTH; - const handleRegenerate = async (apiKeyId: string, event: React.MouseEvent) => { + const handleRegenerate = async ( + apiKeyId: string, + event: React.MouseEvent + ) => { event.stopPropagation(); const confirmed = confirm( "This action cannot be undone. This will revoke the key and generate a new one. Any further requests made with this key will fail. Make sure to update any applications that use this key." @@ -160,11 +163,19 @@ export default function ApiKeySettings({ accessToken, selectedTab }: Props) { Name - Key + + Key + Role - Created By - Created At - Last Used + + Created By + + + Created At + + + Last Used + @@ -221,7 +232,7 @@ export default function ApiKeySettings({ accessToken, selectedTab }: Props) { onClose={() => setApiKeyModalOpen(false)} accessToken={accessToken} setApiKeys={setApiKeys} - apiUrl={apiUrl} + apiUrl={apiUrl!} roles={roles} /> diff --git a/keep-ui/app/settings/auth/groups-sidebar.tsx b/keep-ui/app/settings/auth/groups-sidebar.tsx index eabb569c4..a31c0d5bd 100644 --- a/keep-ui/app/settings/auth/groups-sidebar.tsx +++ b/keep-ui/app/settings/auth/groups-sidebar.tsx @@ -1,11 +1,24 @@ import { Fragment, useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; -import { Text, Subtitle, Button, TextInput, MultiSelect, MultiSelectItem, Callout } from "@tremor/react"; +import { + Text, + Subtitle, + Button, + TextInput, + MultiSelect, + MultiSelectItem, + Callout, +} from "@tremor/react"; import { IoMdClose } from "react-icons/io"; -import { useForm, Controller, SubmitHandler, FieldValues } from "react-hook-form"; +import { + useForm, + Controller, + SubmitHandler, + FieldValues, +} from "react-hook-form"; import { useRoles } from "utils/hooks/useRoles"; import { useUsers } from "utils/hooks/useUsers"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import "./multiselect.css"; interface GroupSidebarProps { @@ -17,8 +30,23 @@ interface GroupSidebarProps { accessToken: string; } -const GroupsSidebar = ({ isOpen, toggle, group, isNewGroup, mutateGroups, accessToken }: GroupSidebarProps) => { - const { control, handleSubmit, setValue, reset, formState: { errors, isDirty }, clearErrors, setError } = useForm({ +const GroupsSidebar = ({ + isOpen, + toggle, + group, + isNewGroup, + mutateGroups, + accessToken, +}: GroupSidebarProps) => { + const { + control, + handleSubmit, + setValue, + reset, + formState: { errors, isDirty }, + clearErrors, + setError, + } = useForm({ defaultValues: { name: "", members: [], @@ -29,6 +57,7 @@ const GroupsSidebar = ({ isOpen, toggle, group, isNewGroup, mutateGroups, access const { data: roles = [] } = useRoles(); const { data: users = [], mutate: mutateUsers } = useUsers(); const [isSubmitting, setIsSubmitting] = useState(false); + const apiUrl = useApiUrl(); useEffect(() => { if (isOpen) { @@ -52,7 +81,9 @@ const GroupsSidebar = ({ isOpen, toggle, group, isNewGroup, mutateGroups, access clearErrors(); // Clear all errors const method = isNewGroup ? "POST" : "PUT"; - const url = isNewGroup ? `${getApiURL()}/auth/groups` : `${getApiURL()}/auth/groups/${group.id}`; + const url = isNewGroup + ? `${apiUrl}/auth/groups` + : `${apiUrl}/auth/groups/${group.id}`; try { const response = await fetch(url, { method: method, @@ -70,7 +101,8 @@ const GroupsSidebar = ({ isOpen, toggle, group, isNewGroup, mutateGroups, access } else { const errorData = await response.json(); setError("root.serverError", { - message: errorData.detail || errorData.message || "Failed to save group" + message: + errorData.detail || errorData.message || "Failed to save group", }); } } catch (error) { @@ -127,7 +159,10 @@ const GroupsSidebar = ({ isOpen, toggle, group, isNewGroup, mutateGroups, access -
+
{errors.root?.serverError && ( - + {errors.root.serverError.message} )} @@ -219,7 +256,11 @@ const GroupsSidebar = ({ isOpen, toggle, group, isNewGroup, mutateGroups, access type="submit" disabled={isSubmitting || (isNewGroup ? false : !isDirty)} > - {isSubmitting ? "Saving..." : isNewGroup ? "Create Group" : "Save"} + {isSubmitting + ? "Saving..." + : isNewGroup + ? "Create Group" + : "Save"} diff --git a/keep-ui/app/settings/auth/groups-tab.tsx b/keep-ui/app/settings/auth/groups-tab.tsx index 538001542..93f051555 100644 --- a/keep-ui/app/settings/auth/groups-tab.tsx +++ b/keep-ui/app/settings/auth/groups-tab.tsx @@ -19,7 +19,7 @@ import { useUsers } from "utils/hooks/useUsers"; import { useRoles } from "utils/hooks/useRoles"; import { useState, useEffect, useMemo } from "react"; import GroupsSidebar from "./groups-sidebar"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { TrashIcon } from "@heroicons/react/24/outline"; import { MdGroupAdd } from "react-icons/md"; @@ -28,33 +28,49 @@ interface Props { } export default function GroupsTab({ accessToken }: Props) { - const { data: groups = [], isLoading: groupsLoading, mutate: mutateGroups } = useGroups(); - const { data: users = [], isLoading: usersLoading, mutate: mutateUsers } = useUsers(); + const { + data: groups = [], + isLoading: groupsLoading, + mutate: mutateGroups, + } = useGroups(); + const { + data: users = [], + isLoading: usersLoading, + mutate: mutateUsers, + } = useUsers(); const { data: roles = [] } = useRoles(); - const [groupStates, setGroupStates] = useState<{ [key: string]: { members: string[], roles: string[] } }>({}); + const [groupStates, setGroupStates] = useState<{ + [key: string]: { members: string[]; roles: string[] }; + }>({}); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [selectedGroup, setSelectedGroup] = useState(null); const [filter, setFilter] = useState(""); const [isNewGroup, setIsNewGroup] = useState(false); + const apiUrl = useApiUrl(); useEffect(() => { if (groups) { - const initialGroupStates = groups.reduce((acc, group) => { - acc[group.id] = { - members: group.members || [], - roles: group.roles || [], - }; - return acc; - }, {} as { [key: string]: { members: string[], roles: string[] } }); + const initialGroupStates = groups.reduce( + (acc, group) => { + acc[group.id] = { + members: group.members || [], + roles: group.roles || [], + }; + return acc; + }, + {} as { [key: string]: { members: string[]; roles: string[] } } + ); setGroupStates(initialGroupStates); } }, [groups]); const filteredGroups = useMemo(() => { - return groups?.filter(group => - group.name.toLowerCase().includes(filter.toLowerCase()) - ) || []; + return ( + groups?.filter((group) => + group.name.toLowerCase().includes(filter.toLowerCase()) + ) || [] + ); }, [groups, filter]); if (groupsLoading || usersLoading || !roles) return ; @@ -71,13 +87,16 @@ export default function GroupsTab({ accessToken }: Props) { setIsSidebarOpen(true); }; - const handleDeleteGroup = async (groupName: string, event: React.MouseEvent) => { + const handleDeleteGroup = async ( + groupName: string, + event: React.MouseEvent + ) => { event.stopPropagation(); if (window.confirm("Are you sure you want to delete this group?")) { try { - const url = `${getApiURL()}/auth/groups/${groupName}`; + const url = `${apiUrl}/auth/groups/${groupName}`; const response = await fetch(url, { - method: 'DELETE', + method: "DELETE", headers: { Authorization: `Bearer ${accessToken}`, }, diff --git a/keep-ui/app/settings/auth/permissions-tab.tsx b/keep-ui/app/settings/auth/permissions-tab.tsx index d2529df78..a598d3e43 100644 --- a/keep-ui/app/settings/auth/permissions-tab.tsx +++ b/keep-ui/app/settings/auth/permissions-tab.tsx @@ -19,7 +19,7 @@ import { usePresets } from "utils/hooks/usePresets"; import { useGroups } from "utils/hooks/useGroups"; import { useUsers } from "utils/hooks/useUsers"; import { usePermissions } from "utils/hooks/usePermissions"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { TrashIcon } from "@heroicons/react/24/outline"; import PermissionSidebar from "./permissions-sidebar"; import { Permission } from "app/settings/models"; @@ -31,45 +31,67 @@ interface Props { export default function PermissionsTab({ accessToken }: Props) { const { data: session } = useSession(); - const apiUrl = getApiURL(); - const [selectedPermissions, setSelectedPermissions] = useState<{ [key: string]: string[] }>({}); - const [initialPermissions, setInitialPermissions] = useState<{ [key: string]: string[] }>({}); + const apiUrl = useApiUrl(); + const [selectedPermissions, setSelectedPermissions] = useState<{ + [key: string]: string[]; + }>({}); + const [initialPermissions, setInitialPermissions] = useState<{ + [key: string]: string[]; + }>({}); const [filter, setFilter] = useState(""); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [selectedPreset, setSelectedPreset] = useState(null); const { useAllPresets } = usePresets(); - const { data: presets = [], error: presetsError, isValidating: presetsLoading } = useAllPresets(); - const { data: groups = [], error: groupsError, isValidating: groupsLoading } = useGroups(); - const { data: users = [], error: usersError, isValidating: usersLoading } = useUsers(); - const { data: permissions = [], error: permissionsError, isValidating: permissionsLoading } = usePermissions(); - + const { + data: presets = [], + error: presetsError, + isValidating: presetsLoading, + } = useAllPresets(); + const { + data: groups = [], + error: groupsError, + isValidating: groupsLoading, + } = useGroups(); + const { + data: users = [], + error: usersError, + isValidating: usersLoading, + } = useUsers(); + const { + data: permissions = [], + error: permissionsError, + isValidating: permissionsLoading, + } = usePermissions(); // SHAHAR: TODO: fix when needed const displayPermissions = useMemo(() => { - const groupPermissions: Permission[] = (groups || []).map(group => ({ + const groupPermissions: Permission[] = (groups || []).map((group) => ({ id: group.id, resource_id: group.id, entity_id: group.id, - permissions: [{ id: 'group' }], + permissions: [{ id: "group" }], name: group.name, - type: "group" + type: "group", })); - const userPermissions: Permission[] = (users || []).map(user => ({ + const userPermissions: Permission[] = (users || []).map((user) => ({ id: user.email, resource_id: user.email, entity_id: user.email, - permissions: [{ id: 'user' }], + permissions: [{ id: "user" }], name: user.name, - type: "user" + type: "user", })); return [...groupPermissions, ...userPermissions]; }, [groups, users]); - const handlePermissionChange = (presetId: string, newPermissions: string[]) => { - setSelectedPermissions(prev => ({ + const handlePermissionChange = ( + presetId: string, + newPermissions: string[] + ) => { + setSelectedPermissions((prev) => ({ ...prev, - [presetId]: newPermissions + [presetId]: newPermissions, })); }; @@ -77,8 +99,10 @@ export default function PermissionsTab({ accessToken }: Props) { if (permissions) { const initialPerms: { [key: string]: string[] } = {}; - permissions.forEach(permission => { - initialPerms[permission.resource_id] = permission.permissions.map(p => p.id); + permissions.forEach((permission) => { + initialPerms[permission.resource_id] = permission.permissions.map( + (p) => p.id + ); }); setInitialPermissions(initialPerms); @@ -86,36 +110,48 @@ export default function PermissionsTab({ accessToken }: Props) { } }, [permissions]); - const hasChanges = JSON.stringify(initialPermissions) !== JSON.stringify(selectedPermissions); + const hasChanges = + JSON.stringify(initialPermissions) !== JSON.stringify(selectedPermissions); const savePermissions = async () => { try { - const changedPermissions = Object.entries(selectedPermissions).reduce((acc, [presetId, permissions]) => { - if (JSON.stringify(permissions) !== JSON.stringify(initialPermissions[presetId])) { - acc[presetId] = permissions; - } - return acc; - }, {} as { [key: string]: string[] }); - - const resourcePermissions = Object.entries(changedPermissions).map(([presetId, permissions]) => ({ - resource_id: presetId, - resource_name: presets?.find(preset => preset.id === presetId)?.name || "", - resource_type: "preset", - permissions: permissions.map(permissionId => { - const permission = displayPermissions.find(p => p.id === permissionId); - return { - id: permissionId, - type: permission?.type - }; + const changedPermissions = Object.entries(selectedPermissions).reduce( + (acc, [presetId, permissions]) => { + if ( + JSON.stringify(permissions) !== + JSON.stringify(initialPermissions[presetId]) + ) { + acc[presetId] = permissions; + } + return acc; + }, + {} as { [key: string]: string[] } + ); + + const resourcePermissions = Object.entries(changedPermissions).map( + ([presetId, permissions]) => ({ + resource_id: presetId, + resource_name: + presets?.find((preset) => preset.id === presetId)?.name || "", + resource_type: "preset", + permissions: permissions.map((permissionId) => { + const permission = displayPermissions.find( + (p) => p.id === permissionId + ); + return { + id: permissionId, + type: permission?.type, + }; + }), }) - })); + ); if (resourcePermissions.length === 0) { console.log("No changes to save"); return; } - const response = await fetch(`${getApiURL()}/auth/permissions`, { + const response = await fetch(`${apiUrl}/auth/permissions`, { method: "POST", headers: { Authorization: `Bearer ${session?.accessToken}`, @@ -129,18 +165,25 @@ export default function PermissionsTab({ accessToken }: Props) { // You might want to show a success message here } else { const errorData = await response.json(); - console.error("Failed to save permissions:", errorData.detail || errorData.message || "Unknown error"); + console.error( + "Failed to save permissions:", + errorData.detail || errorData.message || "Unknown error" + ); // You might want to show an error message to the user here } } catch (error) { - console.error("An unexpected error occurred while saving permissions:", error); + console.error( + "An unexpected error occurred while saving permissions:", + error + ); // You might want to show an error message to the user here } }; - if (presetsLoading || groupsLoading || usersLoading || permissionsLoading) return ; + if (presetsLoading || groupsLoading || usersLoading || permissionsLoading) + return ; - const filteredPresets = (presets || []).filter(preset => + const filteredPresets = (presets || []).filter((preset) => preset.name.toLowerCase().includes(filter.toLowerCase()) ); @@ -149,12 +192,15 @@ export default function PermissionsTab({ accessToken }: Props) { setIsSidebarOpen(true); }; - const handleDeletePermission = async (presetId: string, event: React.MouseEvent) => { + const handleDeletePermission = async ( + presetId: string, + event: React.MouseEvent + ) => { event.stopPropagation(); if (window.confirm("Are you sure you want to delete this permission?")) { try { const response = await fetch(`${apiUrl}/auth/permissions/${presetId}`, { - method: 'DELETE', + method: "DELETE", headers: { Authorization: `Bearer ${accessToken}`, }, @@ -178,11 +224,7 @@ export default function PermissionsTab({ accessToken }: Props) { Permissions Management Manage permissions for Keep resources - @@ -196,8 +238,12 @@ export default function PermissionsTab({ accessToken }: Props) { - Resource Name - Resource Type + + Resource Name + + + Resource Type + Permissions @@ -210,14 +256,24 @@ export default function PermissionsTab({ accessToken }: Props) { onClick={() => handleRowClick(preset)} > {preset.name} - preset + + {" "} + + preset + +
- {selectedPermissions[preset.id]?.slice(0, 5).map((permId, index) => ( - - {displayPermissions.find(p => p.id === permId)?.name} - - ))} + {selectedPermissions[preset.id] + ?.slice(0, 5) + .map((permId, index) => ( + + { + displayPermissions.find((p) => p.id === permId) + ?.name + } + + ))} {selectedPermissions[preset.id]?.length > 5 && ( +{selectedPermissions[preset.id].length - 5} more @@ -226,7 +282,7 @@ export default function PermissionsTab({ accessToken }: Props) {
-
+
-
+
- {errors.root?.serverError && typeof errors.root.serverError.message === "string" && ( - - {errors.root.serverError.message} - - )} + {errors.root?.serverError && + typeof errors.root.serverError.message === "string" && ( + + {errors.root.serverError.message} + + )}
diff --git a/keep-ui/app/settings/auth/roles-tab.tsx b/keep-ui/app/settings/auth/roles-tab.tsx index 105a63338..3aaaad6b7 100644 --- a/keep-ui/app/settings/auth/roles-tab.tsx +++ b/keep-ui/app/settings/auth/roles-tab.tsx @@ -14,7 +14,7 @@ import { TextInput, } from "@tremor/react"; import { useState, useEffect, useMemo } from "react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useScopes } from "utils/hooks/useScopes"; import { useRoles } from "utils/hooks/useRoles"; import React from "react"; @@ -29,10 +29,17 @@ interface RolesTabProps { customRolesAllowed: boolean; } -export default function RolesTab({ accessToken, customRolesAllowed }: RolesTabProps) { - const apiUrl = getApiURL(); +export default function RolesTab({ + accessToken, + customRolesAllowed, +}: RolesTabProps) { + const apiUrl = useApiUrl(); const { data: scopes = [], isLoading: scopesLoading } = useScopes(); - const { data: roles = [], isLoading: rolesLoading, mutate: mutateRoles } = useRoles(); + const { + data: roles = [], + isLoading: rolesLoading, + mutate: mutateRoles, + } = useRoles(); const [resources, setResources] = useState([]); const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -41,13 +48,19 @@ export default function RolesTab({ accessToken, customRolesAllowed }: RolesTabPr useEffect(() => { if (scopes && scopes.length > 0) { - const extractedResources = [...new Set(scopes.map(scope => scope.split(':')[1]).filter(resource => resource !== '*'))]; + const extractedResources = [ + ...new Set( + scopes + .map((scope) => scope.split(":")[1]) + .filter((resource) => resource !== "*") + ), + ]; setResources(extractedResources); } }, [scopes]); const filteredRoles = useMemo(() => { - return roles.filter(role => + return roles.filter((role) => role.name.toLowerCase().includes(filter.toLowerCase()) ); }, [roles, filter]); @@ -64,7 +77,7 @@ export default function RolesTab({ accessToken, customRolesAllowed }: RolesTabPr if (window.confirm("Are you sure you want to delete this role?")) { try { const response = await fetch(`${apiUrl}/auth/roles/${roleId}`, { - method: 'DELETE', + method: "DELETE", headers: { Authorization: `Bearer ${accessToken}`, }, @@ -98,7 +111,11 @@ export default function RolesTab({ accessToken, customRolesAllowed }: RolesTabPr setIsSidebarOpen(true); }} disabled={!customRolesAllowed} - tooltip={customRolesAllowed ? undefined : "This feature is not available in your authentication mode."} + tooltip={ + customRolesAllowed + ? undefined + : "This feature is not available in your authentication mode." + } > Create Custom Role @@ -121,54 +138,68 @@ export default function RolesTab({ accessToken, customRolesAllowed }: RolesTabPr - {filteredRoles.sort((a, b) => (a.predefined === b.predefined ? 0 : a.predefined ? -1 : 1)).map((role) => ( - handleRowClick(role)} - > - -
- {role.name} -
- {role.predefined ? ( - Predefined - ) : ( - Custom + {filteredRoles + .sort((a, b) => + a.predefined === b.predefined ? 0 : a.predefined ? -1 : 1 + ) + .map((role) => ( + handleRowClick(role)} + > + +
+ {role.name} +
+ {role.predefined ? ( + + Predefined + + ) : ( + + Custom + + )} +
+
+
+ + {role.description} + + +
+ {role.scopes.slice(0, 4).map((scope, index) => ( + + {scope} + + ))} + {role.scopes.length > 4 && ( + + +{role.scopes.length - 4} more + )}
-
- - - {role.description} - - -
- {role.scopes.slice(0, 4).map((scope, index) => ( - - {scope} - - ))} - {role.scopes.length > 4 && ( - - +{role.scopes.length - 4} more - + + + {!role.predefined && ( +
-
- - {!role.predefined && ( -
diff --git a/keep-ui/app/settings/auth/sso-settings.tsx b/keep-ui/app/settings/auth/sso-settings.tsx index 7ed498fb5..f5e7d6394 100644 --- a/keep-ui/app/settings/auth/sso-settings.tsx +++ b/keep-ui/app/settings/auth/sso-settings.tsx @@ -1,10 +1,21 @@ -import React from 'react'; -import useSWR from 'swr'; -import { Card, Title, Subtitle, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Button, Icon } from '@tremor/react'; -import { fetcher } from 'utils/fetcher'; -import { getApiURL } from 'utils/apiUrl'; -import Loading from 'app/loading'; -import Image from "next/image"; +import React from "react"; +import useSWR from "swr"; +import { + Card, + Title, + Subtitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Button, + Icon, +} from "@tremor/react"; +import { fetcher } from "utils/fetcher"; +import { useApiUrl } from "utils/hooks/useConfig"; +import Loading from "app/loading"; interface SSOProvider { id: string; @@ -18,11 +29,12 @@ interface Props { } const SSOSettings: React.FC = ({ accessToken }) => { - const apiUrl = getApiURL(); - const { data, error } = useSWR<{ sso: boolean, providers: SSOProvider[], wizardUrl: string }>( - `${apiUrl}/settings/sso`, - (url: string) => fetcher(url, accessToken) - ); + const apiUrl = useApiUrl(); + const { data, error } = useSWR<{ + sso: boolean; + providers: SSOProvider[]; + wizardUrl: string; + }>(`${apiUrl}/settings/sso`, (url: string) => fetcher(url, accessToken)); if (!data) return ; if (error) return
Error loading SSO settings: {error.message}
; @@ -43,15 +55,27 @@ const SSOSettings: React.FC = ({ accessToken }) => { - {providers.map(provider => ( + {providers.map((provider) => ( {provider.name} - {provider.connected ? "Connected" : "Not connected"} - - diff --git a/keep-ui/app/settings/auth/users-menu.tsx b/keep-ui/app/settings/auth/users-menu.tsx index 15bf36e78..e9dbc5f05 100644 --- a/keep-ui/app/settings/auth/users-menu.tsx +++ b/keep-ui/app/settings/auth/users-menu.tsx @@ -5,7 +5,7 @@ import { Bars3Icon } from "@heroicons/react/20/solid"; import { Icon } from "@tremor/react"; import { TrashIcon } from "@radix-ui/react-icons"; import { getSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { User } from "../models"; import { User as AuthUser } from "next-auth"; import { mutate } from "swr"; @@ -18,6 +18,7 @@ interface Props { export default function UsersMenu({ user, currentUser }: Props) { const { refs, x, y } = useFloating(); + const apiUrl = useApiUrl(); const onDelete = async () => { const confirmed = confirm( @@ -25,7 +26,7 @@ export default function UsersMenu({ user, currentUser }: Props) { ); if (confirmed) { const session = await getSession(); - const apiUrl = getApiURL(); + const res = await fetch(`${apiUrl}/users/${user.email}`, { method: "DELETE", headers: { diff --git a/keep-ui/app/settings/auth/users-settings.tsx b/keep-ui/app/settings/auth/users-settings.tsx index 10809df1e..198280211 100644 --- a/keep-ui/app/settings/auth/users-settings.tsx +++ b/keep-ui/app/settings/auth/users-settings.tsx @@ -1,11 +1,5 @@ import React, { useState, useEffect, useMemo } from "react"; -import { - Title, - Subtitle, - Card, - Button, - TextInput, -} from "@tremor/react"; +import { Title, Subtitle, Card, Button, TextInput } from "@tremor/react"; import Loading from "app/loading"; import { User as AuthUser } from "next-auth"; import { TiUserAdd } from "react-icons/ti"; @@ -16,7 +10,7 @@ import { useGroups } from "utils/hooks/useGroups"; import { useConfig } from "utils/hooks/useConfig"; import UsersSidebar from "./users-sidebar"; import { User } from "app/settings/models"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { UsersTable } from "./users-table"; interface Props { @@ -41,33 +35,38 @@ export default function UsersSettings({ const { data: groups } = useGroups(); const { data: configData } = useConfig(); - const [userStates, setUserStates] = useState<{ [key: string]: { role: string, groups: string[] } }>({}); + const [userStates, setUserStates] = useState<{ + [key: string]: { role: string; groups: string[] }; + }>({}); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [filter, setFilter] = useState(""); const [isNewUser, setIsNewUser] = useState(false); - + const apiUrl = useApiUrl(); // Determine runtime configuration const authType = configData?.AUTH_TYPE as AuthenticationType; - const apiUrl = getApiURL(); useEffect(() => { if (users) { - const initialUserStates = users.reduce((acc, user) => { - acc[user.email] = { - role: user.role, - groups: user.groups ? user.groups.map(group => group.name) : [], - }; - return acc; - }, {} as { [key: string]: { role: string, groups: string[] } }); + const initialUserStates = users.reduce( + (acc, user) => { + acc[user.email] = { + role: user.role, + groups: user.groups ? user.groups.map((group) => group.name) : [], + }; + return acc; + }, + {} as { [key: string]: { role: string; groups: string[] } } + ); setUserStates(initialUserStates); } }, [users]); const filteredUsers = useMemo(() => { - const filtered = users?.filter(user => - user.email.toLowerCase().includes(filter.toLowerCase()) - ) || []; + const filtered = + users?.filter((user) => + user.email.toLowerCase().includes(filter.toLowerCase()) + ) || []; return filtered.sort((a, b) => { if (a.last_login && !b.last_login) return -1; @@ -90,13 +89,16 @@ export default function UsersSettings({ setIsSidebarOpen(true); }; - const handleDeleteUser = async (userEmail: string, event: React.MouseEvent) => { + const handleDeleteUser = async ( + userEmail: string, + event: React.MouseEvent + ) => { event.stopPropagation(); if (window.confirm("Are you sure you want to delete this user?")) { try { - const url = `${getApiURL()}/auth/users/${userEmail}`; + const url = `${apiUrl}/auth/users/${userEmail}`; const response = await fetch(url, { - method: 'DELETE', + method: "DELETE", headers: { Authorization: `Bearer ${accessToken}`, }, @@ -156,7 +158,9 @@ export default function UsersSettings({ isNewUser={isNewUser} mutateUsers={mutateUsers} groupsEnabled={groupsAllowed} - identifierType={authType === AuthenticationType.DB ? "username" : "email"} + identifierType={ + authType === AuthenticationType.DB ? "username" : "email" + } /> ); diff --git a/keep-ui/app/settings/auth/users-sidebar.tsx b/keep-ui/app/settings/auth/users-sidebar.tsx index 72dd6a2d9..7d8466d48 100644 --- a/keep-ui/app/settings/auth/users-sidebar.tsx +++ b/keep-ui/app/settings/auth/users-sidebar.tsx @@ -1,16 +1,31 @@ import { Fragment, useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; -import { Text, Subtitle, Button, TextInput, SearchSelect, SearchSelectItem, MultiSelect, MultiSelectItem, Callout } from "@tremor/react"; +import { + Text, + Subtitle, + Button, + TextInput, + SearchSelect, + SearchSelectItem, + MultiSelect, + MultiSelectItem, + Callout, +} from "@tremor/react"; import { IoMdClose } from "react-icons/io"; -import { useForm, Controller, SubmitHandler, FieldValues } from "react-hook-form"; +import { + useForm, + Controller, + SubmitHandler, + FieldValues, +} from "react-hook-form"; import { useRoles } from "utils/hooks/useRoles"; import { useGroups } from "utils/hooks/useGroups"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { useSession } from "next-auth/react"; import { User, Group } from "app/settings/models"; import { AuthenticationType } from "utils/authenticationType"; import { useConfig } from "utils/hooks/useConfig"; -import Select from "@/components/ui/Select"; +import Select from "@/components/ui/Select"; interface UserSidebarProps { isOpen: boolean; @@ -19,11 +34,27 @@ interface UserSidebarProps { isNewUser: boolean; mutateUsers: (data?: any, shouldRevalidate?: boolean) => Promise; groupsEnabled?: boolean; - identifierType: 'email' | 'username'; + identifierType: "email" | "username"; } -const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnabled = true, identifierType }: UserSidebarProps) => { - const { control, handleSubmit, setValue, reset, formState: { errors, isDirty }, clearErrors, setError } = useForm<{ +const UsersSidebar = ({ + isOpen, + toggle, + user, + isNewUser, + mutateUsers, + groupsEnabled = true, + identifierType, +}: UserSidebarProps) => { + const { + control, + handleSubmit, + setValue, + reset, + formState: { errors, isDirty }, + clearErrors, + setError, + } = useForm<{ username: string; name: string; role: string; @@ -35,7 +66,7 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab name: "", role: "", groups: [], - password: "" + password: "", }, }); @@ -44,13 +75,13 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab const { data: groups = [], mutate: mutateGroups } = useGroups(); const [isSubmitting, setIsSubmitting] = useState(false); const { data: configData } = useConfig(); - + const apiUrl = useApiUrl(); const authType = configData?.AUTH_TYPE as AuthenticationType; useEffect(() => { if (isOpen) { if (user) { - if (identifierType === 'email') { + if (identifierType === "email") { // server parse as email setValue("username", user.email); setValue("name", user.name); @@ -67,7 +98,7 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab groups: [], }); } - clearErrors(); // Clear errors when the modal is opened + clearErrors(); // Clear errors when the modal is opened } }, [user, setValue, isOpen, reset, clearErrors, identifierType]); @@ -76,7 +107,11 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab clearErrors("root.serverError"); const method = isNewUser ? "POST" : "PUT"; - const url = isNewUser ? `${getApiURL()}/auth/users` : `${getApiURL()}/auth/users/${identifierType === 'email' ? user?.email : user?.name}`; + const url = isNewUser + ? `${apiUrl}/auth/users` + : `${apiUrl}/auth/users/${ + identifierType === "email" ? user?.email : user?.name + }`; try { const response = await fetch(url, { method: method, @@ -93,7 +128,11 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab handleClose(); } else { const errorData = await response.json(); - setError("root.serverError", { type: "manual", message: errorData.detail || errorData.message || "Failed to save user" }); + setError("root.serverError", { + type: "manual", + message: + errorData.detail || errorData.message || "Failed to save user", + }); } } catch (error) { setError("root.serverError", { @@ -107,12 +146,12 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab const handleSubmitClick = (e: React.FormEvent) => { e.preventDefault(); - clearErrors(); // Clear errors on each submit click + clearErrors(); // Clear errors on each submit click handleSubmit(onSubmit)(); }; const handleClose = () => { - setIsSubmitting(false); // Ensure isSubmitting is reset when closing the modal + setIsSubmitting(false); // Ensure isSubmitting is reset when closing the modal clearErrors("root.serverError"); reset(); toggle(); @@ -150,9 +189,12 @@ const UsersSidebar = ({ isOpen, toggle, user, isNewUser, mutateUsers, groupsEnab - +
- {identifierType === 'email' ? ( + {identifierType === "email" ? ( <>
)} {/* Password Field */} - {(authType === AuthenticationType.DB || authType === AuthenticationType.KEYCLOAK) && isNewUser && ( -
- Password - ( - - )} - /> -
- )} + {(authType === AuthenticationType.DB || + authType === AuthenticationType.KEYCLOAK) && + isNewUser && ( +
+ Password + ( + + )} + /> +
+ )}
- {error &&

Failed to upload the file: {error}

Please try again with another file.

} + {error && ( +

+ Failed to upload the file: {error} +

Please try again with another file. +

+ )}
); }; diff --git a/keep-ui/app/workflows/manual-run-workflow-modal.tsx b/keep-ui/app/workflows/manual-run-workflow-modal.tsx index 753dd0baf..bc1494f33 100644 --- a/keep-ui/app/workflows/manual-run-workflow-modal.tsx +++ b/keep-ui/app/workflows/manual-run-workflow-modal.tsx @@ -1,10 +1,10 @@ -import {Button, Select, SelectItem, Title} from "@tremor/react"; +import { Button, Select, SelectItem, Title } from "@tremor/react"; import Modal from "@/components/ui/Modal"; import { useWorkflows } from "utils/hooks/useWorkflows"; import { useState } from "react"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { toast } from "react-toastify"; import { useRouter } from "next/navigation"; import { IncidentDto } from "@/app/incidents/models"; @@ -16,7 +16,11 @@ interface Props { handleClose: () => void; } -export default function ManualRunWorkflowModal({ alert, incident, handleClose }: Props) { +export default function ManualRunWorkflowModal({ + alert, + incident, + handleClose, +}: Props) { /** * */ @@ -26,7 +30,7 @@ export default function ManualRunWorkflowModal({ alert, incident, handleClose }: const { data: workflows } = useWorkflows({}); const { data: session } = useSession(); const router = useRouter(); - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const isOpen = !!alert || !!incident; @@ -44,7 +48,10 @@ export default function ManualRunWorkflowModal({ alert, incident, handleClose }: Authorization: `Bearer ${session?.accessToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({"type": alert ? "alert" : "incident", "body": alert ? alert : incident}), + body: JSON.stringify({ + type: alert ? "alert" : "incident", + body: alert ? alert : incident, + }), } ); diff --git a/keep-ui/app/workflows/mockworkflows.tsx b/keep-ui/app/workflows/mockworkflows.tsx index 0ea737931..9e0f0f653 100644 --- a/keep-ui/app/workflows/mockworkflows.tsx +++ b/keep-ui/app/workflows/mockworkflows.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { MockAction, MockStep, MockWorkflow, Workflow } from "./models"; -import { getApiURL } from "../../utils/apiUrl"; import Loading from "../loading"; import { Button, Card, Tab, TabGroup, TabList } from "@tremor/react"; import Modal from "@/components/ui/Modal"; @@ -18,13 +17,16 @@ export function WorkflowSteps({ workflow }: { workflow: MockWorkflow }) {
{workflow?.steps?.map((step: any, index: number) => { const provider = step?.provider; - if (['threshold', 'assert', 'foreach'].includes(provider?.type)) { + if (["threshold", "assert", "foreach"].includes(provider?.type)) { return null; } return ( <> {provider && ( -
+
{index > 0 && ( )} @@ -42,13 +44,16 @@ export function WorkflowSteps({ workflow }: { workflow: MockWorkflow }) { })} {workflow?.actions?.map((action: any, index: number) => { const provider = action?.provider; - if (['threshold', 'assert', 'foreach'].includes(provider?.type)) { + if (["threshold", "assert", "foreach"].includes(provider?.type)) { return null; } return ( <> {provider && ( -
+
{(index > 0 || isStepPresent) && ( )} @@ -98,7 +103,7 @@ export default function MockWorkflowCardSection({ mockLoading: boolean | null; }) { const router = useRouter(); - const [loadingId, setLoadingId] = useState(null); + const [loadingId, setLoadingId] = useState(null); const getNameFromId = (id: string) => { if (!id) { @@ -147,7 +152,7 @@ export default function MockWorkflowCardSection({ )}
- {mockError && ( + {mockError && (

Error: {mockError.message || "Something went wrong!"}

diff --git a/keep-ui/app/workflows/workflow-tile.tsx b/keep-ui/app/workflows/workflow-tile.tsx index 7e3da37ba..ad9a4e636 100644 --- a/keep-ui/app/workflows/workflow-tile.tsx +++ b/keep-ui/app/workflows/workflow-tile.tsx @@ -2,7 +2,7 @@ import { useSession } from "next-auth/react"; import { Workflow, Filter } from "./models"; -import { getApiURL } from "../../utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import Image from "next/image"; import React, { useState, useMemo } from "react"; import { useRouter } from "next/navigation"; @@ -265,7 +265,7 @@ export const ProvidersCarousel = ({ function WorkflowTile({ workflow }: { workflow: Workflow }) { // Create a set to keep track of unique providers - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const router = useRouter(); const [openPanel, setOpenPanel] = useState(false); @@ -720,7 +720,7 @@ function WorkflowTile({ workflow }: { workflow: Workflow }) { export function WorkflowTileOld({ workflow }: { workflow: Workflow }) { // Create a set to keep track of unique providers - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const router = useRouter(); const [openPanel, setOpenPanel] = useState(false); diff --git a/keep-ui/app/workflows/workflows.client.tsx b/keep-ui/app/workflows/workflows.client.tsx index 2d90aa818..8a5d9e34b 100644 --- a/keep-ui/app/workflows/workflows.client.tsx +++ b/keep-ui/app/workflows/workflows.client.tsx @@ -11,7 +11,7 @@ import { import { useSession } from "next-auth/react"; import { fetcher } from "../../utils/fetcher"; import { Workflow, MockWorkflow } from "./models"; -import { getApiURL } from "../../utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import Loading from "../loading"; import React from "react"; import WorkflowsEmptyState from "./noworfklows"; @@ -23,7 +23,7 @@ import Modal from "@/components/ui/Modal"; import MockWorkflowCardSection from "./mockworkflows"; export default function WorkflowsPage() { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const router = useRouter(); const { data: session, status, update } = useSession(); const [fileError, setFileError] = useState(null); diff --git a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx index 460d65fe7..f6465a5ef 100644 --- a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx +++ b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx @@ -1,7 +1,7 @@ import { CSSProperties, useEffect, useState } from "react"; import { Session } from "next-auth"; import { toast } from "react-toastify"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { usePresets } from "utils/hooks/usePresets"; import { AiOutlineSwap } from "react-icons/ai"; import { usePathname, useRouter } from "next/navigation"; @@ -22,7 +22,7 @@ import { CSS } from "@dnd-kit/utilities"; import { Preset } from "app/alerts/models"; import { AiOutlineSound } from "react-icons/ai"; // Using dynamic import to avoid hydration issues with react-player -import dynamic from 'next/dynamic' +import dynamic from "next/dynamic"; const ReactPlayer = dynamic(() => import("react-player"), { ssr: false }); // import css import "./CustomPresetAlertLink.css"; @@ -60,12 +60,7 @@ const PresetAlert = ({ preset, pathname, deletePreset }: PresetAlertProps) => { }; return ( -
  • +
  • { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); - const { useAllPresets, presetsOrderFromLS, setPresetsOrderFromLS } = usePresets(); + const { useAllPresets, presetsOrderFromLS, setPresetsOrderFromLS } = + usePresets(); const { data: presets = [], mutate: presetsMutator } = useAllPresets({ revalidateIfStale: false, revalidateOnFocus: false, @@ -126,16 +122,26 @@ export const CustomPresetAlertLinks = ({ useEffect(() => { const filteredLS = presetsOrderFromLS.filter( - (preset) => !["feed", "deleted", "dismissed", "without-incident", "groups"].includes(preset.name) + (preset) => + ![ + "feed", + "deleted", + "dismissed", + "without-incident", + "groups", + ].includes(preset.name) ); // Combine live presets and local storage order - const combinedOrder = presets.reduce((acc, preset: Preset) => { - if (!acc.find((p) => p.id === preset.id)) { - acc.push(preset); - } - return acc.filter((preset) => checkValidPreset(preset)); - }, [...filteredLS]); + const combinedOrder = presets.reduce( + (acc, preset: Preset) => { + if (!acc.find((p) => p.id === preset.id)) { + acc.push(preset); + } + return acc.filter((preset) => checkValidPreset(preset)); + }, + [...filteredLS] + ); // Only update state if there's an actual change to prevent infinite loops if (JSON.stringify(presetsOrder) !== JSON.stringify(combinedOrder)) { @@ -143,11 +149,12 @@ export const CustomPresetAlertLinks = ({ } }, [presets, presetsOrderFromLS]); // Filter presets based on tags, or return all if no tags are selected - const filteredOrderedPresets = selectedTags.length === 0 - ? presetsOrder - : presetsOrder.filter((preset) => - preset.tags.some((tag) => selectedTags.includes(tag.name)) - ); + const filteredOrderedPresets = + selectedTags.length === 0 + ? presetsOrder + : presetsOrder.filter((preset) => + preset.tags.some((tag) => selectedTags.includes(tag.name)) + ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { diff --git a/keep-ui/components/navbar/DashboardLinks.tsx b/keep-ui/components/navbar/DashboardLinks.tsx index 948be30ad..cf0cab3d4 100644 --- a/keep-ui/components/navbar/DashboardLinks.tsx +++ b/keep-ui/components/navbar/DashboardLinks.tsx @@ -15,7 +15,7 @@ import { Disclosure } from "@headlessui/react"; import { IoChevronUp } from "react-icons/io5"; import classNames from "classnames"; import { useDashboards } from "utils/hooks/useDashboards"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "utils/hooks/useConfig"; import { Session } from "next-auth"; import { PlusIcon } from "@radix-ui/react-icons"; @@ -28,6 +28,7 @@ export const DashboardLinks = ({ session }: DashboardProps) => { const { dashboards = [], isLoading, error, mutate } = useDashboards(); const pathname = usePathname(); const router = useRouter(); + const apiUrl = useApiUrl(); const sensors = useSensors(useSensor(PointerSensor), useSensor(TouchSensor)); @@ -51,7 +52,6 @@ export const DashboardLinks = ({ session }: DashboardProps) => { ); if (isDeleteConfirmed) { try { - const apiUrl = getApiURL(); await fetch(`${apiUrl}/dashboard/${id}`, { method: "DELETE", headers: { diff --git a/keep-ui/middleware.tsx b/keep-ui/middleware.tsx index 9c405978d..4f3390cfd 100644 --- a/keep-ui/middleware.tsx +++ b/keep-ui/middleware.tsx @@ -5,6 +5,8 @@ import { getApiURL } from "utils/apiUrl"; export default withAuth( function middleware(req) { const { pathname, searchParams } = new URL(req.url); + // Shahar: This is just for backward compatibility + // **should be removed** // Redirect /backend/ to the API if (pathname.startsWith("/backend/")) { let apiUrl = getApiURL(); diff --git a/keep-ui/pages/api/config.tsx b/keep-ui/pages/api/config.tsx index 07ef9a2a6..e052a7022 100644 --- a/keep-ui/pages/api/config.tsx +++ b/keep-ui/pages/api/config.tsx @@ -1,5 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { AuthenticationType, MULTI_TENANT, SINGLE_TENANT, NO_AUTH } from "utils/authenticationType"; +import { + AuthenticationType, + MULTI_TENANT, + SINGLE_TENANT, + NO_AUTH, +} from "utils/authenticationType"; export default async function handler( req: NextApiRequest, @@ -19,13 +24,20 @@ export default async function handler( res.status(200).json({ AUTH_TYPE: authType, PUSHER_DISABLED: process.env.PUSHER_DISABLED === "true", + // could be relative (for ingress) or absolute (e.g. Pusher) PUSHER_HOST: process.env.PUSHER_HOST, PUSHER_PORT: process.env.PUSHER_HOST ? parseInt(process.env.PUSHER_PORT!) : undefined, PUSHER_APP_KEY: process.env.PUSHER_APP_KEY, PUSHER_CLUSTER: process.env.PUSHER_CLUSTER, + // The API URL is used by the server to make requests to the API + // note that we need two different URLs for the client and the server + // because in some environments, e.g. docker-compose, the server can get keep-backend + // whereas the client (browser) can get only localhost API_URL: process.env.API_URL, + // could be relative (e.g. for ingress) or absolute (e.g. for cloud run) + API_URL_CLIENT: process.env.API_URL_CLIENT, POSTHOG_KEY: process.env.POSTHOG_KEY, POSTHOG_DISABLED: process.env.POSTHOG_DISABLED, POSTHOG_HOST: process.env.POSTHOG_HOST, diff --git a/keep-ui/types/internal-config.ts b/keep-ui/types/internal-config.ts index 522e3da87..e8a368cd0 100644 --- a/keep-ui/types/internal-config.ts +++ b/keep-ui/types/internal-config.ts @@ -5,7 +5,13 @@ export interface InternalConfig { PUSHER_PORT?: number; PUSHER_APP_KEY: string; PUSHER_CLUSTER?: string; + PUSHER_INGRESS: boolean; // e.g. for kubernetes/helmchart where the websocket endpoint is through the ingress POSTHOG_KEY: string; POSTHOG_HOST: string; POSTHOG_DISABLED: string; + // the API URL is used by the server to make requests to the API + API_URL: string; + // the API URL for the client (browser) + // optional, defaults to /backend (relative) + API_URL_CLIENT?: string; } diff --git a/keep-ui/utils/apiUrl.ts b/keep-ui/utils/apiUrl.ts index 22478957f..10e8c7878 100644 --- a/keep-ui/utils/apiUrl.ts +++ b/keep-ui/utils/apiUrl.ts @@ -1,21 +1,6 @@ +// server only! export function getApiURL(): string { - // https://github.com/vercel/next.js/issues/5354#issuecomment-520305040 - // https://stackoverflow.com/questions/49411796/how-do-i-detect-whether-i-am-on-server-on-client-in-next-js - - // Some background on this: - // On docker-compose, the browser can't access the "http://keep-backend" url - // since its the name of the container (and not accesible from the host) - // so we need to use the "http://localhost:3000" url instead. - const componentType = typeof window === "undefined" ? "server" : "client"; - - // if its client, use the same url as the browser but with the "/backend" prefix so that middleware.ts can proxy the request to the backend - if (componentType === "client") { - return "/backend"; - } - - // SERVER ONLY FROM HERE ON - - // else, its the server, and we need to check if we are on vercel or not + // we need to check if we are on vercel or not const gitBranchName = process.env.VERCEL_GIT_COMMIT_REF || "notvercel"; // main branch or not vercel - use the normal url if (gitBranchName === "main" || gitBranchName === "notvercel") { diff --git a/keep-ui/utils/helpers.ts b/keep-ui/utils/helpers.ts index a54434487..1fd58bd4d 100644 --- a/keep-ui/utils/helpers.ts +++ b/keep-ui/utils/helpers.ts @@ -1,5 +1,4 @@ import { toast } from "react-toastify"; -import { getApiURL } from "./apiUrl"; import { Provider } from "../app/providers/providers"; import moment from "moment"; import { twMerge } from "tailwind-merge"; @@ -39,12 +38,14 @@ export function toDateObjectWithFallback(date: string | Date) { return new Date(); } -export async function installWebhook(provider: Provider, accessToken: string) { +export async function installWebhook( + provider: Provider, + accessToken: string, + apiUrl: string +) { return toast.promise( fetch( - `${getApiURL()}/providers/install/webhook/${provider.type}/${ - provider.id - }`, + `${apiUrl}/providers/install/webhook/${provider.type}/${provider.id}`, { method: "POST", headers: { diff --git a/keep-ui/utils/hooks/useAI.ts b/keep-ui/utils/hooks/useAI.ts index 57af67d73..329830b32 100644 --- a/keep-ui/utils/hooks/useAI.ts +++ b/keep-ui/utils/hooks/useAI.ts @@ -1,19 +1,18 @@ import { AILogs, AIStats } from "app/ai/model"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { useWebsocket } from "./usePusher"; import { useCallback, useEffect } from "react"; - export const useAIStats = ( options: SWRConfiguration = { revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( @@ -38,4 +37,4 @@ export const usePollAILogs = (mutateAILogs: (logs: AILogs) => void) => { unbind("ai-logs-change", handleIncoming); }; }, [bind, unbind, handleIncoming]); -}; \ No newline at end of file +}; diff --git a/keep-ui/utils/hooks/useAlerts.ts b/keep-ui/utils/hooks/useAlerts.ts index 8b0686b62..27ce9555e 100644 --- a/keep-ui/utils/hooks/useAlerts.ts +++ b/keep-ui/utils/hooks/useAlerts.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { AlertDto } from "app/alerts/models"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { toDateObjectWithFallback } from "utils/helpers"; @@ -16,7 +16,7 @@ export type AuditEvent = { }; export const useAlerts = () => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const useAlertHistory = ( diff --git a/keep-ui/utils/hooks/useConfig.ts b/keep-ui/utils/hooks/useConfig.ts index 68de69202..7356783e2 100644 --- a/keep-ui/utils/hooks/useConfig.ts +++ b/keep-ui/utils/hooks/useConfig.ts @@ -10,3 +10,15 @@ export const useConfig = () => { fetcher("/api/config", session?.accessToken) ); }; + +export const useApiUrl = () => { + const { data: config } = useConfig(); + + if (config?.API_URL_CLIENT) { + return config.API_URL_CLIENT; + } + + // backward compatibility or for docker-compose or other deployments where the browser + // can't access the API directly + return "/backend"; +}; diff --git a/keep-ui/utils/hooks/useDashboards.ts b/keep-ui/utils/hooks/useDashboards.ts index de61bd967..7f3c81245 100644 --- a/keep-ui/utils/hooks/useDashboards.ts +++ b/keep-ui/utils/hooks/useDashboards.ts @@ -1,6 +1,6 @@ import useSWR from "swr"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export interface Dashboard { @@ -11,7 +11,7 @@ export interface Dashboard { export const useDashboards = () => { const { data: session } = useSession(); - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data, error, mutate } = useSWR( session ? `${apiUrl}/dashboard` : null, diff --git a/keep-ui/utils/hooks/useDeduplicationRules.ts b/keep-ui/utils/hooks/useDeduplicationRules.ts index 1d6d43c54..54b68015b 100644 --- a/keep-ui/utils/hooks/useDeduplicationRules.ts +++ b/keep-ui/utils/hooks/useDeduplicationRules.ts @@ -2,11 +2,11 @@ import { DeduplicationRule } from "app/deduplication/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useDeduplicationRules = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( @@ -17,7 +17,7 @@ export const useDeduplicationRules = (options: SWRConfiguration = {}) => { }; export const useDeduplicationFields = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable>( diff --git a/keep-ui/utils/hooks/useExtractionRules.ts b/keep-ui/utils/hooks/useExtractionRules.ts index 779509bdd..0ff32b445 100644 --- a/keep-ui/utils/hooks/useExtractionRules.ts +++ b/keep-ui/utils/hooks/useExtractionRules.ts @@ -1,7 +1,7 @@ import { ExtractionRule } from "app/extraction/model"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useExtractions = ( @@ -9,7 +9,7 @@ export const useExtractions = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( diff --git a/keep-ui/utils/hooks/useGroups.ts b/keep-ui/utils/hooks/useGroups.ts index 0c2ed17fe..3e1dde9a2 100644 --- a/keep-ui/utils/hooks/useGroups.ts +++ b/keep-ui/utils/hooks/useGroups.ts @@ -2,11 +2,11 @@ import { Group } from "app/settings/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useGroups = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( diff --git a/keep-ui/utils/hooks/useIncidents.ts b/keep-ui/utils/hooks/useIncidents.ts index 9e94c6386..b3ba58a26 100644 --- a/keep-ui/utils/hooks/useIncidents.ts +++ b/keep-ui/utils/hooks/useIncidents.ts @@ -7,7 +7,7 @@ import { import { PaginatedWorkflowExecutionDto } from "app/workflows/builder/types"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { useWebsocket } from "./usePusher"; import { useCallback, useEffect } from "react"; @@ -35,7 +35,7 @@ export const useIncidents = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const filtersParams = new URLSearchParams(); @@ -70,7 +70,7 @@ export const useIncidentAlerts = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( () => @@ -88,7 +88,7 @@ export const useIncidentFutureIncidents = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( @@ -105,7 +105,7 @@ export const useIncident = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( @@ -123,7 +123,7 @@ export const useIncidentWorkflowExecutions = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( () => @@ -192,7 +192,7 @@ export const useIncidentsMeta = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( diff --git a/keep-ui/utils/hooks/useMaintenanceRules.ts b/keep-ui/utils/hooks/useMaintenanceRules.ts index 0e46f54b9..b771615b1 100644 --- a/keep-ui/utils/hooks/useMaintenanceRules.ts +++ b/keep-ui/utils/hooks/useMaintenanceRules.ts @@ -1,7 +1,7 @@ import { MaintenanceRule } from "app/maintenance/model"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useMaintenanceRules = ( @@ -9,7 +9,7 @@ export const useMaintenanceRules = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( diff --git a/keep-ui/utils/hooks/useMappingRules.ts b/keep-ui/utils/hooks/useMappingRules.ts index 87db305a1..cf116bc9c 100644 --- a/keep-ui/utils/hooks/useMappingRules.ts +++ b/keep-ui/utils/hooks/useMappingRules.ts @@ -1,7 +1,7 @@ import { MappingRule } from "app/mapping/models"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useMappings = ( @@ -9,7 +9,7 @@ export const useMappings = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( diff --git a/keep-ui/utils/hooks/usePermissions.ts b/keep-ui/utils/hooks/usePermissions.ts index 3248cc082..b240ec2f6 100644 --- a/keep-ui/utils/hooks/usePermissions.ts +++ b/keep-ui/utils/hooks/usePermissions.ts @@ -2,11 +2,11 @@ import { Permission } from "app/settings/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const usePermissions = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( diff --git a/keep-ui/utils/hooks/usePresets.ts b/keep-ui/utils/hooks/usePresets.ts index b4b9d5509..3559427d9 100644 --- a/keep-ui/utils/hooks/usePresets.ts +++ b/keep-ui/utils/hooks/usePresets.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { Preset } from "app/alerts/models"; import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { useLocalStorage } from "utils/hooks/useLocalStorage"; import { useConfig } from "./useConfig"; @@ -14,7 +14,7 @@ import moment from "moment"; export const usePresets = (type?: string, useFilters?: boolean) => { const { data: session } = useSession(); const { data: configData } = useConfig(); - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); //ideally, we can use pathname. but hardcoding it for now. const isDashBoard = type === "dashboard"; const [presetsOrderFromLS, setPresetsOrderFromLS] = useLocalStorage( @@ -54,8 +54,8 @@ export const usePresets = (type?: string, useFilters?: boolean) => { ...currentPreset, alerts_count: currentPreset.alerts_count + newPreset.alerts_count, created_by: newPreset.created_by, - is_private: newPreset.is_private - }); + is_private: newPreset.is_private, + }); } else { // If the preset is not in the current presets, add it updatedPresets.set(newPresetId, { @@ -71,7 +71,14 @@ export const usePresets = (type?: string, useFilters?: boolean) => { updatePresets( presetsOrderRef.current, newPresets.filter( - (p) => !["feed", "deleted", "dismissed", "without-incident", "groups"].includes(p.name) + (p) => + ![ + "feed", + "deleted", + "dismissed", + "without-incident", + "groups", + ].includes(p.name) ) ) ); @@ -79,7 +86,13 @@ export const usePresets = (type?: string, useFilters?: boolean) => { updatePresets( staticPresetsOrderRef.current, newPresets.filter((p) => - ["feed", "deleted", "dismissed", "without-incident", "groups"].includes(p.name) + [ + "feed", + "deleted", + "dismissed", + "without-incident", + "groups", + ].includes(p.name) ) ) ); @@ -126,10 +139,22 @@ export const usePresets = (type?: string, useFilters?: boolean) => { if (data) { const dynamicPresets = data.filter( (p) => - !["feed", "deleted", "dismissed", "without-incident", "groups"].includes(p.name) + ![ + "feed", + "deleted", + "dismissed", + "without-incident", + "groups", + ].includes(p.name) ); const staticPresets = data.filter((p) => - ["feed", "deleted", "dismissed", "without-incident", "groups"].includes(p.name) + [ + "feed", + "deleted", + "dismissed", + "without-incident", + "groups", + ].includes(p.name) ); //if it is dashboard we don't need to merge with local storage. @@ -193,7 +218,13 @@ export const usePresets = (type?: string, useFilters?: boolean) => { } = useFetchAllPresets(options); const filteredPresets = presets?.filter( (preset) => - !["feed", "deleted", "dismissed", "groups", "without-incident"].includes(preset.name) + ![ + "feed", + "deleted", + "dismissed", + "groups", + "without-incident", + ].includes(preset.name) ); return { data: filteredPresets, diff --git a/keep-ui/utils/hooks/useProviders.ts b/keep-ui/utils/hooks/useProviders.ts index f7d3d854f..668af3a1e 100644 --- a/keep-ui/utils/hooks/useProviders.ts +++ b/keep-ui/utils/hooks/useProviders.ts @@ -1,5 +1,5 @@ import { useSession } from "next-auth/react"; -import { getApiURL } from "../apiUrl"; +import { useApiUrl } from "./useConfig"; import { SWRConfiguration } from "swr"; import { ProvidersResponse } from "app/providers/providers"; import { fetcher } from "../fetcher"; @@ -9,7 +9,7 @@ export const useProviders = ( options: SWRConfiguration = { revalidateOnFocus: false } ) => { const { data: session } = useSession(); - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); return useSWRImmutable( () => (session ? `${apiUrl}/providers` : null), diff --git a/keep-ui/utils/hooks/usePusher.ts b/keep-ui/utils/hooks/usePusher.ts index 106c46384..57828a2a3 100644 --- a/keep-ui/utils/hooks/usePusher.ts +++ b/keep-ui/utils/hooks/usePusher.ts @@ -1,18 +1,21 @@ import Pusher from "pusher-js"; import { useConfig } from "./useConfig"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; let PUSHER: Pusher | null = null; const POLLING_INTERVAL = 3000; export const useWebsocket = () => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: configData } = useConfig(); const { data: session } = useSession(); let channelName = `private-${session?.tenantId}`; + console.log("useWebsocket: Initializing with config:", configData); + console.log("useWebsocket: Session:", session); + if ( PUSHER === null && configData !== undefined && @@ -20,34 +23,73 @@ export const useWebsocket = () => { configData.PUSHER_DISABLED === false ) { channelName = `private-${session?.tenantId}`; - PUSHER = new Pusher(configData.PUSHER_APP_KEY, { - wsHost: configData.PUSHER_HOST, - wsPort: configData.PUSHER_PORT, - forceTLS: false, - disableStats: true, - enabledTransports: ["ws", "wss"], - cluster: configData.PUSHER_CLUSTER || "local", - channelAuthorization: { - transport: "ajax", - endpoint: `${apiUrl}/pusher/auth`, - headers: { - Authorization: `Bearer ${session?.accessToken!}`, + console.log("useWebsocket: Creating new Pusher instance"); + try { + const isRelativeHost = + configData.PUSHER_HOST && !configData.PUSHER_HOST.includes("://"); + console.log("useWebsocket: isRelativeHost:", isRelativeHost); + PUSHER = new Pusher(configData.PUSHER_APP_KEY, { + wsHost: isRelativeHost + ? window.location.hostname + : configData.PUSHER_HOST, + wsPath: isRelativeHost ? configData.PUSHER_HOST : "", + wsPort: isRelativeHost + ? window.location.protocol === "https:" + ? 443 + : 80 + : configData.PUSHER_PORT, + forceTLS: window.location.protocol === "https:", + disableStats: true, + enabledTransports: ["ws", "wss"], + cluster: configData.PUSHER_CLUSTER || "local", + channelAuthorization: { + transport: "ajax", + endpoint: `${apiUrl}/pusher/auth`, + headers: { + Authorization: `Bearer ${session?.accessToken!}`, + }, }, - }, - }); - PUSHER.subscribe(channelName); + }); + console.log("useWebsocket: Pusher instance created successfully"); + + PUSHER.connection.bind("connected", () => { + console.log("useWebsocket: Pusher connected successfully"); + }); + + PUSHER.connection.bind("error", (err: any) => { + console.error("useWebsocket: Pusher connection error:", err); + }); + + PUSHER.subscribe(channelName) + .bind("pusher:subscription_succeeded", () => { + console.log( + `useWebsocket: Successfully subscribed to ${channelName}` + ); + }) + .bind("pusher:subscription_error", (err: any) => { + console.error( + `useWebsocket: Subscription error for ${channelName}:`, + err + ); + }); + } catch (error) { + console.error("useWebsocket: Error creating Pusher instance:", error); + } } const subscribe = useCallback(() => { + console.log(`useWebsocket: Subscribing to ${channelName}`); return PUSHER?.subscribe(channelName); }, [channelName]); const unsubscribe = useCallback(() => { + console.log(`useWebsocket: Unsubscribing from ${channelName}`); return PUSHER?.unsubscribe(channelName); }, [channelName]); const bind = useCallback( (event: any, callback: any) => { + console.log(`useWebsocket: Binding to event ${event} on ${channelName}`); return PUSHER?.channel(channelName)?.bind(event, callback); }, [channelName] @@ -55,6 +97,9 @@ export const useWebsocket = () => { const unbind = useCallback( (event: any, callback: any) => { + console.log( + `useWebsocket: Unbinding from event ${event} on ${channelName}` + ); return PUSHER?.channel(channelName)?.unbind(event, callback); }, [channelName] @@ -62,12 +107,17 @@ export const useWebsocket = () => { const trigger = useCallback( (event: any, data: any) => { + console.log( + `useWebsocket: Triggering event ${event} on ${channelName} with data:`, + data + ); return PUSHER?.channel(channelName).trigger(event, data); }, [channelName] ); const channel = useCallback(() => { + console.log(`useWebsocket: Getting channel ${channelName}`); return PUSHER?.channel(channelName); }, [channelName]); @@ -86,24 +136,40 @@ export const useAlertPolling = () => { const [pollAlerts, setPollAlerts] = useState(0); const lastPollTimeRef = useRef(0); + console.log("useAlertPolling: Initializing"); + const handleIncoming = useCallback((incoming: any) => { + console.log("useAlertPolling: Received incoming data:", incoming); const currentTime = Date.now(); const timeSinceLastPoll = currentTime - lastPollTimeRef.current; + console.log( + `useAlertPolling: Time since last poll: ${timeSinceLastPoll}ms` + ); + if (timeSinceLastPoll < POLLING_INTERVAL) { + console.log("useAlertPolling: Ignoring poll due to short interval"); setPollAlerts(0); } else { + console.log("useAlertPolling: Updating poll alerts"); lastPollTimeRef.current = currentTime; - setPollAlerts(Math.floor(Math.random() * 10000)); + const newPollValue = Math.floor(Math.random() * 10000); + console.log(`useAlertPolling: New poll value: ${newPollValue}`); + setPollAlerts(newPollValue); } }, []); useEffect(() => { + console.log("useAlertPolling: Setting up event listener for 'poll-alerts'"); bind("poll-alerts", handleIncoming); return () => { + console.log( + "useAlertPolling: Cleaning up event listener for 'poll-alerts'" + ); unbind("poll-alerts", handleIncoming); }; }, [bind, unbind, handleIncoming]); + console.log("useAlertPolling: Current poll alerts value:", pollAlerts); return { data: pollAlerts }; }; diff --git a/keep-ui/utils/hooks/useRoles.ts b/keep-ui/utils/hooks/useRoles.ts index f71a048a6..dabfc9b58 100644 --- a/keep-ui/utils/hooks/useRoles.ts +++ b/keep-ui/utils/hooks/useRoles.ts @@ -2,11 +2,11 @@ import { Role } from "app/settings/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useRoles = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( diff --git a/keep-ui/utils/hooks/useRules.ts b/keep-ui/utils/hooks/useRules.ts index 3a710ed88..97976d51e 100644 --- a/keep-ui/utils/hooks/useRules.ts +++ b/keep-ui/utils/hooks/useRules.ts @@ -1,6 +1,6 @@ import { useSession } from "next-auth/react"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export type Rule = { @@ -25,7 +25,7 @@ export type Rule = { }; export const useRules = (options?: SWRConfiguration) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( diff --git a/keep-ui/utils/hooks/useScopes.ts b/keep-ui/utils/hooks/useScopes.ts index 8d437e8c1..775d8717a 100644 --- a/keep-ui/utils/hooks/useScopes.ts +++ b/keep-ui/utils/hooks/useScopes.ts @@ -2,11 +2,11 @@ import { Scope } from "app/settings/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useScopes = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( diff --git a/keep-ui/utils/hooks/useSearchAlerts.ts b/keep-ui/utils/hooks/useSearchAlerts.ts index 1eb580ddf..01de3fe14 100644 --- a/keep-ui/utils/hooks/useSearchAlerts.ts +++ b/keep-ui/utils/hooks/useSearchAlerts.ts @@ -1,7 +1,7 @@ import useSWR, { SWRConfiguration } from "swr"; import { AlertDto } from "app/alerts/models"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { useDebouncedValue } from "./useDebouncedValue"; import { RuleGroupType, formatQuery } from "react-querybuilder"; @@ -10,7 +10,7 @@ export const useSearchAlerts = ( args: { query: RuleGroupType; timeframe: number }, options?: SWRConfiguration ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const [debouncedArgs] = useDebouncedValue(args, 2000); @@ -30,7 +30,7 @@ export const useSearchAlerts = ( body: JSON.stringify({ query: { cel_query: formatQuery(debouncedRules, "cel"), - sql_query: formatQuery(debouncedRules, "parameterized_named") + sql_query: formatQuery(debouncedRules, "parameterized_named"), }, timeframe: debouncedTimeframe, }), diff --git a/keep-ui/utils/hooks/useTags.ts b/keep-ui/utils/hooks/useTags.ts index 998b7b4fc..124da6649 100644 --- a/keep-ui/utils/hooks/useTags.ts +++ b/keep-ui/utils/hooks/useTags.ts @@ -1,13 +1,12 @@ import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { Tag } from "app/alerts/models"; - export const useTags = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( diff --git a/keep-ui/utils/hooks/useUsers.ts b/keep-ui/utils/hooks/useUsers.ts index 5a15256c9..de254e040 100644 --- a/keep-ui/utils/hooks/useUsers.ts +++ b/keep-ui/utils/hooks/useUsers.ts @@ -2,11 +2,11 @@ import { User } from "app/settings/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useUsers = (options: SWRConfiguration = {}) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWRImmutable( diff --git a/keep-ui/utils/hooks/useWorkflowExecutions.ts b/keep-ui/utils/hooks/useWorkflowExecutions.ts index 2414ce3c6..2763ff3aa 100644 --- a/keep-ui/utils/hooks/useWorkflowExecutions.ts +++ b/keep-ui/utils/hooks/useWorkflowExecutions.ts @@ -6,7 +6,7 @@ import { import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import useSWR, { SWRConfiguration } from "swr"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; export const useWorkflowExecutions = ( @@ -14,7 +14,7 @@ export const useWorkflowExecutions = ( revalidateOnFocus: false, } ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( @@ -30,7 +30,7 @@ export const useWorkflowExecutionsV2 = ( limit: number = 25, offset: number = 0 ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); const searchParams = useSearchParams(); limit = searchParams?.get("limit") @@ -61,7 +61,7 @@ export const useWorkflowExecution = ( workflowId: string, workflowExecutionId: string ) => { - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); const { data: session } = useSession(); return useSWR( diff --git a/keep-ui/utils/hooks/useWorkflowRun.ts b/keep-ui/utils/hooks/useWorkflowRun.ts index 74a9f9d45..dbaa98dc8 100644 --- a/keep-ui/utils/hooks/useWorkflowRun.ts +++ b/keep-ui/utils/hooks/useWorkflowRun.ts @@ -1,6 +1,6 @@ import { useState } from "react"; import { useSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; +import { useApiUrl } from "./useConfig"; import { useRouter } from "next/navigation"; import { useProviders } from "./useProviders"; import { Filter, Workflow } from "app/workflows/models"; @@ -19,13 +19,12 @@ export const useWorkflowRun = (workflow: Workflow) => { let message = ""; const [alertFilters, setAlertFilters] = useState([]); const [alertDependencies, setAlertDependencies] = useState([]); + const apiUrl = useApiUrl(); const { data: providersData = { providers: {} } as ProvidersData } = useProviders(); const providers = providersData.providers; - const apiUrl = getApiURL(); - if (!workflow) { return {}; } diff --git a/keep-ui/utils/hooks/useWorkflows.ts b/keep-ui/utils/hooks/useWorkflows.ts index 3739627bd..26859eaf2 100644 --- a/keep-ui/utils/hooks/useWorkflows.ts +++ b/keep-ui/utils/hooks/useWorkflows.ts @@ -1,13 +1,13 @@ import { Workflow } from "app/workflows/models"; import { useSession } from "next-auth/react"; import { SWRConfiguration } from "swr"; -import { getApiURL } from "../apiUrl"; +import { useApiUrl } from "./useConfig"; import { fetcher } from "../fetcher"; import useSWRImmutable from "swr/immutable"; export const useWorkflows = (options: SWRConfiguration = {}) => { const { data: session } = useSession(); - const apiUrl = getApiURL(); + const apiUrl = useApiUrl(); return useSWRImmutable( () => (session ? `${apiUrl}/workflows` : null), diff --git a/keep/api/models/db/migrations/versions/2024-10-22-10-38_8438f041ee0e.py b/keep/api/models/db/migrations/versions/2024-10-22-10-38_8438f041ee0e.py new file mode 100644 index 000000000..eae0abfc5 --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-10-22-10-38_8438f041ee0e.py @@ -0,0 +1,32 @@ +"""add pulling_enabled + +Revision ID: 8438f041ee0e +Revises: 83c1020be97d +Create Date: 2024-10-22 10:38:29.857284 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8438f041ee0e" +down_revision = "83c1020be97d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("provider", schema=None) as batch_op: + batch_op.add_column( + sa.Column("pulling_enabled", sa.Boolean(), nullable=False, default=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("provider", schema=None) as batch_op: + batch_op.drop_column("pulling_enabled") + # ### end Alembic commands ### diff --git a/keep/api/models/db/provider.py b/keep/api/models/db/provider.py index 37d8d05fa..33e200784 100644 --- a/keep/api/models/db/provider.py +++ b/keep/api/models/db/provider.py @@ -20,6 +20,7 @@ class Provider(SQLModel, table=True): sa_column=Column(JSON) ) # scope name is key and value is either True if validated or string with error message, e.g: {"read": True, "write": "error message"} consumer: bool = False + pulling_enabled: bool = True last_pull_time: Optional[datetime] provisioned: bool = Field(default=False) diff --git a/keep/api/models/provider.py b/keep/api/models/provider.py index 93810817c..5c15bfc22 100644 --- a/keep/api/models/provider.py +++ b/keep/api/models/provider.py @@ -39,6 +39,7 @@ class Provider(BaseModel): methods: list[ProviderMethod] = [] installed_by: str | None = None installation_time: datetime | None = None + pulling_enabled: bool = True last_pull_time: datetime | None = None docs: str | None = None tags: list[ diff --git a/keep/api/routes/preset.py b/keep/api/routes/preset.py index a812d6be1..cfa701681 100644 --- a/keep/api/routes/preset.py +++ b/keep/api/routes/preset.py @@ -1,16 +1,18 @@ +import json import logging import os import uuid from datetime import datetime from typing import Optional + from fastapi import ( APIRouter, BackgroundTasks, Depends, HTTPException, + Query, Request, Response, - Query ) from pydantic import BaseModel from sqlmodel import Session, select @@ -24,7 +26,6 @@ update_provider_last_pull_time, ) from keep.api.models.alert import AlertDto -from keep.api.models.time_stamp import TimeStampFilter from keep.api.models.db.preset import ( Preset, PresetDto, @@ -33,6 +34,7 @@ Tag, TagDto, ) +from keep.api.models.time_stamp import TimeStampFilter from keep.api.tasks.process_event_task import process_event from keep.api.tasks.process_topology_task import process_topology from keep.contextmanager.contextmanager import ContextManager @@ -41,7 +43,6 @@ from keep.providers.base.base_provider import BaseTopologyProvider from keep.providers.providers_factory import ProvidersFactory from keep.searchengine.searchengine import SearchEngine -import json router = APIRouter() logger = logging.getLogger(__name__) @@ -86,6 +87,10 @@ def pull_data_from_providers( "trace_id": trace_id, } + if not provider.pulling_enabled: + logger.debug("Pulling is disabled for this provider", extra=extra) + continue + if provider.last_pull_time is not None: now = datetime.now() days_passed = (now - provider.last_pull_time).days @@ -173,9 +178,7 @@ def pull_data_from_providers( # Function to handle the time_stamp query parameter and parse it -def _get_time_stamp_filter( - time_stamp: Optional[str] = Query(None) -) -> TimeStampFilter: +def _get_time_stamp_filter(time_stamp: Optional[str] = Query(None)) -> TimeStampFilter: if time_stamp: try: # Parse the JSON string @@ -186,6 +189,7 @@ def _get_time_stamp_filter( raise HTTPException(status_code=400, detail="Invalid time_stamp format") return TimeStampFilter() + @router.get( "", description="Get all presets for tenant", @@ -195,7 +199,7 @@ def get_presets( IdentityManagerFactory.get_auth_verifier(["read:preset"]) ), session: Session = Depends(get_session), - time_stamp: TimeStampFilter = Depends(_get_time_stamp_filter) + time_stamp: TimeStampFilter = Depends(_get_time_stamp_filter), ) -> list[PresetDto]: tenant_id = authenticated_entity.tenant_id logger.info(f"Getting all presets {time_stamp}") @@ -224,7 +228,9 @@ def get_presets( # get the number of alerts + noisy alerts for each preset search_engine = SearchEngine(tenant_id=tenant_id) # get the preset metatada - presets_dto = search_engine.search_preset_alerts(presets=presets_dto, time_stamp=time_stamp) + presets_dto = search_engine.search_preset_alerts( + presets=presets_dto, time_stamp=time_stamp + ) return presets_dto diff --git a/keep/api/routes/providers.py b/keep/api/routes/providers.py index e30f7c349..e746ce839 100644 --- a/keep/api/routes/providers.py +++ b/keep/api/routes/providers.py @@ -490,6 +490,7 @@ async def install_provider( provider_id = provider_info.pop("provider_id") provider_name = provider_info.pop("provider_name") provider_type = provider_info.pop("provider_type", None) or provider_id + pulling_enabled = provider_info.pop("pulling_enabled", True) except KeyError as e: raise HTTPException( status_code=400, detail=f"Missing required field: {e.args[0]}" @@ -507,6 +508,7 @@ async def install_provider( provider_name, provider_type, provider_info, + pulling_enabled=pulling_enabled, ) return JSONResponse(status_code=200, content=result) except HTTPException as e: diff --git a/keep/providers/netdata_provider/netdata_provider.py b/keep/providers/netdata_provider/netdata_provider.py index 1def2c787..114ef40e8 100644 --- a/keep/providers/netdata_provider/netdata_provider.py +++ b/keep/providers/netdata_provider/netdata_provider.py @@ -72,10 +72,10 @@ def _format_alert( ), status=( NetdataProvider.STATUS_MAP.get( - event["status"]["text"], AlertStatus.INFO + event["status"]["text"], AlertStatus.FIRING ) if "status" in event - else AlertStatus.INFO + else AlertStatus.FIRING ), alert=event["alert"] if "alert" in event else None, url=( diff --git a/keep/providers/providers_factory.py b/keep/providers/providers_factory.py index 079467200..fb5d9bdd6 100644 --- a/keep/providers/providers_factory.py +++ b/keep/providers/providers_factory.py @@ -411,6 +411,7 @@ def get_installed_providers( provider_copy.installation_time = p.installation_time provider_copy.last_pull_time = p.last_pull_time provider_copy.provisioned = p.provisioned + provider_copy.pulling_enabled = p.pulling_enabled try: provider_auth = {"name": p.name} if include_details: diff --git a/keep/providers/providers_service.py b/keep/providers/providers_service.py index dad1d8d37..453a2ebcf 100644 --- a/keep/providers/providers_service.py +++ b/keep/providers/providers_service.py @@ -47,6 +47,7 @@ def install_provider( provider_config: Dict[str, Any], provisioned: bool = False, validate_scopes: bool = True, + pulling_enabled: bool = True, ) -> Dict[str, Any]: provider_unique_id = uuid.uuid4().hex logger.info( @@ -95,6 +96,7 @@ def install_provider( validatedScopes=validated_scopes, consumer=provider.is_consumer, provisioned=provisioned, + pulling_enabled=pulling_enabled, ) try: session.add(provider_model) @@ -148,6 +150,8 @@ def update_provider( if provider.provisioned: raise HTTPException(403, detail="Cannot update a provisioned provider") + pulling_enabled = provider_info.pop("pulling_enabled", True) + provider_config = { "authentication": provider_info, "name": provider.name, @@ -171,6 +175,7 @@ def update_provider( provider.installed_by = updated_by provider.validatedScopes = validated_scopes + provider.pulling_enabled = pulling_enabled session.commit() return { diff --git a/pyproject.toml b/pyproject.toml index 1dfcc6d45..5546b50e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.26.0" +version = "0.27.1" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] readme = "README.md"