Skip to content

Commit 10d4e77

Browse files
adityathebemoshloop
authored andcommitted
fix(notifications): show inhibited notification source
1 parent 24d8164 commit 10d4e77

4 files changed

Lines changed: 282 additions & 3 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Meta, StoryObj } from "@storybook/react";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3+
import { ComponentProps, useState } from "react";
4+
import NotificationDetails from "./NotificationDetails";
5+
import {
6+
inhibitedDeploymentNotification,
7+
sentDeploymentNotification,
8+
sentPodNotification
9+
} from "./__fixtures__/notificationDetails";
10+
11+
function NotificationDetailsStory({
12+
notification
13+
}: ComponentProps<typeof NotificationDetails>) {
14+
const [queryClient] = useState(() => {
15+
const client = new QueryClient({
16+
defaultOptions: {
17+
queries: {
18+
retry: false,
19+
staleTime: Infinity
20+
}
21+
}
22+
});
23+
24+
client.setQueryData(
25+
["notification_send_history_inhibitor", sentPodNotification.id],
26+
sentPodNotification
27+
);
28+
29+
return client;
30+
});
31+
32+
return (
33+
<QueryClientProvider client={queryClient}>
34+
<NotificationDetails notification={notification} />
35+
</QueryClientProvider>
36+
);
37+
}
38+
39+
const meta: Meta<typeof NotificationDetails> = {
40+
title: "Notifications/Notification Details",
41+
component: NotificationDetails
42+
};
43+
44+
export default meta;
45+
46+
type Story = StoryObj<typeof NotificationDetails>;
47+
48+
export const Inhibited: Story = {
49+
render: (args) => <NotificationDetailsStory {...args} />,
50+
args: {
51+
notification: inhibitedDeploymentNotification
52+
}
53+
};
54+
55+
export const Sent: Story = {
56+
render: (args) => <NotificationDetailsStory {...args} />,
57+
args: {
58+
notification: sentDeploymentNotification
59+
}
60+
};

