feat: filter notifications to warnings/errors/ready only and add clear-all button

Notification filtering:
- lifecycle_hooks.py: only instance.error and instance.health_changed
  with status=running generate notifications. All other lifecycle events
  (created, started, stopped, restarted, deleted) are filtered out.
- health_monitor.py: only error and unhealthy states generate notifications.
  Running/recovered state no longer creates info notifications.
- _derive_title now maps instance.health_changed to "Container ready".

Clear-all button:
- Added dismiss_all() to NotificationService
- Added DELETE /notifications endpoint for bulk dismiss
- Frontend: clearAllNotifications API, clearAll in notification context,
  "Clear all" button in notification drawer alongside "Mark all as read"
- Added CSS for .notification-clear-all with danger hover state
- Updated notification-center tests

Quality gates: pytest (21 passed), vitest (11 passed)
This commit is contained in:
Alex Blank
2026-05-29 16:42:31 +02:00
parent 9c4500f9cb
commit 2b5223097f
11 changed files with 253 additions and 16 deletions
+9
View File
@@ -30,6 +30,10 @@ export interface MarkAllReadResponse {
marked_count: number;
}
export interface ClearAllResponse {
cleared_count: number;
}
export const getNotifications = async (): Promise<NotificationListResponse> => {
const response =
await apiClient.get<NotificationListResponse>("/notifications");
@@ -62,3 +66,8 @@ export const markAllNotificationsRead = async (): Promise<number> => {
export const dismissNotification = async (id: string): Promise<void> => {
await apiClient.delete(`/notifications/${id}`);
};
export const clearAllNotifications = async (): Promise<number> => {
const response = await apiClient.delete<ClearAllResponse>("/notifications");
return response.data.cleared_count;
};
@@ -9,6 +9,7 @@ vi.mock("../api/notifications", () => ({
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
dismissNotification: vi.fn(),
clearAllNotifications: vi.fn(),
}));
import { getNotifications, getUnreadCount } from "../api/notifications";
@@ -146,6 +147,26 @@ describe("NotificationCenter", () => {
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled();
});
it("calls clearAll on clear-all button click", async () => {
mockedGetNotifications.mockResolvedValue({
items: [makeNotification("1")],
total: 1,
limit: 20,
offset: 0,
});
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
await vi.advanceTimersByTimeAsync(100);
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
const { clearAllNotifications: mockClearAll } = await import(
"../api/notifications"
);
expect(vi.mocked(mockClearAll)).toHaveBeenCalled();
});
it("refreshes list immediately on open", async () => {
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
@@ -15,6 +15,7 @@ export function NotificationCenter({
unreadCount,
markRead,
markAllRead,
clearAll,
dismiss,
refreshList,
isDropdownOpen,
@@ -115,6 +116,15 @@ export function NotificationCenter({
>
Mark all as read
</button>
<button
type="button"
className="notification-clear-all"
onClick={() => {
void clearAll();
}}
>
Clear all
</button>
</div>
)}
</div>
+21
View File
@@ -11,6 +11,7 @@ import {
markNotificationRead,
markAllNotificationsRead,
dismissNotification,
clearAllNotifications,
} from "../api/notifications";
import type { NotificationItem } from "../api/notifications";
@@ -21,6 +22,7 @@ export interface NotificationContextValue {
error: Error | null;
markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>;
clearAll: () => Promise<void>;
dismiss: (id: string) => Promise<void>;
refreshList: () => Promise<void>;
isDropdownOpen: boolean;
@@ -265,6 +267,24 @@ export function NotificationProvider({
await fetchList();
}, [fetchList]);
const clearAll = useCallback(async () => {
const { notifications: currentNotifications } = stateRef.current;
const unreadInList = currentNotifications.filter(
(n) => n.read_at === null,
).length;
setNotifications([]);
setUnreadCount((c) => Math.max(0, c - unreadInList));
setError(null);
try {
await clearAllNotifications();
} catch (err) {
setNotifications(currentNotifications);
setError(err as Error);
}
}, []);
const value: NotificationContextValue = {
notifications,
unreadCount,
@@ -272,6 +292,7 @@ export function NotificationProvider({
error,
markRead,
markAllRead,
clearAll,
dismiss,
refreshList,
isDropdownOpen,
+22 -1
View File
@@ -4602,10 +4602,12 @@ a:active,
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
display: flex;
gap: 0.5rem;
}
.notification-mark-all {
width: 100%;
flex: 1;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid var(--border);
@@ -4623,6 +4625,25 @@ a:active,
border-color: var(--brand);
}
.notification-clear-all {
flex: 1;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s ease;
}
.notification-clear-all:hover {
background: var(--bg);
color: var(--danger);
border-color: var(--danger);
}
/* Notification Item */
.notification-item {
display: flex;