Skip to content

Commit f6f5787

Browse files
author
secus
committed
feat: implement complete real-time notification system with WebSocket
✨ Features: - Complete WebSocket notification infrastructure with JWT authentication - Real-time deployment status updates and auto-refresh functionality - Notification bell with dropdown UI and click-to-navigate feature - Delete deployment functionality with confirmations - Auto-refresh for deployment lists when notifications arrive 🔧 Backend: - WebSocket server with JWT token authentication - NotificationManager for managing user connections - Real-time notifications for deployment events - Integration with deployment worker for status updates - Test endpoints for notification development 🎨 Frontend: - NotificationContext with authentication integration - WebSocket service with automatic reconnection - Updated all deployment pages to handle real-time updates - Proper message structure handling (nested data.data) - Toast notifications and navigation features 📄 New Pages: - DocumentationPage with complete API reference - FeaturesPage showcasing platform capabilities - PrivacyPolicyPage with comprehensive privacy details - TermsOfServicePage with legal terms and conditions - Updated LandingPage with proper navigation links 🔗 Integration: - WebSocket connects only when user is authenticated - Auto-cleanup on logout with proper state management - Cross-component notification handling - Real-time status synchronization across all pages This implements a production-ready notification system for real-time deployment monitoring and user engagement.
1 parent 4e468ce commit f6f5787

25 files changed

Lines changed: 2818 additions & 166 deletions

