Skip to content

Commit ed5ff18

Browse files
committed
feat: improve error handling and notification system
- add new notification store - add new notification badge component to navbar - add new notification dropdown - make warning and error distiguishible in ui - allow users to dismiss warnings/errors - allow users to check and clear notification history Signed-off-by: Siavash Safi <[email protected]>
1 parent ccd14cb commit ed5ff18

24 files changed

+3644
-417
lines changed

ui/package-lock.json

Lines changed: 0 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/Components/FaviconBadge/index.test.tsx

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@ import { mount } from "enzyme";
33
import Favico from "favico.js";
44

55
import { AlertStore } from "Stores/AlertStore";
6+
import { notificationStore } from "Stores/NotificationStore";
67
import FaviconBadge from ".";
78

89
let alertStore: AlertStore;
910

1011
beforeEach(() => {
1112
alertStore = new AlertStore([]);
1213
Favico.badge.mockClear();
14+
// Clear all notifications
15+
notificationStore.activeNotifications.forEach((n) =>
16+
notificationStore.dismissNotification(n.id),
17+
);
18+
notificationStore.clearDismissed();
1319
});
1420

1521
const MountedFaviconBadge = () => {
@@ -42,26 +48,31 @@ describe("<FaviconBadge />", () => {
4248
expect(Favico.badge).toHaveBeenCalledWith("?");
4349
});
4450

45-
it("badge is ! when there are alertmanager upstreams with errors", () => {
46-
alertStore.data.setUpstreams({
47-
counters: { total: 1, healthy: 1, failed: 0 },
48-
clusters: { default: ["default"] },
49-
instances: [
50-
{
51-
name: "default",
52-
uri: "http://localhost",
53-
publicURI: "http://example.com",
54-
readonly: false,
55-
headers: { foo: "bar" },
56-
corsCredentials: "include",
57-
error: 'Healthcheck filter "DeadMansSwitch" didn\'t match any alerts',
58-
version: "0.24.0",
59-
cluster: "default",
60-
clusterMembers: ["default"],
61-
},
62-
],
51+
it("badge is ! when there are error notifications", () => {
52+
// Add an error notification
53+
notificationStore.addNotification({
54+
type: "error",
55+
title: "Test Error",
56+
message: "Test error message",
57+
source: "alertmanager",
58+
sourceId: "test-error",
6359
});
60+
6461
MountedFaviconBadge();
6562
expect(Favico.badge).toHaveBeenCalledWith("!");
6663
});
64+
65+
it("badge is ⚠ when there are warning notifications", () => {
66+
// Add a warning notification
67+
notificationStore.addNotification({
68+
type: "warning",
69+
title: "Test Warning",
70+
message: "Test warning message",
71+
source: "alertmanager",
72+
sourceId: "test-warning",
73+
});
74+
75+
MountedFaviconBadge();
76+
expect(Favico.badge).toHaveBeenCalledWith("⚠");
77+
});
6778
});
Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,44 @@
1-
import { useState, useEffect, FC } from "react";
1+
import { useEffect } from "react";
22

33
import { autorun } from "mobx";
44

5-
import Favico from "favico.js";
6-
75
import type { AlertStore } from "Stores/AlertStore";
6+
import { notificationStore } from "Stores/NotificationStore";
7+
8+
import Favico from "favico.js";
89

9-
const FaviconBadge: FC<{
10-
alertStore: AlertStore;
11-
}> = ({ alertStore }) => {
12-
const [favico] = useState(
13-
new Favico({
10+
const FaviconBadge = ({ alertStore }: { alertStore: AlertStore }) => {
11+
useEffect(() => {
12+
const favico = new Favico({
1413
animation: "none",
15-
position: "down",
16-
bgColor: "#e74c3c",
17-
textColor: "#fff",
18-
}),
19-
);
20-
21-
useEffect(
22-
() =>
14+
});
15+
16+
const dispose = autorun(() =>
2317
autorun(() => {
24-
favico.badge(
25-
alertStore.data.upstreamsWithErrors.length > 0
26-
? "!"
27-
: alertStore.status.error === null
28-
? alertStore.info.totalAlerts
29-
: "?",
30-
);
18+
// Priority: critical notifications > network errors > alert count
19+
const criticalCount = notificationStore.errorCount;
20+
const warningCount = notificationStore.warningCount;
21+
22+
let badge;
23+
if (criticalCount > 0) {
24+
badge = "!";
25+
} else if (warningCount > 0) {
26+
badge = "⚠";
27+
} else if (alertStore.status.error !== null) {
28+
badge = "?";
29+
} else {
30+
badge = alertStore.info.totalAlerts;
31+
}
32+
33+
favico.badge(badge);
3134
}),
32-
[], // eslint-disable-line react-hooks/exhaustive-deps
33-
);
35+
);
36+
37+
return dispose;
38+
}, [alertStore]);
3439

3540
return null;
3641
};
3742

43+
export { FaviconBadge };
3844
export default FaviconBadge;

ui/src/Components/Grid/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
55
import type { AlertStore } from "Stores/AlertStore";
66
import type { Settings } from "Stores/Settings";
77
import type { SilenceFormStore } from "Stores/SilenceFormStore";
8+
import { useNotificationManager } from "Hooks/useNotificationManager";
89
import AlertGrid from "./AlertGrid";
910
import { FatalError } from "./FatalError";
1011
import { UpgradeNeeded } from "./UpgradeNeeded";
@@ -17,6 +18,9 @@ const Grid: FC<{
1718
silenceFormStore: SilenceFormStore;
1819
settingsStore: Settings;
1920
}> = ({ alertStore, settingsStore, silenceFormStore }) => {
21+
// Initialize notification management
22+
useNotificationManager(alertStore);
23+
2024
return alertStore.info.upgradeNeeded ? (
2125
<UpgradeNeeded newVersion={alertStore.info.version} reloadAfter={3000} />
2226
) : alertStore.info.reloadNeeded ? (

ui/src/Components/NavBar/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { OverviewModal } from "Components/OverviewModal";
1717
import { MainModal } from "Components/MainModal";
1818
import SilenceModal from "Components/SilenceModal";
1919
import AppToasts from "Components/Toast/AppToasts";
20+
import { NotificationBadge } from "Components/NotificationBadge";
2021
import { ThemeContext } from "Components/Theme";
2122
import { Fetcher } from "Components/Fetcher";
2223
import { FilterInput } from "./FilterInput";
@@ -135,6 +136,7 @@ const NavBar: FC<{
135136
{alertStore.info.timestamp !== "" &&
136137
alertStore.data.upstreams.instances.length === 0 ? null : (
137138
<ul className="navbar-nav flex-wrap flex-shrink-1 ms-1">
139+
<NotificationBadge />
138140
<AppToasts alertStore={alertStore} />
139141
<SilenceModal
140142
alertStore={alertStore}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { mount } from "enzyme";
2+
3+
import { NotificationContent } from "./NotificationContent";
4+
5+
describe("<NotificationContent />", () => {
6+
const mockTimestamp = new Date("2023-01-01T12:00:00Z");
7+
8+
it("renders with basic props", () => {
9+
const wrapper = mount(
10+
<NotificationContent
11+
title="Test Title"
12+
message="Test message"
13+
timestamp={mockTimestamp}
14+
/>,
15+
);
16+
17+
expect(wrapper.find("h6").html()).toContain("Test Title");
18+
expect(wrapper.find("small").html()).toContain("Test message");
19+
expect(wrapper.find("DateFromNow")).toHaveLength(1);
20+
});
21+
22+
it("renders HTML content in title and message", () => {
23+
const wrapper = mount(
24+
<NotificationContent
25+
title='Cluster <code class="bg-secondary text-white px-1 rounded">test</code> is unreachable'
26+
message='Instance <code class="bg-secondary text-white px-1 rounded">am1</code> failing'
27+
timestamp={mockTimestamp}
28+
/>,
29+
);
30+
31+
const titleHtml = wrapper.find("h6").html();
32+
const messageHtml = wrapper.find("small").html();
33+
34+
expect(titleHtml).toContain(
35+
'<code class="bg-secondary text-white px-1 rounded">test</code>',
36+
);
37+
expect(messageHtml).toContain(
38+
'<code class="bg-secondary text-white px-1 rounded">am1</code>',
39+
);
40+
});
41+
42+
it("uses default occurrenceCount of 1 when not provided", () => {
43+
const wrapper = mount(
44+
<NotificationContent
45+
title="Test Title"
46+
message="Test message"
47+
timestamp={mockTimestamp}
48+
/>,
49+
);
50+
51+
// Should not show occurrence count badge for default count of 1
52+
expect(wrapper.find(".badge")).toHaveLength(0);
53+
});
54+
55+
it("does not show occurrence count badge when count is 1", () => {
56+
const wrapper = mount(
57+
<NotificationContent
58+
title="Test Title"
59+
message="Test message"
60+
timestamp={mockTimestamp}
61+
occurrenceCount={1}
62+
/>,
63+
);
64+
65+
expect(wrapper.find(".badge")).toHaveLength(0);
66+
});
67+
68+
it("shows occurrence count badge when count is greater than 1", () => {
69+
const wrapper = mount(
70+
<NotificationContent
71+
title="Test Title"
72+
message="Test message"
73+
timestamp={mockTimestamp}
74+
occurrenceCount={3}
75+
/>,
76+
);
77+
78+
const badge = wrapper.find(".badge");
79+
expect(badge).toHaveLength(1);
80+
expect(badge.text()).toBe("3x");
81+
expect(badge.hasClass("bg-secondary")).toBe(true);
82+
expect(badge.hasClass("text-white")).toBe(true);
83+
});
84+
85+
it("formats timestamp correctly using DateFromNow", () => {
86+
const wrapper = mount(
87+
<NotificationContent
88+
title="Test Title"
89+
message="Test message"
90+
timestamp={mockTimestamp}
91+
/>,
92+
);
93+
94+
const dateFromNow = wrapper.find("DateFromNow");
95+
expect(dateFromNow.prop("timestamp")).toBe(mockTimestamp.toISOString());
96+
});
97+
98+
it("applies correct CSS classes", () => {
99+
const wrapper = mount(
100+
<NotificationContent
101+
title="Test Title"
102+
message="Test message"
103+
timestamp={mockTimestamp}
104+
occurrenceCount={2}
105+
/>,
106+
);
107+
108+
// Check main container has flex-grow-1 class
109+
expect(wrapper.find("div").first().hasClass("flex-grow-1")).toBe(true);
110+
111+
// Check header structure
112+
expect(wrapper.find(".d-flex.align-items-center")).toHaveLength(1);
113+
expect(wrapper.find("h6.alert-heading.mb-1.flex-grow-1")).toHaveLength(1);
114+
115+
// Check message styling
116+
expect(wrapper.find("small.mb-1.d-block")).toHaveLength(1);
117+
118+
// Check timestamp styling
119+
expect(wrapper.find(".text-white-50")).toHaveLength(1);
120+
121+
// Check occurrence count badge
122+
expect(wrapper.find(".badge.bg-secondary.ms-2.text-white")).toHaveLength(1);
123+
});
124+
125+
it("handles different occurrence count values", () => {
126+
// Test with count of 5
127+
const wrapper = mount(
128+
<NotificationContent
129+
title="Test Title"
130+
message="Test message"
131+
timestamp={mockTimestamp}
132+
occurrenceCount={5}
133+
/>,
134+
);
135+
136+
expect(wrapper.find(".badge").text()).toBe("5x");
137+
});
138+
139+
it("matches snapshot with default occurrenceCount", () => {
140+
const wrapper = mount(
141+
<NotificationContent
142+
title="Test notification title"
143+
message="Test notification message"
144+
timestamp={mockTimestamp}
145+
/>,
146+
);
147+
148+
expect(wrapper).toMatchSnapshot();
149+
});
150+
151+
it("matches snapshot with custom occurrenceCount", () => {
152+
const wrapper = mount(
153+
<NotificationContent
154+
title="Test notification title"
155+
message="Test notification message"
156+
timestamp={mockTimestamp}
157+
occurrenceCount={3}
158+
/>,
159+
);
160+
161+
expect(wrapper).toMatchSnapshot();
162+
});
163+
});

0 commit comments

Comments
 (0)