Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/run-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ jobs:
- name: Dump logs
if: always()
run: |
if [ ! -f tests/e2e_tests/docker-compose-modified.yml ]; then
echo "docker-compose-modified.yml not found; skipping log collection"
exit 0
fi
docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs keep-backend > backend_logs-${{ inputs.db-type }}.txt
docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs keep-frontend > frontend_logs-${{ inputs.db-type }}.txt
docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml logs keep-backend-db-auth > backend_logs-${{ inputs.db-type }}-db-auth.txt
Expand Down Expand Up @@ -330,4 +334,8 @@ jobs:
- name: Tear down environment
if: always()
run: |
docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml down
if [ -f tests/e2e_tests/docker-compose-modified.yml ]; then
docker compose -p keep --project-directory . -f tests/e2e_tests/docker-compose-modified.yml down
else
echo "docker-compose-modified.yml not found; skipping tear down"
fi
28 changes: 28 additions & 0 deletions keep-ui/utils/hooks/__tests__/useAlertPolling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { parsePollAlertsPayload } from "../useAlertPolling";

describe("parsePollAlertsPayload", () => {
it("returns empty array for missing payload", () => {
expect(parsePollAlertsPayload(null)).toEqual([]);
expect(parsePollAlertsPayload(undefined)).toEqual([]);
});

it("parses fingerprints from object payload", () => {
expect(
parsePollAlertsPayload({ fingerprints: ["fp-1", "fp-2"] })
).toEqual(["fp-1", "fp-2"]);
});

it("parses fingerprints from legacy string payload", () => {
expect(parsePollAlertsPayload('{"fingerprints":["fp-1"]}')).toEqual([
"fp-1",
]);
});

it("filters invalid fingerprint values", () => {
expect(
parsePollAlertsPayload({
fingerprints: ["fp-1", "", 2, null, "fp-2"],
})
).toEqual(["fp-1", "fp-2"]);
});
});
55 changes: 47 additions & 8 deletions keep-ui/utils/hooks/useAlertPolling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,64 @@ import { useWebsocket } from "@/utils/hooks/usePusher";
import { Observable } from "rxjs";
import { v4 as generateGuid } from "uuid";

type PollAlertsPayload = {
fingerprints?: string[];
};

export function parsePollAlertsPayload(data: unknown): string[] {
if (!data) {
return [];
}

let payload: unknown = data;
if (typeof data === "string") {
try {
payload = JSON.parse(data);
} catch {
return [];
}
}

if (
typeof payload === "object" &&
payload !== null &&
"fingerprints" in payload
) {
const fingerprints = (payload as PollAlertsPayload).fingerprints;
if (!Array.isArray(fingerprints)) {
return [];
}

return fingerprints.filter(
(fingerprint): fingerprint is string =>
typeof fingerprint === "string" && fingerprint.length > 0
);
}

return [];
}