apps/container-engine-frontend/src/App.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// src/App.tsx
22
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
33
import { AuthProvider } from './context/AuthContext';
4+
import { NotificationProvider } from './context/NotificationContext';
45
import LandingPage from './pages/LandingPage';
56
import AuthPage from './pages/AuthPage';
67
import DashboardPage from './pages/DashboardPage';
@@ -9,6 +10,10 @@ import NewDeploymentPage from './pages/NewDeploymentPage';
910
import DeploymentDetailPage from './pages/DeploymentDetailPage';
1011
import ApiKeysPage from './pages/ApiKeysPage';
1112
import AccountSettingsPage from './pages/AccountSettingsPage';
13+
import FeaturesPage from './pages/FeaturesPage';
14+
import DocumentationPage from './pages/DocumentationPage';
15+
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
16+
import TermsOfServicePage from './pages/TermsOfServicePage';
1217
import ProtectedRoute from './components/ProtectedRoute';
1318
import { ToastContainer } from 'react-toastify';
1419
import 'react-toastify/dist/ReactToastify.css';
@@ -19,23 +24,29 @@ function App() {
1924
<>
2025
<ToastContainer position="top-center" />
2126
<AuthProvider>
22-
<Router>
23-
<Routes>
24-
<Route path="/" element={<LandingPage />} />
25-
<Route path="/auth" element={<AuthPage />} />
27+
<NotificationProvider>
28+
<Router>
29+
<Routes>
30+
<Route path="/" element={<LandingPage />} />
31+
<Route path="/auth" element={<AuthPage />} />
32+
<Route path="/features" element={<FeaturesPage />} />
33+
<Route path="/documentation" element={<DocumentationPage />} />
34+
<Route path="/privacy" element={<PrivacyPolicyPage />} />
35+
<Route path="/terms" element={<TermsOfServicePage />} />
2636

27-
{/* Protected Routes */}
28-
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
29-
<Route path="/deployments" element={<ProtectedRoute><DeploymentsPage /></ProtectedRoute>} />
30-
<Route path="/deployments/new" element={<ProtectedRoute><NewDeploymentPage /></ProtectedRoute>} />
31-
<Route path="/deployments/:deploymentId" element={<ProtectedRoute><DeploymentDetailPage /></ProtectedRoute>} />
32-
<Route path="/api-keys" element={<ProtectedRoute><ApiKeysPage /></ProtectedRoute>} />
33-
<Route path="/settings" element={<ProtectedRoute><AccountSettingsPage /></ProtectedRoute>} />
37+
{/* Protected Routes */}
38+
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
39+
<Route path="/deployments" element={<ProtectedRoute><DeploymentsPage /></ProtectedRoute>} />
40+
<Route path="/deployments/new" element={<ProtectedRoute><NewDeploymentPage /></ProtectedRoute>} />
41+
<Route path="/deployments/:deploymentId" element={<ProtectedRoute><DeploymentDetailPage /></ProtectedRoute>} />
42+
<Route path="/api-keys" element={<ProtectedRoute><ApiKeysPage /></ProtectedRoute>} />
43+
<Route path="/settings" element={<ProtectedRoute><AccountSettingsPage /></ProtectedRoute>} />
3444

35-
{/* Default route */}
36-
<Route path="*" element={<Navigate to="/dashboard" />} />
37-
</Routes>
38-
</Router>
45+
{/* Default route */}
46+
<Route path="*" element={<Navigate to="/dashboard" />} />
47+
</Routes>
48+
</Router>
49+
</NotificationProvider>
3950
</AuthProvider>
4051
</>
4152

apps/container-engine-frontend/src/api/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import axios from 'axios';
22

33
// Base API configuration
4-
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
4+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || window.location.origin;
5+
// const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
6+
57

68
// Create axios instance with default config
79
const api = axios.create({

apps/container-engine-frontend/src/components/DeploymentDetail/LogsPage.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export default function LogsPage() {
118118
const ws = new WebSocket(wsUrl);
119119

120120
ws.onopen = () => {
121-
console.log('WebSocket connected');
122121
setIsConnected(true);
123122
setIsConnecting(false);
124123
reconnectDelay.current = 1000; // Reset reconnect delay
@@ -149,7 +148,6 @@ export default function LogsPage() {
149148
};
150149

151150
ws.onclose = (event) => {
152-
console.log('WebSocket disconnected', event.code, event.reason);
153151
setIsConnected(false);
154152
setIsConnecting(false);
155153
wsRef.current = null;

apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import React, { useState } from 'react';
33
import { Link, useNavigate, useLocation } from 'react-router-dom';
44
import { useAuth } from '../../context/AuthContext';
5+
import { useNotifications } from '../../context/NotificationContext';
6+
import { formatDistanceToNow, parseISO } from 'date-fns';
57

68
const DashboardIcon = () => (
79
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -77,12 +79,12 @@ const Sidebar: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, o
7779
<>
7880
{/* Mobile overlay */}
7981
{isOpen && (
80-
<div
82+
<div
8183
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
8284
onClick={onClose}
8385
/>
8486
)}
85-
87+
8688
{/* Sidebar */}
8789
<div className={`
8890
fixed lg:static inset-y-0 left-0 z-50 w-64
@@ -94,13 +96,13 @@ const Sidebar: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, o
9496
<div className="flex flex-col h-full">
9597
{/* Logo */}
9698
<div className="flex-shrink-0 p-6 border-b border-slate-700/50">
97-
<Link
98-
to="/dashboard"
99+
<Link
100+
to="/dashboard"
99101
className="flex items-center space-x-3 group"
100102
onClick={() => window.innerWidth < 1024 && onClose()}
101103
>
102-
<div className="w-10 h-10 bg-linear-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center transform group-hover:scale-110 transition-transform duration-200">
103-
<span className="text-white font-bold text-lg">CE</span>
104+
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center">
105+
<img src="/open-container-engine-logo.png" alt="Open Container Engine" className="w-full h-full object-contain rounded-md" />
104106
</div>
105107
<div>
106108
<div className="text-xl font-bold text-white group-hover:text-blue-300 transition-colors">
@@ -116,16 +118,16 @@ const Sidebar: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, o
116118
{navItems.map((item) => {
117119
const Icon = item.icon;
118120
const active = isActive(item.path);
119-
121+
120122
return (
121123
<Link
122124
key={item.path}
123125
to={item.path}
124126
onClick={() => window.innerWidth < 1024 && onClose()}
125127
className={`
126128
flex items-center space-x-3 px-4 py-3 rounded-xl transition-all duration-200
127-
${active
128-
? 'bg-linear-to-r from-blue-500 to-purple-600 text-white shadow-lg transform scale-[1.02]'
129+
${active
130+
? 'bg-linear-to-r from-blue-500 to-purple-600 text-white shadow-lg transform scale-[1.02]'
129131
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
130132
}
131133
group
@@ -168,8 +170,24 @@ const Sidebar: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, o
168170

169171
const Header: React.FC<{ onMenuClick: () => void }> = ({ onMenuClick }) => {
170172
const { user } = useAuth();
173+
const { notifications, unreadCount, markNotificationAsRead } = useNotifications();
171174
const [showNotifications, setShowNotifications] = useState(false);
172175
const [showProfile, setShowProfile] = useState(false);
176+
const navigate = useNavigate();
177+
178+
const handleNotificationClick = (notification: any) => {
179+
// Mark as read
180+
markNotificationAsRead(notification.id);
181+
182+
// Close notification dropdown
183+
setShowNotifications(false);
184+
185+
// Navigate to deployment detail if deployment_id exists
186+
const deploymentId = notification.data.data?.deployment_id;
187+
if (deploymentId) {
188+
navigate(`/deployments/${deploymentId}`);
189+
}
190+
};
173191

174192
return (
175193
<header className="bg-white/80 backdrop-blur-md shadow-sm border-b border-slate-200 sticky top-0 z-30">
@@ -183,17 +201,17 @@ const Header: React.FC<{ onMenuClick: () => void }> = ({ onMenuClick }) => {
183201
>
184202
<MenuIcon />
185203
</button>
186-
204+
187205
<div className="min-w-0">
188206
<h1 className="text-base sm:text-lg lg:text-xl font-bold text-slate-800 truncate">
189207
Welcome back, {user?.username || 'User'}! 👋
190208
</h1>
191209
<p className="text-xs sm:text-sm text-slate-500 hidden sm:block">
192-
{new Date().toLocaleDateString('vi-VN', {
193-
weekday: 'long',
194-
year: 'numeric',
195-
month: 'long',
196-
day: 'numeric'
210+
{new Date().toLocaleDateString('vi-VN', {
211+
weekday: 'long',
212+
year: 'numeric',
213+
month: 'long',
214+
day: 'numeric'
197215
})}
198216
</p>
199217
</div>
@@ -220,23 +238,65 @@ const Header: React.FC<{ onMenuClick: () => void }> = ({ onMenuClick }) => {
220238
className="relative p-2 rounded-lg hover:bg-slate-100 transition-colors"
221239
>
222240
<BellIcon />
223-
<span className="absolute -top-1 -right-1 w-4 h-4 sm:w-5 sm:h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
224-
3
225-
</span>
241+
{unreadCount > 0 && (
242+
<span className="absolute -top-1 -right-1 w-4 h-4 sm:w-5 sm:h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
243+
{unreadCount > 99 ? '99+' : unreadCount}
244+
</span>
245+
)}
226246
</button>
227-
247+
228248
{showNotifications && (
229249
<div className="absolute right-0 mt-2 w-72 sm:w-80 bg-white rounded-xl shadow-lg border border-slate-200 py-2 z-50">
230250
<div className="px-4 py-2 border-b border-slate-100">
231251
<h3 className="font-semibold text-slate-800">Notifications</h3>
252+
{unreadCount > 0 && (
253+
<button
254+
onClick={() => {
255+
notifications.forEach(n => markNotificationAsRead(n.id));
256+
}}
257+
className="text-xs text-blue-600 hover:text-blue-800"
258+
>
259+
Mark all as read
260+
</button>
261+
)}
232262
</div>
233263
<div className="max-h-64 overflow-y-auto">
234-
{[1, 2, 3].map((i) => (
235-
<div key={i} className="px-4 py-3 hover:bg-slate-50 cursor-pointer border-b border-slate-50 last:border-0">
236-
<p className="text-sm font-medium text-slate-800">New deployment completed</p>
237-
<p className="text-xs text-slate-500 mt-1">2 minutes ago</p>
264+
{notifications.length > 0 ? (
265+
notifications.slice(0, 10).map((notification) => (
266+
<div
267+
key={notification.id}
268+
className="px-4 py-3 hover:bg-slate-50 cursor-pointer border-b border-slate-50 last:border-0"
269+
onClick={() => handleNotificationClick(notification)}
270+
>
271+
<p className="text-sm font-medium text-slate-800">
272+
{notification.type === 'deployment_status_changed' &&
273+
`${notification.data.data?.app_name || 'Deployment'} status changed to ${notification.data.data?.status}`}
274+
{notification.type === 'deployment_created' &&
275+
`New deployment created: ${notification.data.data?.app_name || 'Unknown'}`}
276+
{notification.type === 'deployment_deleted' &&
277+
`Deployment deleted: ${notification.data.data?.app_name || 'Unknown'}`}
278+
{notification.type === 'deployment_scaled' &&
279+
`${notification.data.data?.app_name || 'Deployment'} scaled from ${notification.data.data?.old_replicas} to ${notification.data.data?.new_replicas} replicas`}
280+
</p>
281+
<p className="text-xs text-slate-500 mt-1">
282+
{formatDistanceToNow(parseISO(notification.timestamp), { addSuffix: true })}
283+
</p>
284+
{notification.data.data?.message && (
285+
<p className="text-xs text-slate-600 mt-1">{notification.data.data.message}</p>
286+
)}
287+
{notification.data.data?.error_message && (
288+
<p className="text-xs text-red-600 mt-1">{notification.data.data.error_message}</p>
289+
)}
290+
</div>
291+
))
292+
) : (
293+
<div className="px-4 py-6 text-center text-slate-500">
294+
<svg className="h-8 w-8 mx-auto mb-2 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
295+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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" />
296+
</svg>
297+
<p className="text-sm">No notifications yet</p>
238298
</div>
239-
))}
299+
)}
240300
</div>
241301
</div>
242302
)}
@@ -286,10 +346,10 @@ const DashboardLayout: React.FC<{ children: React.ReactNode }> = ({ children })
286346
return (
287347
<div className="flex min-h-screen bg-linear-to-br from-slate-50 to-slate-100">
288348
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
289-
349+
290350
<div className="flex-1 flex flex-col lg:ml-0">
291351
<Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
292-
352+
293353
<main className="flex-1 overflow-auto">
294354
<div className="p-4 sm:p-6">
295355
{children}

0 commit comments

Comments
 (0)