Skip to content

Commit 33d0114

Browse files
author
Max Azatian
committed
rewrite: using kubernetes' watch
1 parent 8d0feed commit 33d0114

13 files changed

+5645
-0
lines changed
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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

Comments
 (0)