Skip to content

Commit 8dbb9ef

Browse files
gcmsgclaude
andcommitted
feat: implement notification system v1 (frontend)
Add dashboard notification UI: - useNotifications, useUnreadCount, useNotificationMutations hooks - NotificationBell component with dropdown and unread badge - NotificationsPage with filter tabs, mark-read, and mark-all-read - Header bar in ConsoleLayout with notification bell - Nav link and route for /console/notifications - i18n keys for all 8 locales (en, zh, es, fr, ar, pt, ja, ru) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 87d1307 commit 8dbb9ef

File tree

13 files changed

+578
-10
lines changed

13 files changed

+578
-10
lines changed

web/app/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { AgentEditPage } from "@/pages/AgentEditPage"
2525
import { InvocationHistoryPage } from "@/pages/InvocationHistoryPage"
2626
import { AccessRequestsPage } from "@/pages/AccessRequestsPage"
2727
import { APIKeysPage } from "@/pages/APIKeysPage"
28+
import { NotificationsPage } from "@/pages/NotificationsPage"
2829
import { ProfilePage } from "@/pages/ProfilePage"
2930
import { UsersPage } from "@/pages/admin/UsersPage"
3031
import { ReportsPage } from "@/pages/admin/ReportsPage"
@@ -69,6 +70,7 @@ export function App() {
6970
<Route path="invocations" element={<InvocationHistoryPage />} />
7071
<Route path="access-requests" element={<AccessRequestsPage />} />
7172
<Route path="api-keys" element={<APIKeysPage />} />
73+
<Route path="notifications" element={<NotificationsPage />} />
7274
<Route path="profile" element={<ProfilePage />} />
7375
</Route>
7476

web/app/src/components/layout/ConsoleLayout.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
Github,
1313
Lock,
1414
Home,
15+
Bell,
1516
} from "lucide-react"
17+
import { NotificationBell } from "@/components/layout/NotificationBell"
1618

