From 465e99152e7586fcbe4c2732213d31c5c28def4b Mon Sep 17 00:00:00 2001 From: Shahar Glazner Date: Mon, 28 Sep 2026 10:11:17 +0300 Subject: [PATCH] fix: preserve UI error details and publish debug images (#6852) --- .github/workflows/release-debug-ui.yml | 72 ++++++++++++++ docker/Dockerfile.ui | 2 + docs/deployment/docker.mdx | 29 +++++- .../graylog-snippet-autogenerated.mdx | 2 +- keep-ui/__tests__/next-config.test.ts | 73 ++++++++++++++ keep-ui/app/__tests__/global-error.test.tsx | 23 +++++ keep-ui/app/global-error.tsx | 20 +++- .../__tests__/alert-preset-manager.test.tsx | 12 +-- .../ui/alert-preset-manager.tsx | 2 + keep-ui/next.config.js | 13 ++- tests/e2e_tests/test_end_to_end_theme.py | 99 +++++-------------- 11 files changed, 253 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/release-debug-ui.yml create mode 100644 keep-ui/__tests__/next-config.test.ts create mode 100644 keep-ui/app/__tests__/global-error.test.tsx diff --git a/.github/workflows/release-debug-ui.yml b/.github/workflows/release-debug-ui.yml new file mode 100644 index 0000000000..1de9178178 --- /dev/null +++ b/.github/workflows/release-debug-ui.yml @@ -0,0 +1,72 @@ +name: Release Debug UI Image + +on: + push: + branches: [main] + paths: [pyproject.toml] + workflow_dispatch: + inputs: + ref: + description: Release tag or commit to build + required: true + type: string + +permissions: + contents: read + +jobs: + debug-ui: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Read release version + id: release + run: | + python - <<'PY' + import os + import subprocess + import tomllib + + with open("pyproject.toml", "rb") as file: + version = tomllib.load(file)["tool"]["poetry"]["version"] + commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + print(f"version={version}", file=output) + print(f"commit={commit}", file=output) + print(f"image=ghcr.io/{os.environ['GITHUB_REPOSITORY_OWNER'].lower()}/keep-frontend", file=output) + PY + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish UI with browser source maps + uses: docker/build-push-action@v6 + with: + context: keep-ui + file: docker/Dockerfile.ui + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.release.outputs.image }}:${{ steps.release.outputs.version }}-dev + build-args: | + KEEP_INCLUDE_SOURCES=true + KEEP_VERSION=${{ steps.release.outputs.version }}-dev + GIT_COMMIT_HASH=${{ steps.release.outputs.commit }} + cache-from: type=gha,scope=debug-ui + cache-to: type=gha,mode=max,scope=debug-ui diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui index 6c343a97eb..8de0f169e8 100644 --- a/docker/Dockerfile.ui +++ b/docker/Dockerfile.ui @@ -13,6 +13,8 @@ RUN npm ci --noproxy registry.npmjs.org --maxsockets 1 # Rebuild the source code only when needed FROM base AS builder +ARG KEEP_INCLUDE_SOURCES=false +ENV KEEP_INCLUDE_SOURCES=${KEEP_INCLUDE_SOURCES} WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index c1ea1e4634..6b7b2d0739 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -31,6 +31,34 @@ The docker-compose.yml contains 3 services: - [keep-frontend](https://console.cloud.google.com/artifacts/docker/keephq/us-central1/keep/keep-ui?project=keephq) - a nextjs app that serves as Keep UI interface. - [keep-websocket-server](https://docs.soketi.app/getting-started/installation/docker) - Soketi (a pusher compatible websocket server) for real time alerting. +### Debugging Keep UI + +The debug UI image uses the same production server as the regular image, with +browser source maps and console output retained. The `Release Debug UI Image` +workflow publishes `ghcr.io/keephq/keep-frontend:-dev` when the release +version changes on `main`. It can also be run manually with a release tag or +commit containing debug-image support. + +To debug an existing deployment, replace only the frontend image with the +matching `-dev` image. Keep its environment variables, ports, and +oauth2-proxy configuration, then reproduce the error with browser developer +tools open and JavaScript source maps enabled. This also works in Kubernetes +without running a local development server. + +You can build the image yourself from the corresponding release checkout: + +```sh +docker build -f docker/Dockerfile.ui \ + --build-arg KEEP_INCLUDE_SOURCES=true \ + -t keep-ui:debug keep-ui +``` + +`KEEP_INCLUDE_SOURCES` is a **build argument**. Setting it only in a running +container cannot generate source maps or change the routes compiled by Next.js. +Source maps are included even when Sentry is disabled at build time. Debug images +serve frontend source code to browsers; switch back to the regular image after +debugging. Regular builds continue to block requests for source maps. + ### Reinstall Keep with the option to refresh from scratch `Caution:` This usage context will refresh from the beginning and Keep's data and settings will be erased. Even other containers on this host are also erased. So please consider when using the steps below. @@ -64,4 +92,3 @@ rm -rf state/* ``` docker compose up -d ``` - diff --git a/docs/snippets/providers/graylog-snippet-autogenerated.mdx b/docs/snippets/providers/graylog-snippet-autogenerated.mdx index e0bd841dfa..64483a9fbe 100644 --- a/docs/snippets/providers/graylog-snippet-autogenerated.mdx +++ b/docs/snippets/providers/graylog-snippet-autogenerated.mdx @@ -66,7 +66,7 @@ To send alerts from Graylog to Keep, Use the following webhook url to configure - **Headers**: X-API-KEY:{api_key} 4. Erase the Body Template. 5. Click on "Create Notification". -6. Go the the `Event Definitions` tab, and select the Event Definition that will trigger the alert you want to send to Keep and click on More > Edit. +6. Go to the `Event Definitions` tab, and select the Event Definition that will trigger the alert you want to send to Keep and click on More > Edit. 7. Go to "Notifications" tab. 8. Click on "Add Notification" and select the "keep-graylog-webhook-integration" that you created in step 3. 9. Click on "Add Notification". diff --git a/keep-ui/__tests__/next-config.test.ts b/keep-ui/__tests__/next-config.test.ts new file mode 100644 index 0000000000..60becf8376 --- /dev/null +++ b/keep-ui/__tests__/next-config.test.ts @@ -0,0 +1,73 @@ +import { withSentryConfig } from "@sentry/nextjs"; + +jest.mock("@sentry/nextjs", () => ({ + withSentryConfig: jest.fn((config) => config), +})); + +describe("production source maps", () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + it.each(["true", "false"])( + "serves debug source maps with SENTRY_DISABLED=%s", + async (sentryDisabled) => { + process.env = { + ...originalEnv, + NODE_ENV: "production", + KEEP_INCLUDE_SOURCES: "true", + SENTRY_DISABLED: sentryDisabled, + }; + let config: any; + jest.isolateModules(() => { + config = require("../next.config"); + }); + + expect(config.productionBrowserSourceMaps).toBe(true); + expect(config.compiler.removeConsole).toBe(false); + expect(await config.rewrites()).toEqual([]); + if (sentryDisabled === "false") { + expect(withSentryConfig).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sourcemaps: { deleteSourcemapsAfterUpload: false }, + }) + ); + } + } + ); + + it.each([undefined, "false"])( + "blocks production source maps with KEEP_INCLUDE_SOURCES=%s", + async (includeSources) => { + process.env = { + ...originalEnv, + NODE_ENV: "production", + SENTRY_DISABLED: "false", + }; + if (includeSources === undefined) { + delete process.env.KEEP_INCLUDE_SOURCES; + } else { + process.env.KEEP_INCLUDE_SOURCES = includeSources; + } + let config: any; + jest.isolateModules(() => { + config = require("../next.config"); + }); + + expect(config.compiler.removeConsole).toBe(true); + expect(await config.rewrites()).toEqual({ + beforeFiles: [{ source: "/:path*.map", destination: "/404" }], + }); + expect(withSentryConfig).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }) + ); + } + ); +}); diff --git a/keep-ui/app/__tests__/global-error.test.tsx b/keep-ui/app/__tests__/global-error.test.tsx new file mode 100644 index 0000000000..5dab579e59 --- /dev/null +++ b/keep-ui/app/__tests__/global-error.test.tsx @@ -0,0 +1,23 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import * as Sentry from "@sentry/nextjs"; +import GlobalError from "../global-error"; + +jest.unmock("next-auth/react"); +jest.unmock("@/shared/lib/hooks/useApi"); +jest.unmock("@/utils/hooks/useConfig"); +jest.mock("@sentry/nextjs", () => ({ captureException: jest.fn() })); + +it("renders the original error and retries without layout providers", () => { + const error = Object.assign(new Error("Unable to load the session"), { + digest: "error-reference", + }); + const reset = jest.fn(); + + render(, { container: document }); + + expect(screen.getByText(error.message)).toBeInTheDocument(); + expect(screen.getByText("Error reference: error-reference")).toBeInTheDocument(); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reset).toHaveBeenCalledTimes(1); +}); diff --git a/keep-ui/app/global-error.tsx b/keep-ui/app/global-error.tsx index 1aee749cc9..4576d729ec 100644 --- a/keep-ui/app/global-error.tsx +++ b/keep-ui/app/global-error.tsx @@ -1,16 +1,30 @@ "use client"; -import { ErrorComponent } from "@/shared/ui"; +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; export default function GlobalError({ error, + reset, }: { error: Error & { digest?: string }; + reset: () => void; }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + // This boundary replaces the root layout, including all of its providers. + // Keep it independent of session, configuration, and API hooks. return ( - + - +
+

Something went wrong

+

{error.message || "An unexpected error occurred."}

+ {error.digest &&

Error reference: {error.digest}

} + +
); diff --git a/keep-ui/features/presets/presets-manager/ui/__tests__/alert-preset-manager.test.tsx b/keep-ui/features/presets/presets-manager/ui/__tests__/alert-preset-manager.test.tsx index 4f1899e151..7b957439e8 100644 --- a/keep-ui/features/presets/presets-manager/ui/__tests__/alert-preset-manager.test.tsx +++ b/keep-ui/features/presets/presets-manager/ui/__tests__/alert-preset-manager.test.tsx @@ -105,13 +105,9 @@ describe("AlertPresetManager", () => { /> ); - // The test alerts button is rendered, check by its color and variant - const buttons = screen.getAllByRole("button"); - const testButton = buttons.find(button => - button.className.includes("border-orange-500") && - button.className.includes("text-orange-500") - ); - expect(testButton).toBeInTheDocument(); + const testButton = screen.getByRole("button", { name: "Test alerts" }); + fireEvent.click(testButton); + expect(screen.getByTestId("push-alert-modal")).toBeInTheDocument(); }); it("should maintain button order and spacing", () => { @@ -139,4 +135,4 @@ describe("AlertPresetManager", () => { }); -}); \ No newline at end of file +}); diff --git a/keep-ui/features/presets/presets-manager/ui/alert-preset-manager.tsx b/keep-ui/features/presets/presets-manager/ui/alert-preset-manager.tsx index 2738d64416..b685830837 100644 --- a/keep-ui/features/presets/presets-manager/ui/alert-preset-manager.tsx +++ b/keep-ui/features/presets/presets-manager/ui/alert-preset-manager.tsx @@ -162,6 +162,8 @@ export function AlertPresetManager({