export const useAlertPolling = (isEnabled: boolean) => {
const { bind, unbind } = useWebsocket();
const [pollAlerts, setPollAlerts] = useState<string | null>(null);

console.log("useAlertPolling: Initializing");
const [data, setData] = useState<string | null>(null);
const [fingerprints, setFingerprints] = useState<string[]>([]);

useEffect(() => {
if (!isEnabled) {
console.log("useAlertPolling: Disabling polling");
return;
}

const subscription = new Observable((subscriber) => {
const callback = () => subscriber.next(true);
const subscription = new Observable<unknown>((subscriber) => {
const callback = (eventData: unknown) => subscriber.next(eventData);
bind("poll-alerts", callback);
return () => unbind("poll-alerts", callback);
}).subscribe(() => setPollAlerts(generateGuid()));
}).subscribe((eventData) => {
setData(generateGuid());
setFingerprints(parsePollAlertsPayload(eventData));
});

return () => subscription.unsubscribe();
}, [isEnabled, bind, unbind]);

return { data: pollAlerts };
return { data, fingerprints };
};
128 changes: 124 additions & 4 deletions keep-ui/widgets/alerts-table/ui/__tests__/useAlertsTableData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "../useAlertsTableData";
import { AlertsQuery, useAlerts } from "@/entities/alerts/model";
import { useAlertPolling } from "@/utils/hooks/useAlertPolling";
import { useApi } from "@/shared/lib/hooks/useApi";
import {
AbsoluteTimeFrame,
AllTimeFrame,
Expand All @@ -17,14 +18,26 @@ jest.mock("@/entities/alerts/model", () => ({
jest.mock("@/utils/hooks/useAlertPolling", () => ({
useAlertPolling: jest.fn(),
}));
jest.mock("@/shared/lib/hooks/useApi", () => ({
useApi: jest.fn(),
}));
jest.mock("uuid", () => ({ v4: () => "mock-uuid" }));

const mockUseLastAlerts = jest.fn();
const mockApiGet = jest.fn();
const mockApiPost = jest.fn();
(useAlerts as jest.Mock).mockReturnValue({ useLastAlerts: mockUseLastAlerts });
(useApi as jest.Mock).mockReturnValue({
isReady: () => true,
get: mockApiGet,
post: mockApiPost,
});

const mockMutate = jest.fn();

const defaultAlerts = [{ id: 1, name: "Alert 1" }];
const defaultAlerts = [
{ id: 1, fingerprint: "fp-1", name: "Alert 1" },
];
const defaultQuery: AlertsTableDataQuery = {
searchCel: "test",
filterCel: "filter",
Expand All @@ -36,6 +49,7 @@ const defaultQuery: AlertsTableDataQuery = {

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockUseLastAlerts.mockReturnValue({
data: defaultAlerts,
totalCount: 1,
Expand All @@ -44,7 +58,15 @@ beforeEach(() => {
error: null,
queryTimeInSeconds: 1,
});
(useAlertPolling as jest.Mock).mockReturnValue({ data: null });
(useAlertPolling as jest.Mock).mockReturnValue({ data: null, fingerprints: [] });
mockApiPost.mockResolvedValue([
{
id: 1,
fingerprint: "fp-1",
name: "Updated Alert",
lastReceived: "2025-01-01T00:00:00.000Z",
},
]);
});

describe("useAlertsTableData", () => {
Expand Down Expand Up @@ -80,11 +102,105 @@ describe("useAlertsTableData", () => {
});

it("updates alerts when polling token changes", () => {
(useAlertPolling as jest.Mock).mockReturnValue({ data: "token" });
(useAlertPolling as jest.Mock).mockReturnValue({
data: "token",
fingerprints: [],
});
const { result } = renderHook(() => useAlertsTableData(defaultQuery));
expect(result.current.alertsChangeToken).toBe("token");
});

it("patches visible alerts via batch when all polled fingerprints are visible", async () => {
(useAlertPolling as jest.Mock).mockReturnValue({
data: "token-with-fingerprints",
fingerprints: ["fp-1"],
});

const { result, unmount } = renderHook(() =>
useAlertsTableData(defaultQuery)
);

await act(async () => {
await Promise.resolve();
});

expect(mockApiPost).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith("/alerts/batch", ["fp-1"]);
expect(result.current.alerts).toEqual([
{
id: 1,
fingerprint: "fp-1",
name: "Updated Alert",
lastReceived: new Date("2025-01-01T00:00:00.000Z"),
},
]);
expect(result.current.facetsPanelRefreshToken).toBe("mock-uuid");

unmount();
});

it("falls back to full refresh when any polled fingerprint is not visible", async () => {
(useAlertPolling as jest.Mock).mockReturnValue({
data: "token-with-hidden",
fingerprints: ["fp-1", "fp-hidden"],
});

const { unmount } = renderHook(() => useAlertsTableData(defaultQuery));

await act(async () => {
await Promise.resolve();
});

expect(mockApiPost).not.toHaveBeenCalled();
unmount();
});

it("removes rows for fingerprints absent from batch response", async () => {
const twoAlerts = [
{ id: 1, fingerprint: "fp-1", name: "Alert 1" },
{ id: 2, fingerprint: "fp-2", name: "Alert 2" },
];
mockUseLastAlerts.mockReturnValue({
data: twoAlerts,
totalCount: 2,
isLoading: false,
mutate: mockMutate,
error: null,
queryTimeInSeconds: 1,
});
(useAlertPolling as jest.Mock).mockReturnValue({
data: "token-evict",
fingerprints: ["fp-1", "fp-2"],
});
mockApiPost.mockResolvedValue([
{
id: 1,
fingerprint: "fp-1",
name: "Updated Alert 1",
lastReceived: "2025-01-01T00:00:00.000Z",
},
]);

const { result, unmount } = renderHook(() =>
useAlertsTableData(defaultQuery)
);

await act(async () => {
await Promise.resolve();
});

expect(result.current.alerts).toEqual([
{
id: 1,
fingerprint: "fp-1",
name: "Updated Alert 1",
lastReceived: new Date("2025-01-01T00:00:00.000Z"),
},
]);

unmount();
});

it("generates correct facetsCel for absolute timeFrame", () => {
const { result } = renderHook(() =>
useAlertsTableData({
Expand Down Expand Up @@ -221,6 +337,7 @@ describe("useAlertsTableData", () => {
});
(useAlertPolling as jest.Mock).mockReturnValueOnce({
data: "polling-token",
fingerprints: [],
});
const { result } = renderHook(() => useAlertsTableData(defaultQuery));

Expand All @@ -241,7 +358,10 @@ describe("useAlertsTableData", () => {
error: null,
queryTimeInSeconds: 1,
});
(useAlertPolling as jest.Mock).mockReturnValue({ data: "polling-token" });
(useAlertPolling as jest.Mock).mockReturnValue({
data: "polling-token",
fingerprints: [],
});
const { result, rerender } = renderHook(
({ query }) => useAlertsTableData(query),
{
Expand Down
Loading
Loading