1719
export function ConsoleLayout() {
1820
const { user, logout } = useAuth()
@@ -26,6 +28,7 @@ export function ConsoleLayout() {
2628
{ to: "/console/invocations", label: t('nav.invocations'), icon: Activity, end: false },
2729
{ to: "/console/access-requests", label: t('nav.accessRequests'), icon: Lock, end: false },
2830
{ to: "/console/api-keys", label: t('nav.apiKeys'), icon: KeyRound, end: false },
31+
{ to: "/console/notifications", label: t('nav.notifications'), icon: Bell, end: false },
2932
]
3033

3134
const handleLogout = async () => {
@@ -100,8 +103,13 @@ export function ConsoleLayout() {
100103
</aside>
101104

102105
{/* Main content */}
103-
<main className="flex-1 overflow-y-auto p-6">
104-
<Outlet />
106+
<main className="flex-1 overflow-y-auto">
107+
<div className="flex h-12 items-center justify-end border-b border-border px-6">
108+
<NotificationBell />
109+
</div>
110+
<div className="p-6">
111+
<Outlet />
112+
</div>
105113
</main>
106114
</div>
107115
)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { useState } from "react"
2+
import { useNavigate } from "react-router-dom"
3+
import { Bell } from "lucide-react"
4+
import { useTranslation } from "react-i18next"
5+
import { useNotifications, useUnreadCount, useNotificationMutations } from "@/hooks/use-notifications"
6+
import {
7+
DropdownMenu,
8+
DropdownMenuContent,
9+
DropdownMenuItem,
10+
DropdownMenuSeparator,
11+
DropdownMenuTrigger,
12+
} from "@/components/ui/dropdown-menu"
13+
14+
const severityColors: Record<string, string> = {
15+
info: "bg-blue-500",
16+
warning: "bg-yellow-500",
17+
critical: "bg-red-500",
18+
}
19+
20+
function timeAgo(dateStr: string): string {
21+
const now = Date.now()
22+
const then = new Date(dateStr).getTime()
23+
const diffMs = now - then
24+
const diffMin = Math.floor(diffMs / 60000)
25+
if (diffMin < 1) return "just now"
26+
if (diffMin < 60) return `${diffMin}m ago`
27+
const diffHrs = Math.floor(diffMin / 60)
28+
if (diffHrs < 24) return `${diffHrs}h ago`
29+
const diffDays = Math.floor(diffHrs / 24)
30+
return `${diffDays}d ago`
31+
}
32+
33+
export function NotificationBell() {
34+
const { t } = useTranslation()
35+
const navigate = useNavigate()
36+
const { count, reset, decrement } = useUnreadCount()
37+
const { notifications, reload } = useNotifications(10)
38+
const { markRead, markAllRead } = useNotificationMutations()
39+
const [open, setOpen] = useState(false)
40+
41+
const handleOpen = (isOpen: boolean) => {
42+
setOpen(isOpen)
43+
if (isOpen) {
44+
reload()
45+
}
46+
}
47+
48+
const handleMarkRead = async (id: string) => {
49+
await markRead(id)
50+
decrement(1)
51+
reload()
52+
}
53+
54+
const handleMarkAllRead = async () => {
55+
await markAllRead()
56+
reset()
57+
reload()
58+
}
59+
60+
return (
61+
<DropdownMenu open={open} onOpenChange={handleOpen}>
62+
<DropdownMenuTrigger asChild>
63+
<button className="relative rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
64+
<Bell className="size-4" />
65+
{count > 0 && (
66+
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium text-white">
67+
{count > 99 ? "99+" : count}
68+
</span>
69+
)}
70+
</button>
71+
</DropdownMenuTrigger>
72+
<DropdownMenuContent align="end" className="w-80">
73+
<div className="flex items-center justify-between px-3 py-2">
74+
<span className="text-sm font-medium">{t('notifications.title')}</span>
75+
{count > 0 && (
76+
<button
77+
onClick={handleMarkAllRead}
78+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
79+
>
80+
{t('notifications.markAllRead')}
81+
</button>
82+
)}
83+
</div>
84+
<DropdownMenuSeparator />
85+
{notifications.length === 0 ? (
86+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
87+
{t('notifications.empty')}
88+
</div>
89+
) : (
90+
<>
91+
{notifications.map((n) => (
92+
<DropdownMenuItem
93+
key={n.id}
94+
className="flex items-start gap-2.5 px-3 py-2.5 cursor-pointer"
95+
onClick={() => {
96+
if (!n.read) handleMarkRead(n.id)
97+
}}
98+
>
99+
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${severityColors[n.severity] || severityColors.info}`} />
100+
<div className="min-w-0 flex-1">
101+
<div className={`text-sm truncate ${n.read ? "text-muted-foreground" : "font-medium"}`}>
102+
{n.title}
103+
</div>
104+
<div className="text-xs text-muted-foreground mt-0.5 truncate">
105+
{n.body}
106+
</div>
107+
<div className="text-xs text-muted-foreground/60 mt-0.5">
108+
{timeAgo(n.created_at)}
109+
</div>
110+
</div>
111+
</DropdownMenuItem>
112+
))}
113+
<DropdownMenuSeparator />
114+
<DropdownMenuItem
115+
className="justify-center text-xs text-muted-foreground cursor-pointer"
116+
onClick={() => {
117+
setOpen(false)
118+
navigate("/console/notifications")
119+
}}
120+
>
121+
{t('notifications.viewAll')}
122+
</DropdownMenuItem>
123+
</>
124+
)}
125+
</DropdownMenuContent>
126+
</DropdownMenu>
127+
)
128+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useState, useEffect, useCallback, useRef } from "react"
2+
import { fetchWithAuth } from "@/api/client"
3+
import { useAuth } from "@/hooks/use-auth"
4+
5+
export interface Notification {
6+
id: string
7+
user_id: string
8+
agent_id: string
9+
type: string
10+
severity: string
11+
title: string
12+
body: string
13+
metadata?: Record<string, string>
14+
read: boolean
15+
created_at: string
16+
}
17+
18+
interface NotificationListResponse {
19+
notifications: Notification[]
20+
total: number
21+
unread_count: number
22+
}
23+
24+
export function useNotifications(limit = 20) {
25+
const { accessToken } = useAuth()
26+
const [notifications, setNotifications] = useState<Notification[]>([])
27+
const [total, setTotal] = useState(0)
28+
const [unreadCount, setUnreadCount] = useState(0)
29+
const [loading, setLoading] = useState(true)
30+
const [error, setError] = useState<string | null>(null)
31+
32+
const load = useCallback(async () => {
33+
if (!accessToken) return
34+
try {
35+
setLoading(true)
36+
const data = await fetchWithAuth<NotificationListResponse>(
37+
`/provider/notifications?limit=${limit}`,
38+
accessToken
39+
)
40+
setNotifications(data.notifications ?? [])
41+
setTotal(data.total)
42+
setUnreadCount(data.unread_count)
43+
setError(null)
44+
} catch (e) {
45+
setError(e instanceof Error ? e.message : "Failed to fetch notifications")
46+
} finally {
47+
setLoading(false)
48+
}
49+
}, [accessToken, limit])
50+
51+
useEffect(() => {
52+
load()
53+
}, [load])
54+
55+
return { notifications, total, unreadCount, loading, error, reload: load }
56+
}
57+
58+
export function useUnreadCount() {
59+
const { accessToken } = useAuth()
60+
const [count, setCount] = useState(0)
61+
const wsRef = useRef<WebSocket | null>(null)
62+
63+
// Initial fetch.
64+
useEffect(() => {
65+
if (!accessToken) return
66+
fetchWithAuth<{ unread_count: number }>(
67+
"/provider/notifications/count",
68+
accessToken
69+
).then((data) => setCount(data.unread_count))
70+
.catch(() => {})
71+
}, [accessToken])
72+
73+
// WebSocket for real-time updates.
74+
useEffect(() => {
75+
if (!accessToken) return
76+
77+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
78+
const wsUrl = `${protocol}//${window.location.host}/api/v1/provider/notifications/ws?token=${encodeURIComponent(accessToken)}`
79+
80+
const ws = new WebSocket(wsUrl)
81+
wsRef.current = ws
82+
83+
ws.onmessage = () => {
84+
setCount((prev) => prev + 1)
85+
}
86+
87+
ws.onerror = () => {}
88+
ws.onclose = () => {}
89+
90+
return () => {
91+
ws.close()
92+
wsRef.current = null
93+
}
94+
}, [accessToken])
95+
96+
const decrement = useCallback((n = 1) => {
97+
setCount((prev) => Math.max(0, prev - n))
98+
}, [])
99+
100+
const reset = useCallback(() => {
101+
setCount(0)
102+
}, [])
103+
104+
return { count, setCount, decrement, reset }
105+
}
106+
107+
export function useNotificationMutations() {
108+
const { accessToken } = useAuth()
109+
110+
const markRead = useCallback(async (id: string) => {
111+
if (!accessToken) return
112+
await fetchWithAuth(`/provider/notifications/${id}/read`, accessToken, {
113+
method: "PUT",
114+
})
115+
}, [accessToken])
116+
117+
const markAllRead = useCallback(async () => {
118+
if (!accessToken) return
119+
await fetchWithAuth("/provider/notifications/read-all", accessToken, {
120+
method: "PUT",
121+
})
122+
}, [accessToken])
123+
124+
return { markRead, markAllRead }
125+
}

web/app/src/i18n/locales/ar.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"logout": "تسجيل الخروج",
5757
"github": "GitHub",
5858
"about": "حول",
59-
"profile": "الملف الشخصي"
59+
"profile": "الملف الشخصي",
60+
"notifications": "الإشعارات"
6061
},
6162
"auth": {
6263
"email": "البريد الإلكتروني",
@@ -631,5 +632,24 @@
631632
"passwordChanged": "تم تغيير كلمة المرور بنجاح",
632633
"passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين",
633634
"passwordMinLength": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل"
635+
},
636+
"notifications": {
637+
"title": "الإشعارات",
638+
"empty": "لا توجد إشعارات",
639+
"markRead": "تحديد كمقروء",
640+
"markAllRead": "تحديد الكل كمقروء",
641+
"unread": "غير مقروءة",
642+
"all": "الكل",
643+
"viewAll": "عرض جميع الإشعارات",
644+
"types": {
645+
"access_request_received": "طلب وصول مستلم",
646+
"access_request_approved": "طلب وصول مقبول",
647+
"access_request_rejected": "طلب وصول مرفوض",
648+
"contact_request_received": "طلب اتصال مستلم",
649+
"contact_added": "تمت إضافة جهة اتصال",
650+
"agent_offline": "الوكيل غير متصل",
651+
"agent_degraded": "أداء الوكيل منخفض",
652+
"sdk_outdated": "إصدار SDK قديم"
653+
}
634654
}
635655
}

web/app/src/i18n/locales/en.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"logout": "Logout",
5757
"github": "GitHub",
5858
"about": "About",
59-
"profile": "Profile"
59+
"profile": "Profile",
60+
"notifications": "Notifications"
6061
},
6162
"auth": {
6263
"email": "Email",
@@ -631,5 +632,24 @@
631632
"passwordChanged": "Password changed successfully",
632633
"passwordMismatch": "New passwords do not match",
633634
"passwordMinLength": "Password must be at least 8 characters"
635+
},
636+
"notifications": {
637+
"title": "Notifications",
638+
"empty": "No notifications",
639+
"markRead": "Mark as read",
640+
"markAllRead": "Mark all as read",
641+
"unread": "Unread",
642+
"all": "All",
643+
"viewAll": "View all notifications",
644+
"types": {
645+
"access_request_received": "Access Request Received",
646+
"access_request_approved": "Access Request Approved",
647+
"access_request_rejected": "Access Request Rejected",
648+
"contact_request_received": "Contact Request Received",
649+
"contact_added": "Contact Added",
650+
"agent_offline": "Agent Offline",
651+
"agent_degraded": "Agent Degraded",
652+
"sdk_outdated": "SDK Outdated"
653+
}
634654
}
635655
}

0 commit comments

Comments
 (0)