src/components/Notifications/NotificationSendHistory/NotificationDetails.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import NotificationResourceDisplay from "../NotificationResourceDisplay";
1414
import { notificationSendHistoryStatus } from "../NotificationsStatusCell";
1515
import blockKitToMarkdown, { SlackMessage } from "@flanksource-ui/utils/slack";
1616
import { DisplayMarkdown } from "@flanksource-ui/components/Utils/Markdown";
17-
import { getNotificationSilencesByID } from "@flanksource-ui/api/services/notifications";
17+
import {
18+
getNotificationSendHistoryById,
19+
getNotificationSilencesByID
20+
} from "@flanksource-ui/api/services/notifications";
1821
import { useQuery } from "@tanstack/react-query";
1922
import { Status } from "@flanksource-ui/components/Status";
2023
import { sanitizeHTMLContent } from "@flanksource-ui/utils/common";
@@ -64,8 +67,7 @@ export default function NotificationDetails({
6467

6568
try {
6669
const parsed = JSON.parse(legacyBody.trim()) as
67-
| SlackMessage
68-
| SlackMessage[];
70+
SlackMessage | SlackMessage[];
6971

7072
if (Array.isArray(parsed)) {
7173
const [firstMessage] = parsed;
@@ -92,6 +94,29 @@ export default function NotificationDetails({
9294
queryFn: () => getNotificationSilencesByID(notification.silenced_by!)
9395
});
9496

97+
const shouldFetchInhibitor =
98+
notification.status === "inhibited" && !!notification.parent_id;
99+
100+
const { data: inhibitor, isLoading: isLoadingInhibitor } = useQuery({
101+
queryKey: ["notification_send_history_inhibitor", notification.parent_id],
102+
enabled: shouldFetchInhibitor,
103+
queryFn: () => getNotificationSendHistoryById(notification.parent_id!)
104+
});
105+
106+
const inhibitorLink = useMemo(() => {
107+
if (!inhibitor) {
108+
return undefined;
109+
}
110+
111+
const params = new URLSearchParams({ id: inhibitor.id });
112+
if (inhibitor.body_payload) {
113+
params.set("hasBodyPayload", "1");
114+
}
115+
return `/notifications/resource/${encodeURIComponent(
116+
inhibitor.resource_id
117+
)}?${params.toString()}`;
118+
}, [inhibitor]);
119+
95120
return (
96121
<div className="flex flex-col gap-3 overflow-auto">
97122
<div className="grid grid-cols-4 gap-3">
@@ -181,6 +206,34 @@ export default function NotificationDetails({
181206
}
182207
/>
183208
)}
209+
210+
{shouldFetchInhibitor && (
211+
<VerticalDescription
212+
label="Inhibited By"
213+
value={
214+
inhibitor ? (
215+
<div className="flex flex-col gap-1">
216+
<NotificationResourceDisplay
217+
resource={inhibitor.resource}
218+
resourceKind={inhibitor.resource_kind}
219+
/>
220+
{inhibitorLink && (
221+
<Link
222+
className="text-xs font-medium text-blue-500 hover:cursor-pointer hover:underline"
223+
to={inhibitorLink}
224+
>
225+
View notification
226+
</Link>
227+
)}
228+
</div>
229+
) : isLoadingInhibitor ? (
230+
"Loading..."
231+
) : (
232+
"Unknown"
233+
)
234+
}
235+
/>
236+
)}
184237
</div>
185238

186239
{(bodyMarkdown || slackBody || legacyBody) && (
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { NotificationSendHistoryDetailApiResponse } from "@flanksource-ui/api/types/notifications";
2+
3+
const now = "2026-07-09T08:00:00.000Z";
4+
5+
export const sentPodNotification: NotificationSendHistoryDetailApiResponse = {
6+
id: "sent-pod-history-id",
7+
notification_id: "notification-rule-id",
8+
body: "Pod airsonic-7f9d4b9d9c-l6ptw is unhealthy",
9+
body_payload: null,
10+
status: "sent",
11+
count: 1,
12+
first_observed: now,
13+
source_event: "config.unhealthy",
14+
resource_id: "pod-resource-id",
15+
playbook_run_id: undefined,
16+
person_id: undefined,
17+
connection_id: undefined,
18+
error: undefined,
19+
duration_millis: 250,
20+
created_at: now,
21+
parent_id: undefined,
22+
resource_health: "unhealthy",
23+
resource_status: "CrashLoopBackOff",
24+
resource_health_description:
25+
"Pod airsonic-7f9d4b9d9c-l6ptw is in CrashLoopBackOff",
26+
resource_kind: "config",
27+
resource_type: "Kubernetes::Pod",
28+
resource: {
29+
id: "pod-resource-id",
30+
name: "airsonic-7f9d4b9d9c-l6ptw",
31+
type: "Kubernetes::Pod",
32+
health: "unhealthy",
33+
status: "CrashLoopBackOff",
34+
config_class: "Pod",
35+
tags: {
36+
namespace: "default"
37+
}
38+
},
39+
playbook_run: undefined as any,
40+
person: undefined as any,
41+
body_markdown: "Pod airsonic-7f9d4b9d9c-l6ptw is unhealthy"
42+
};
43+
44+
export const inhibitedDeploymentNotification: NotificationSendHistoryDetailApiResponse =
45+
{
46+
id: "inhibited-deployment-history-id",
47+
notification_id: "notification-rule-id",
48+
body: "Deployment airsonic is unhealthy",
49+
body_payload: null,
50+
status: "inhibited",
51+
count: 1,
52+
first_observed: now,
53+
source_event: "config.unhealthy",
54+
silenced_by: undefined,
55+
resource_id: "deployment-resource-id",
56+
playbook_run_id: undefined,
57+
person_id: undefined,
58+
connection_id: undefined,
59+
error: undefined,
60+
duration_millis: 25,
61+
created_at: now,
62+
parent_id: sentPodNotification.id,
63+
resource_health: "unhealthy",
64+
resource_status: "Unavailable",
65+
resource_health_description: "Deployment airsonic has unavailable replicas",
66+
resource_kind: "config",
67+
resource_type: "Kubernetes::Deployment",
68+
resource: {
69+
id: "deployment-resource-id",
70+
name: "airsonic",
71+
type: "Kubernetes::Deployment",
72+
health: "unhealthy",
73+
status: "Unavailable",
74+
config_class: "Deployment",
75+
tags: {
76+
namespace: "default"
77+
}
78+
},
79+
playbook_run: undefined as any,
80+
person: undefined as any,
81+
body_markdown: "Deployment airsonic is unhealthy"
82+
};
83+
84+
export const sentDeploymentNotification: NotificationSendHistoryDetailApiResponse =
85+
{
86+
...inhibitedDeploymentNotification,
87+
id: "sent-deployment-history-id",
88+
status: "sent",
89+
parent_id: undefined
90+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen } from "@testing-library/react";
3+
import { MemoryRouter } from "react-router-dom";
4+
import NotificationDetails from "../NotificationDetails";
5+
import {
6+
inhibitedDeploymentNotification,
7+
sentDeploymentNotification,
8+
sentPodNotification
9+
} from "../__fixtures__/notificationDetails";
10+
11+
jest.mock("../../../../api/services/notifications", () => ({
12+
getNotificationSendHistoryById: jest.fn(),
13+
getNotificationSilencesByID: jest.fn()
14+
}));
15+
16+
const { getNotificationSendHistoryById: mockGetNotificationSendHistoryById } =
17+
jest.requireMock("../../../../api/services/notifications") as {
18+
getNotificationSendHistoryById: jest.Mock;
19+
};
20+
21+
function renderNotificationDetails(
22+
notification: React.ComponentProps<typeof NotificationDetails>["notification"]
23+
) {
24+
const queryClient = new QueryClient({
25+
defaultOptions: {
26+
queries: {
27+
retry: false
28+
}
29+
}
30+
});
31+
32+
return render(
33+
<MemoryRouter
34+
future={{ v7_relativeSplatPath: true, v7_startTransition: true }}
35+
>
36+
<QueryClientProvider client={queryClient}>
37+
<NotificationDetails notification={notification} />
38+
</QueryClientProvider>
39+
</MemoryRouter>
40+
);
41+
}
42+
43+
describe("NotificationDetails", () => {
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
});
47+
48+
it("shows the inhibiting notification resource and send history link", async () => {
49+
mockGetNotificationSendHistoryById.mockResolvedValue(sentPodNotification);
50+
51+
renderNotificationDetails(inhibitedDeploymentNotification);
52+
53+
expect(screen.getByText("Inhibited By")).toBeInTheDocument();
54+
expect(
55+
await screen.findByText("airsonic-7f9d4b9d9c-l6ptw")
56+
).toBeInTheDocument();
57+
expect(
58+
screen.getByRole("link", { name: "View notification" })
59+
).toHaveAttribute(
60+
"href",
61+
`/notifications/resource/${sentPodNotification.resource_id}?id=${sentPodNotification.id}`
62+
);
63+
expect(screen.getByText("config.unhealthy")).toBeInTheDocument();
64+
expect(mockGetNotificationSendHistoryById).toHaveBeenCalledTimes(1);
65+
expect(mockGetNotificationSendHistoryById).toHaveBeenCalledWith(
66+
sentPodNotification.id
67+
);
68+
});
69+
70+
it("does not fetch an inhibitor for non-inhibited history", () => {
71+
renderNotificationDetails(sentDeploymentNotification);
72+
73+
expect(screen.queryByText("Inhibited By")).not.toBeInTheDocument();
74+
expect(mockGetNotificationSendHistoryById).not.toHaveBeenCalled();
75+
});
76+
});

0 commit comments

Comments
 (0)