1+ <script >
2+ import { onMount , onDestroy } from ' svelte' ;
3+ import { api } from ' ../lib/api' ;
4+ import { fade , fly } from ' svelte/transition' ;
5+ import { isAuthenticated , username , userId } from ' ../stores/auth' ;
6+ import { get } from ' svelte/store' ;
7+ import { navigate } from ' svelte-routing' ;
8+
9+ let notifications = [];
10+ let unreadCount = 0 ;
11+ let showDropdown = false ;
12+ let loading = false ;
13+ let eventSource = null ;
14+ let reconnectAttempts = 0 ;
15+ const maxReconnectAttempts = 3 ;
16+ let reconnectTimeout = null ;
17+ let hasLoadedInitialData = false ;
18+
19+ const bellIcon = ` <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>` ;
20+
21+ const notificationIcons = {
22+ execution_completed: ` <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>` ,
23+ execution_failed: ` <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>` ,
24+ security_alert: ` <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>` ,
25+ system_update: ` <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`
26+ };
27+
28+ const priorityColors = {
29+ low: ' text-gray-600 dark:text-gray-400' ,
30+ medium: ' text-blue-600 dark:text-blue-400' ,
31+ high: ' text-orange-600 dark:text-orange-400' ,
32+ urgent: ' text-red-600 dark:text-red-400'
33+ };
34+
35+ onMount (async () => {
36+ // Subscribe to authentication changes
37+ const unsubscribe = isAuthenticated .subscribe (async ($isAuth ) => {
38+ if ($isAuth && ! hasLoadedInitialData) {
39+ hasLoadedInitialData = true ;
40+ // Load data in parallel, not sequentially
41+ const loadPromises = [
42+ loadNotifications (),
43+ loadUnreadCount ()
44+ ];
45+ await Promise .all (loadPromises);
46+ connectToNotificationStream ();
47+ } else if (! $isAuth) {
48+ // Close stream if not authenticated
49+ if (eventSource) {
50+ eventSource .close ();
51+ eventSource = null ;
52+ }
53+ hasLoadedInitialData = false ;
54+ }
55+ });
56+
57+ return unsubscribe;
58+ });
59+
60+ onDestroy (() => {
61+ if (eventSource) {
62+ eventSource .close ();
63+ eventSource = null ;
64+ }
65+ clearTimeout (reconnectTimeout);
66+ });
67+
68+ async function loadNotifications () {
69+ loading = true ;
70+ try {
71+ const response = await api .get (' /api/v1/notifications?limit=20' );
72+ notifications = response .notifications ;
73+ } catch (error) {
74+ console .error (' Failed to load notifications:' , error);
75+ } finally {
76+ loading = false ;
77+ }
78+ }
79+
80+ async function loadUnreadCount () {
81+ try {
82+ const response = await api .get (' /api/v1/notifications/unread-count' );
83+ unreadCount = response .unread_count ;
84+ } catch (error) {
85+ console .error (' Failed to load unread count:' , error);
86+ }
87+ }
88+
89+ function connectToNotificationStream () {
90+ const isAuth = get (isAuthenticated);
91+ if (! isAuth) return ;
92+
93+ // Check if we've exceeded max attempts
94+ if (reconnectAttempts >= maxReconnectAttempts) {
95+ console .error (' Max reconnection attempts reached for notification stream' );
96+ return ;
97+ }
98+
99+ // Close existing connection if any
100+ if (eventSource) {
101+ eventSource .close ();
102+ }
103+
104+ const url = ` /api/v1/events/notifications/stream` ;
105+ eventSource = new EventSource (url, {
106+ withCredentials: true
107+ });
108+
109+ eventSource .onopen = (event ) => {
110+ console .log (' Notification stream connected' , event .type );
111+ reconnectAttempts = 0 ; // Reset on successful connection
112+ };
113+
114+ eventSource .onmessage = (event ) => {
115+ try {
116+ const notification = JSON .parse (event .data );
117+
118+ // Add to notifications list
119+ notifications = [notification, ... notifications].slice (0 , 20 );
120+
121+ // Increment unread count
122+ unreadCount++ ;
123+
124+ // Show browser notification if permission granted
125+ if (Notification .permission === ' granted' ) {
126+ new Notification (notification .subject , {
127+ body: notification .body ,
128+ icon: ' /favicon.png'
129+ });
130+ }
131+ } catch (error) {
132+ console .error (' Error processing notification:' , error);
133+ }
134+ };
135+
136+ eventSource .onerror = (error ) => {
137+ // SSE connections will fire error event when closing, ignore if we're not authenticated
138+ const isAuth = get (isAuthenticated);
139+ if (! isAuth) {
140+ if (eventSource) {
141+ eventSource .close ();
142+ eventSource = null ;
143+ }
144+ return ;
145+ }
146+
147+ // Only log actual errors, not normal closure
148+ if (eventSource && eventSource .readyState !== EventSource .CLOSED ) {
149+ console .error (' Notification stream error:' , error .type );
150+ }
151+
152+ if (eventSource) {
153+ eventSource .close ();
154+ eventSource = null ;
155+ }
156+
157+ // Only reconnect if authenticated and under limit
158+ if (isAuth && reconnectAttempts < maxReconnectAttempts) {
159+ reconnectAttempts++ ;
160+ console .log (` Reconnecting notification stream... (attempt ${ reconnectAttempts} /${ maxReconnectAttempts} )` );
161+
162+ // Exponential backoff: 5s, 10s, 20s
163+ const delay = Math .min (5000 * Math .pow (2 , reconnectAttempts - 1 ), 20000 );
164+
165+ clearTimeout (reconnectTimeout);
166+ reconnectTimeout = setTimeout (() => {
167+ const stillAuth = get (isAuthenticated);
168+ if (stillAuth && ! eventSource) {
169+ connectToNotificationStream ();
170+ }
171+ }, delay);
172+ } else if (reconnectAttempts >= maxReconnectAttempts) {
173+ console .error (' Max reconnection attempts reached for notification stream' );
174+ }
175+ };
176+ }
177+
178+ async function markAsRead (notification ) {
179+ if (notification .status === ' read' ) return ;
180+
181+ try {
182+ await api .put (` /api/v1/notifications/${ notification .notification_id } /read` );
183+
184+ // Update local state
185+ notification .status = ' read' ;
186+ notification .read_at = new Date ().toISOString ();
187+ notifications = notifications;
188+
189+ // Update unread count
190+ unreadCount = Math .max (0 , unreadCount - 1 );
191+ } catch (error) {
192+ console .error (' Failed to mark notification as read:' , error);
193+ }
194+ }
195+
196+ async function markAllAsRead () {
197+ try {
198+ await api .post (' /api/v1/notifications/mark-all-read' );
199+
200+ // Update local state
201+ notifications = notifications .map (n => ({
202+ ... n,
203+ status: ' read' ,
204+ read_at: new Date ().toISOString ()
205+ }));
206+
207+ unreadCount = 0 ;
208+ } catch (error) {
209+ console .error (' Failed to mark all as read:' , error);
210+ }
211+ }
212+
213+ function toggleDropdown () {
214+ showDropdown = ! showDropdown;
215+
216+ if (showDropdown && unreadCount > 0 ) {
217+ // Mark visible notifications as read after a delay
218+ setTimeout (() => {
219+ notifications .slice (0 , 5 ).forEach (n => {
220+ if (n .status !== ' read' ) {
221+ markAsRead (n);
222+ }
223+ });
224+ }, 2000 );
225+ }
226+ }
227+
228+ function formatTime (timestamp ) {
229+ // Backend sends Unix timestamps in seconds, JS Date expects milliseconds
230+ const date = new Date (timestamp * 1000 );
231+ const now = new Date ();
232+ const diff = now - date;
233+
234+ if (diff < 60000 ) return ' just now' ;
235+ if (diff < 3600000 ) return ` ${ Math .floor (diff / 60000 )} m ago` ;
236+ if (diff < 86400000 ) return ` ${ Math .floor (diff / 3600000 )} h ago` ;
237+ return date .toLocaleDateString ();
238+ }
239+
240+ function getNotificationIcon (type ) {
241+ return notificationIcons[type] || notificationIcons .system_update ;
242+ }
243+
244+ // Request notification permission
245+ if (' Notification' in window && Notification .permission === ' default' ) {
246+ Notification .requestPermission ();
247+ }
248+ </script >
249+
250+ <div class =" relative" >
251+ <button
252+ on:click ={toggleDropdown }
253+ class =" btn btn-ghost btn-icon relative"
254+ aria-label =" Notifications"
255+ >
256+ {@html bellIcon }
257+ {#if unreadCount > 0 }
258+ <span class =" absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center" >
259+ {unreadCount > 9 ? ' 9+' : unreadCount }
260+ </span >
261+ {/if }
262+ </button >
263+
264+ {#if showDropdown }
265+ <div
266+ class =" absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"
267+ transition:fly ={{ y : - 10 , duration : 200 }}
268+ >
269+ <div class =" p-4 border-b border-gray-200 dark:border-gray-700" >
270+ <div class =" flex justify-between items-center" >
271+ <h3 class =" font-semibold text-lg" >Notifications</h3 >
272+ {#if unreadCount > 0 }
273+ <button
274+ on:click ={markAllAsRead }
275+ class =" text-sm text-blue-600 dark:text-blue-400 hover:underline"
276+ >
277+ Mark all as read
278+ </button >
279+ {/if }
280+ </div >
281+ </div >
282+
283+ <div class =" max-h-96 overflow-y-auto" >
284+ {#if loading }
285+ <div class =" p-8 text-center" >
286+ <span class =" loading loading-spinner loading-sm" ></span >
287+ </div >
288+ {:else if notifications .length === 0 }
289+ <div class =" p-8 text-center text-gray-500" >
290+ No notifications yet
291+ </div >
292+ {:else }
293+ {#each notifications as notification }
294+ <div
295+ class =" p-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
296+ class:bg-blue- 50={notification .status !== ' read' }
297+ class:dark:bg-blue- 900={notification .status !== ' read' }
298+ on:click ={() => {
299+ markAsRead (notification );
300+ if (notification .action_url ) {
301+ window .location .href = notification .action_url ;
302+ }
303+ }}
304+ >
305+ <div class =" flex items-start space-x-3" >
306+ <div class ={` mt-1 ${priorityColors [notification .priority ]} ` }>
307+ {@html getNotificationIcon (notification .notification_type )}
308+ </div >
309+ <div class =" flex-1 min-w-0" >
310+ <p class =" font-medium text-sm" >
311+ {notification .subject }
312+ </p >
313+ <p class =" text-sm text-gray-600 dark:text-gray-400 mt-1" >
314+ {notification .body }
315+ </p >
316+ <p class =" text-xs text-gray-500 dark:text-gray-500 mt-2" >
317+ {formatTime (notification .created_at )}
318+ </p >
319+ </div >
320+ {#if notification .status !== ' read' }
321+ <div class =" w-2 h-2 bg-blue-500 rounded-full mt-2" ></div >
322+ {/if }
323+ </div >
324+ </div >
325+ {/each }
326+ {/if }
327+ </div >
328+
329+ <div class =" p-3 border-t border-gray-200 dark:border-gray-700" >
330+ <button
331+ on:click ={() => {
332+ showDropdown = false ;
333+ navigate (' /notifications' );
334+ }}
335+ class =" block w-full text-center text-sm text-blue-600 dark:text-blue-400 hover:underline bg-transparent border-none cursor-pointer"
336+ >
337+ View all notifications
338+ </button >
339+ </div >
340+ </div >
341+ {/if }
342+ </div >
343+
344+ <style >
345+ /* Ensure dropdown is above other content */
346+ .relative {
347+ z-index : 40 ;
348+ }
349+ </style >
0 commit comments