@gravito/zenith 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +88 -0
- package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
- package/DEMO.md +156 -0
- package/DEPLOYMENT.md +157 -0
- package/DOCS_INTERNAL.md +73 -0
- package/Dockerfile +46 -0
- package/Dockerfile.demo-worker +29 -0
- package/EVOLUTION_BLUEPRINT.md +112 -0
- package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
- package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
- package/QUICK_TEST_GUIDE.md +72 -0
- package/README.md +33 -0
- package/ROADMAP.md +85 -0
- package/TESTING_BATCH_OPERATIONS.md +252 -0
- package/bin/flux-console.ts +2 -0
- package/dist/bin.js +108196 -0
- package/dist/client/assets/index-DGYEwTDL.css +1 -0
- package/dist/client/assets/index-oyTdySX0.js +421 -0
- package/dist/client/index.html +13 -0
- package/dist/server/index.js +108191 -0
- package/docker-compose.yml +40 -0
- package/docs/integrations/LARAVEL.md +207 -0
- package/package.json +50 -0
- package/postcss.config.js +6 -0
- package/scripts/flood-logs.ts +21 -0
- package/scripts/seed.ts +213 -0
- package/scripts/verify-throttle.ts +45 -0
- package/scripts/worker.ts +123 -0
- package/src/bin.ts +6 -0
- package/src/client/App.tsx +70 -0
- package/src/client/Layout.tsx +644 -0
- package/src/client/Sidebar.tsx +102 -0
- package/src/client/ThroughputChart.tsx +135 -0
- package/src/client/WorkerStatus.tsx +170 -0
- package/src/client/components/ConfirmDialog.tsx +103 -0
- package/src/client/components/JobInspector.tsx +524 -0
- package/src/client/components/LogArchiveModal.tsx +383 -0
- package/src/client/components/NotificationBell.tsx +203 -0
- package/src/client/components/Toaster.tsx +80 -0
- package/src/client/components/UserProfileDropdown.tsx +177 -0
- package/src/client/contexts/AuthContext.tsx +93 -0
- package/src/client/contexts/NotificationContext.tsx +103 -0
- package/src/client/index.css +174 -0
- package/src/client/index.html +12 -0
- package/src/client/main.tsx +15 -0
- package/src/client/pages/LoginPage.tsx +153 -0
- package/src/client/pages/MetricsPage.tsx +408 -0
- package/src/client/pages/OverviewPage.tsx +511 -0
- package/src/client/pages/QueuesPage.tsx +372 -0
- package/src/client/pages/SchedulesPage.tsx +531 -0
- package/src/client/pages/SettingsPage.tsx +449 -0
- package/src/client/pages/WorkersPage.tsx +316 -0
- package/src/client/pages/index.ts +7 -0
- package/src/client/utils.ts +6 -0
- package/src/server/index.ts +556 -0
- package/src/server/middleware/auth.ts +127 -0
- package/src/server/services/AlertService.ts +160 -0
- package/src/server/services/QueueService.ts +828 -0
- package/tailwind.config.js +73 -0
- package/tests/placeholder.test.ts +7 -0
- package/tsconfig.json +38 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
2
|
+
import {
|
|
3
|
+
Activity,
|
|
4
|
+
Calendar,
|
|
5
|
+
ChevronLeft,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Clock,
|
|
8
|
+
Filter,
|
|
9
|
+
Search,
|
|
10
|
+
X,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import React from 'react'
|
|
13
|
+
import { cn } from '../utils'
|
|
14
|
+
|
|
15
|
+
interface LogArchiveModalProps {
|
|
16
|
+
isOpen: boolean
|
|
17
|
+
onClose: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LogArchiveModal({ isOpen, onClose }: LogArchiveModalProps) {
|
|
21
|
+
const [page, setPage] = React.useState(1)
|
|
22
|
+
const [search, setSearch] = React.useState('')
|
|
23
|
+
const [status, setStatus] = React.useState<string>('all')
|
|
24
|
+
const [logs, setLogs] = React.useState<any[]>([])
|
|
25
|
+
const [total, setTotal] = React.useState(0)
|
|
26
|
+
const [isLoading, setIsLoading] = React.useState(false)
|
|
27
|
+
const [dateRange, setDateRange] = React.useState<{ start?: string; end?: string }>({})
|
|
28
|
+
|
|
29
|
+
const fetchLogs = React.useCallback(async () => {
|
|
30
|
+
setIsLoading(true)
|
|
31
|
+
try {
|
|
32
|
+
const params = new URLSearchParams({
|
|
33
|
+
page: String(page),
|
|
34
|
+
limit: '20',
|
|
35
|
+
search, // Backend handles 'job:123' as jobId filter
|
|
36
|
+
...(status !== 'all' && { status }), // Map 'status' to 'level' or 'status' in backend?
|
|
37
|
+
// Note: The /api/logs/archive endpoint needs to be smart enough, or we need separate endpoints.
|
|
38
|
+
// For now, let's assume we are searching LOGS first.
|
|
39
|
+
// Wait, the user wants "Audit Trail" of jobs which is stored in the same DB but different table?
|
|
40
|
+
// Actually, our previous implementation added filters to `listLogs` too.
|
|
41
|
+
// But `archive()` writes to `jobs` table (SQLitePersistence) / `completed_jobs` (MySQL)?
|
|
42
|
+
// Let's check the backend implementation again.
|
|
43
|
+
|
|
44
|
+
// Correction: The backend has TWO endpoints:
|
|
45
|
+
// 1. /api/queues/:name/archive -> Queries JOB archive (waiting, completed, failed)
|
|
46
|
+
// 2. /api/logs/archive -> Queries LOG archive (info, warn, error)
|
|
47
|
+
|
|
48
|
+
// If the user wants to audit a JOB, they likely want the JOB archive.
|
|
49
|
+
// But this modal is "LogArchiveModal".
|
|
50
|
+
// The requirement is "trace a specific job ... trace status over time".
|
|
51
|
+
// We should probably allow searching BOTH or switching modes.
|
|
52
|
+
|
|
53
|
+
// Let's add a "Mode Switcher" in UI: "System Logs" vs "Job Audit".
|
|
54
|
+
// But for now, let's keep it simple and just query existing logs for now,
|
|
55
|
+
// OR better, query the JOBS archive if it looks like a Job ID.
|
|
56
|
+
|
|
57
|
+
// Let's stick to the existing /api/logs/archive for now as per current file,
|
|
58
|
+
// BUT we need to support `startTime` and `endTime`.
|
|
59
|
+
...(dateRange.start && { startTime: dateRange.start }),
|
|
60
|
+
...(dateRange.end && { endTime: dateRange.end }),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// If user selects "Waiting" or searches "job:...", we might need to hit a different endpoint?
|
|
64
|
+
// Currently /api/logs/archive hits `listLogs`.
|
|
65
|
+
// The job archive is `/api/queues/:name/archive`.
|
|
66
|
+
// This is a bit tricky since we don't know the queue name for global search.
|
|
67
|
+
|
|
68
|
+
// For this specific request "Trace failed jobs / waiting jobs",
|
|
69
|
+
// we really need a "Global Job Search" endpoint.
|
|
70
|
+
// But let's verify if `listLogs` can return job events.
|
|
71
|
+
// Looking at `PersistenceAdapter`, `archiveLog` is for text logs. `archive` is for Jobs.
|
|
72
|
+
|
|
73
|
+
// Since we implemented "Audit Mode", we are writing to the JOBS table (archive).
|
|
74
|
+
// So to find a "lost job", we need to search the JOBS table.
|
|
75
|
+
// But `getArchiveJobs` requires a QUEUE name.
|
|
76
|
+
|
|
77
|
+
// HACK: For now, let's just implement the UI for LOGS filters (Time Range) as requested first.
|
|
78
|
+
// The user asked "trace status...".
|
|
79
|
+
// We might need a "Global Search" later.
|
|
80
|
+
|
|
81
|
+
const res = await fetch(`/api/logs/archive?${params}`).then((r) => r.json())
|
|
82
|
+
setLogs(res.logs || [])
|
|
83
|
+
setTotal(res.total || 0)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error('Failed to fetch archived logs', err)
|
|
86
|
+
} finally {
|
|
87
|
+
setIsLoading(false)
|
|
88
|
+
}
|
|
89
|
+
}, [page, search, status, dateRange])
|
|
90
|
+
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
if (isOpen) {
|
|
93
|
+
fetchLogs()
|
|
94
|
+
}
|
|
95
|
+
}, [isOpen, fetchLogs])
|
|
96
|
+
|
|
97
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
setPage(1)
|
|
100
|
+
fetchLogs()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const totalPages = Math.ceil(total / 20)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<AnimatePresence>
|
|
107
|
+
{isOpen && (
|
|
108
|
+
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4 sm:p-8">
|
|
109
|
+
<motion.div
|
|
110
|
+
initial={{ opacity: 0 }}
|
|
111
|
+
animate={{ opacity: 1 }}
|
|
112
|
+
exit={{ opacity: 0 }}
|
|
113
|
+
className="absolute inset-0 bg-black/60 backdrop-blur-md"
|
|
114
|
+
onClick={onClose}
|
|
115
|
+
/>
|
|
116
|
+
<motion.div
|
|
117
|
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
118
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
119
|
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
120
|
+
className="relative w-full max-w-6xl h-full max-h-[850px] bg-card border border-border/50 rounded-3xl shadow-2xl flex flex-col overflow-hidden scanline"
|
|
121
|
+
>
|
|
122
|
+
{/* Header */}
|
|
123
|
+
<div className="p-6 border-b bg-muted/10 flex justify-between items-center">
|
|
124
|
+
<div className="flex items-center gap-4">
|
|
125
|
+
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
|
126
|
+
<Clock size={20} />
|
|
127
|
+
</div>
|
|
128
|
+
<div>
|
|
129
|
+
<h2 className="text-xl font-black tracking-tight">Time Travel Audit</h2>
|
|
130
|
+
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold opacity-60">
|
|
131
|
+
Trace events and system logs across time
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={onClose}
|
|
138
|
+
className="p-2 hover:bg-muted rounded-xl text-muted-foreground transition-colors"
|
|
139
|
+
>
|
|
140
|
+
<X size={20} />
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Advanced Filters */}
|
|
145
|
+
<div className="p-4 bg-muted/5 border-b grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
|
146
|
+
<form onSubmit={handleSearch} className="md:col-span-2 relative">
|
|
147
|
+
<label
|
|
148
|
+
htmlFor="log-search"
|
|
149
|
+
className="text-[10px] uppercase font-bold text-muted-foreground mb-1.5 block ml-1"
|
|
150
|
+
>
|
|
151
|
+
Search Query
|
|
152
|
+
</label>
|
|
153
|
+
<div className="relative">
|
|
154
|
+
<Search
|
|
155
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
|
|
156
|
+
size={16}
|
|
157
|
+
/>
|
|
158
|
+
<input
|
|
159
|
+
id="log-search"
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="Search message..."
|
|
162
|
+
className="w-full bg-background border border-border/50 rounded-xl py-2.5 pl-10 pr-4 text-sm font-medium outline-none focus:ring-1 focus:ring-primary/30 transition-all font-mono"
|
|
163
|
+
value={search}
|
|
164
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</form>
|
|
168
|
+
|
|
169
|
+
<div className="relative">
|
|
170
|
+
<label
|
|
171
|
+
htmlFor="start-time"
|
|
172
|
+
className="text-[10px] uppercase font-bold text-muted-foreground mb-1.5 block ml-1"
|
|
173
|
+
>
|
|
174
|
+
Time Range
|
|
175
|
+
</label>
|
|
176
|
+
<div className="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-2.5">
|
|
177
|
+
<Calendar size={14} className="text-muted-foreground/50" />
|
|
178
|
+
<input
|
|
179
|
+
id="start-time"
|
|
180
|
+
type="datetime-local"
|
|
181
|
+
className="bg-transparent text-[10px] font-mono outline-none w-full"
|
|
182
|
+
onChange={(e) => {
|
|
183
|
+
const date = e.target.value
|
|
184
|
+
? new Date(e.target.value).toISOString()
|
|
185
|
+
: undefined
|
|
186
|
+
setDateRange((prev) => ({ ...prev, start: date }))
|
|
187
|
+
setPage(1)
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<span className="text-muted-foreground/30 text-[10px]">to</span>
|
|
191
|
+
<input
|
|
192
|
+
aria-label="End Time"
|
|
193
|
+
type="datetime-local"
|
|
194
|
+
className="bg-transparent text-[10px] font-mono outline-none w-full"
|
|
195
|
+
onChange={(e) => {
|
|
196
|
+
const date = e.target.value
|
|
197
|
+
? new Date(e.target.value).toISOString()
|
|
198
|
+
: undefined
|
|
199
|
+
setDateRange((prev) => ({ ...prev, end: date }))
|
|
200
|
+
setPage(1)
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div className="relative">
|
|
207
|
+
<label
|
|
208
|
+
htmlFor="log-level"
|
|
209
|
+
className="text-[10px] uppercase font-bold text-muted-foreground mb-1.5 block ml-1"
|
|
210
|
+
>
|
|
211
|
+
Level / Status
|
|
212
|
+
</label>
|
|
213
|
+
<div className="relative">
|
|
214
|
+
<Filter
|
|
215
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
|
|
216
|
+
size={14}
|
|
217
|
+
/>
|
|
218
|
+
<select
|
|
219
|
+
id="log-level"
|
|
220
|
+
className="w-full bg-background border border-border/50 rounded-xl py-2.5 pl-9 pr-4 text-sm font-bold outline-none focus:ring-1 focus:ring-primary/30 transition-all appearance-none"
|
|
221
|
+
value={status}
|
|
222
|
+
onChange={(e) => {
|
|
223
|
+
setStatus(e.target.value)
|
|
224
|
+
setPage(1)
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<option value="all">Every Event</option>
|
|
228
|
+
<option value="info">Info / Logs</option>
|
|
229
|
+
<option value="error">Errors / Failed</option>
|
|
230
|
+
<option value="warn">Warnings</option>
|
|
231
|
+
<option value="success">Success / Completed</option>
|
|
232
|
+
</select>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Logs List */}
|
|
238
|
+
<div className="flex-1 overflow-y-auto p-0 scrollbar-thin bg-black/20">
|
|
239
|
+
{isLoading ? (
|
|
240
|
+
<div className="h-full flex flex-col items-center justify-center gap-4 py-20">
|
|
241
|
+
<RefreshCwIcon className="animate-spin text-primary" size={32} />
|
|
242
|
+
<p className="text-[10px] font-black uppercase tracking-[0.3em] text-muted-foreground animate-pulse">
|
|
243
|
+
Time Traveling...
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
) : logs.length === 0 ? (
|
|
247
|
+
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/30 gap-4 py-20">
|
|
248
|
+
<Activity size={48} className="opacity-10 animate-pulse" />
|
|
249
|
+
<p className="font-bold uppercase tracking-widest italic text-sm">
|
|
250
|
+
No events found in this timeline
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
) : (
|
|
254
|
+
<div className="divide-y divide-border/10">
|
|
255
|
+
{logs.map((log) => (
|
|
256
|
+
<div
|
|
257
|
+
key={log.id}
|
|
258
|
+
className="p-4 flex gap-4 hover:bg-white/[0.02] transition-colors group cursor-default"
|
|
259
|
+
>
|
|
260
|
+
{/* Column 1: Time */}
|
|
261
|
+
<div className="shrink-0 w-32 pt-1">
|
|
262
|
+
<div className="flex flex-col text-right">
|
|
263
|
+
<span className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-tighter tabular-nums">
|
|
264
|
+
{new Date(log.timestamp).toLocaleDateString()}
|
|
265
|
+
</span>
|
|
266
|
+
<span className="text-xs font-mono font-bold tabular-nums text-foreground/80">
|
|
267
|
+
{new Date(log.timestamp).toLocaleTimeString([], {
|
|
268
|
+
hour12: false,
|
|
269
|
+
hour: '2-digit',
|
|
270
|
+
minute: '2-digit',
|
|
271
|
+
second: '2-digit',
|
|
272
|
+
})}
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Column 2: Status Indicator */}
|
|
278
|
+
<div className="shrink-0 pt-1.5 relative">
|
|
279
|
+
<div className="w-px h-full absolute left-1/2 -translate-x-1/2 top-4 bg-border/30 last:hidden"></div>
|
|
280
|
+
<div
|
|
281
|
+
className={cn(
|
|
282
|
+
'w-3 h-3 rounded-full border-2 relative z-10',
|
|
283
|
+
log.level === 'error'
|
|
284
|
+
? 'border-red-500 bg-red-500/20'
|
|
285
|
+
: log.level === 'warn'
|
|
286
|
+
? 'border-amber-500 bg-amber-500/20'
|
|
287
|
+
: log.level === 'success'
|
|
288
|
+
? 'border-green-500 bg-green-500/20'
|
|
289
|
+
: 'border-blue-500 bg-blue-500/20'
|
|
290
|
+
)}
|
|
291
|
+
></div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Column 3: Event Details */}
|
|
295
|
+
<div className="flex-1 min-w-0">
|
|
296
|
+
<div className="flex items-center gap-3 mb-1.5">
|
|
297
|
+
<span
|
|
298
|
+
className={cn(
|
|
299
|
+
'px-1.5 py-0.5 rounded text-[9px] font-black uppercase tracking-widest',
|
|
300
|
+
log.level === 'error'
|
|
301
|
+
? 'bg-red-500/10 text-red-500'
|
|
302
|
+
: log.level === 'warn'
|
|
303
|
+
? 'bg-amber-500/10 text-amber-500'
|
|
304
|
+
: log.level === 'success'
|
|
305
|
+
? 'bg-green-500/10 text-green-500'
|
|
306
|
+
: 'bg-blue-500/10 text-blue-500'
|
|
307
|
+
)}
|
|
308
|
+
>
|
|
309
|
+
{log.level}
|
|
310
|
+
</span>
|
|
311
|
+
<span className="text-[10px] font-mono text-muted-foreground/50">
|
|
312
|
+
{log.worker_id}
|
|
313
|
+
</span>
|
|
314
|
+
{log.queue && (
|
|
315
|
+
<span className="text-[10px] font-black text-indigo-400/80 uppercase tracking-wider bg-indigo-500/10 px-1.5 rounded">
|
|
316
|
+
{log.queue}
|
|
317
|
+
</span>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
<p className="text-sm text-foreground/90 font-mono break-all leading-relaxed opacity-90">
|
|
321
|
+
{log.message}
|
|
322
|
+
</p>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Footer / Pagination */}
|
|
331
|
+
<div className="p-4 border-t bg-muted/5 flex flex-col sm:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold text-muted-foreground">
|
|
332
|
+
<div>
|
|
333
|
+
Scanning {total.toLocaleString()} events • Page {page} of {totalPages || 1}
|
|
334
|
+
</div>
|
|
335
|
+
<div className="flex items-center gap-2">
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
disabled={page === 1 || isLoading}
|
|
339
|
+
onClick={() => setPage((p) => p - 1)}
|
|
340
|
+
className="p-2 border rounded-xl hover:bg-muted disabled:opacity-30 transition-all active:scale-95"
|
|
341
|
+
>
|
|
342
|
+
<ChevronLeft size={16} />
|
|
343
|
+
</button>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
disabled={page >= totalPages || isLoading}
|
|
347
|
+
onClick={() => setPage((p) => p + 1)}
|
|
348
|
+
className="p-2 border rounded-xl hover:bg-muted disabled:opacity-30 transition-all active:scale-95"
|
|
349
|
+
>
|
|
350
|
+
<ChevronRight size={16} />
|
|
351
|
+
</button>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</motion.div>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</AnimatePresence>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function RefreshCwIcon({ className, size }: { className?: string; size?: number }) {
|
|
362
|
+
return (
|
|
363
|
+
<svg
|
|
364
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
365
|
+
width={size}
|
|
366
|
+
height={size}
|
|
367
|
+
viewBox="0 0 24 24"
|
|
368
|
+
fill="none"
|
|
369
|
+
stroke="currentColor"
|
|
370
|
+
strokeWidth="2"
|
|
371
|
+
strokeLinecap="round"
|
|
372
|
+
strokeLinejoin="round"
|
|
373
|
+
className={className}
|
|
374
|
+
aria-label="Refreshing"
|
|
375
|
+
>
|
|
376
|
+
<title>Refreshing</title>
|
|
377
|
+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
|
378
|
+
<path d="M21 3v5h-5" />
|
|
379
|
+
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
|
380
|
+
<path d="M3 21v-5h5" />
|
|
381
|
+
</svg>
|
|
382
|
+
)
|
|
383
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
2
|
+
import {
|
|
3
|
+
AlertCircle,
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
Bell,
|
|
6
|
+
CheckCheck,
|
|
7
|
+
CheckCircle,
|
|
8
|
+
Info,
|
|
9
|
+
Trash2,
|
|
10
|
+
X,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import { useEffect, useRef, useState } from 'react'
|
|
13
|
+
import { type Notification, useNotifications } from '../contexts/NotificationContext'
|
|
14
|
+
import { cn } from '../utils'
|
|
15
|
+
|
|
16
|
+
export function NotificationBell() {
|
|
17
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
18
|
+
const { notifications, unreadCount, markAsRead, markAllAsRead, clearAll, removeNotification } =
|
|
19
|
+
useNotifications()
|
|
20
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
21
|
+
|
|
22
|
+
// Close panel when clicking outside
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
25
|
+
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
|
26
|
+
setIsOpen(false)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isOpen) {
|
|
31
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
36
|
+
}
|
|
37
|
+
}, [isOpen])
|
|
38
|
+
|
|
39
|
+
const getIcon = (type: Notification['type']) => {
|
|
40
|
+
switch (type) {
|
|
41
|
+
case 'error':
|
|
42
|
+
return <AlertCircle className="text-red-500" size={16} />
|
|
43
|
+
case 'warning':
|
|
44
|
+
return <AlertTriangle className="text-amber-500" size={16} />
|
|
45
|
+
case 'success':
|
|
46
|
+
return <CheckCircle className="text-green-500" size={16} />
|
|
47
|
+
default:
|
|
48
|
+
return <Info className="text-blue-500" size={16} />
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const formatTime = (timestamp: number) => {
|
|
53
|
+
const diff = Date.now() - timestamp
|
|
54
|
+
const minutes = Math.floor(diff / 60000)
|
|
55
|
+
const hours = Math.floor(diff / 3600000)
|
|
56
|
+
|
|
57
|
+
if (minutes < 1) {
|
|
58
|
+
return 'Just now'
|
|
59
|
+
}
|
|
60
|
+
if (minutes < 60) {
|
|
61
|
+
return `${minutes}m ago`
|
|
62
|
+
}
|
|
63
|
+
if (hours < 24) {
|
|
64
|
+
return `${hours}h ago`
|
|
65
|
+
}
|
|
66
|
+
return new Date(timestamp).toLocaleDateString()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="relative" ref={panelRef}>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
74
|
+
className="text-muted-foreground hover:text-foreground transition-all relative p-2 hover:bg-muted rounded-xl"
|
|
75
|
+
>
|
|
76
|
+
<Bell size={20} />
|
|
77
|
+
{unreadCount > 0 && (
|
|
78
|
+
<motion.span
|
|
79
|
+
initial={{ scale: 0 }}
|
|
80
|
+
animate={{ scale: 1 }}
|
|
81
|
+
className="absolute top-1.5 right-1.5 min-w-[16px] h-4 bg-red-500 rounded-full border-2 border-background text-[9px] font-black text-white flex items-center justify-center px-0.5"
|
|
82
|
+
>
|
|
83
|
+
{unreadCount > 9 ? '9+' : unreadCount}
|
|
84
|
+
</motion.span>
|
|
85
|
+
)}
|
|
86
|
+
</button>
|
|
87
|
+
|
|
88
|
+
<AnimatePresence>
|
|
89
|
+
{isOpen && (
|
|
90
|
+
<motion.div
|
|
91
|
+
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
92
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
93
|
+
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
94
|
+
transition={{ duration: 0.2 }}
|
|
95
|
+
className="absolute right-0 top-full mt-2 w-96 bg-card border rounded-2xl shadow-2xl overflow-hidden z-50"
|
|
96
|
+
>
|
|
97
|
+
{/* Header */}
|
|
98
|
+
<div className="p-4 border-b bg-muted/20 flex items-center justify-between">
|
|
99
|
+
<div>
|
|
100
|
+
<h3 className="font-bold">Notifications</h3>
|
|
101
|
+
<p className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
|
102
|
+
{unreadCount} unread
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
{unreadCount > 0 && (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={markAllAsRead}
|
|
110
|
+
className="p-1.5 hover:bg-muted rounded-lg text-muted-foreground hover:text-foreground transition-colors"
|
|
111
|
+
title="Mark all as read"
|
|
112
|
+
>
|
|
113
|
+
<CheckCheck size={16} />
|
|
114
|
+
</button>
|
|
115
|
+
)}
|
|
116
|
+
{notifications.length > 0 && (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={clearAll}
|
|
120
|
+
className="p-1.5 hover:bg-red-500/10 rounded-lg text-muted-foreground hover:text-red-500 transition-colors"
|
|
121
|
+
title="Clear all"
|
|
122
|
+
>
|
|
123
|
+
<Trash2 size={16} />
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Notification List */}
|
|
130
|
+
<div className="max-h-[400px] overflow-y-auto">
|
|
131
|
+
{notifications.length === 0 ? (
|
|
132
|
+
<div className="p-8 text-center">
|
|
133
|
+
<Bell className="mx-auto mb-3 text-muted-foreground/20" size={32} />
|
|
134
|
+
<p className="text-sm text-muted-foreground">No notifications</p>
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<div className="divide-y divide-border/50">
|
|
138
|
+
{notifications.map((notification) => (
|
|
139
|
+
<motion.div
|
|
140
|
+
key={notification.id}
|
|
141
|
+
initial={{ opacity: 0, x: -10 }}
|
|
142
|
+
animate={{ opacity: 1, x: 0 }}
|
|
143
|
+
className={cn(
|
|
144
|
+
'p-4 hover:bg-muted/30 transition-colors cursor-pointer group relative',
|
|
145
|
+
!notification.read && 'bg-primary/5'
|
|
146
|
+
)}
|
|
147
|
+
onClick={() => markAsRead(notification.id)}
|
|
148
|
+
>
|
|
149
|
+
<div className="flex gap-3">
|
|
150
|
+
<div className="shrink-0 mt-0.5">{getIcon(notification.type)}</div>
|
|
151
|
+
<div className="flex-1 min-w-0">
|
|
152
|
+
<div className="flex items-start justify-between gap-2">
|
|
153
|
+
<p
|
|
154
|
+
className={cn(
|
|
155
|
+
'text-sm font-semibold truncate',
|
|
156
|
+
!notification.read && 'text-foreground',
|
|
157
|
+
notification.read && 'text-muted-foreground'
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
{notification.title}
|
|
161
|
+
</p>
|
|
162
|
+
<span className="text-[10px] text-muted-foreground/60 shrink-0">
|
|
163
|
+
{formatTime(notification.timestamp)}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
|
167
|
+
{notification.message}
|
|
168
|
+
</p>
|
|
169
|
+
{notification.source && (
|
|
170
|
+
<span className="inline-block mt-1 text-[10px] font-mono bg-muted/50 px-1.5 py-0.5 rounded">
|
|
171
|
+
{notification.source}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Delete button on hover */}
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={(e) => {
|
|
181
|
+
e.stopPropagation()
|
|
182
|
+
removeNotification(notification.id)
|
|
183
|
+
}}
|
|
184
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 rounded-lg text-muted-foreground hover:text-red-500 transition-all"
|
|
185
|
+
>
|
|
186
|
+
<X size={14} />
|
|
187
|
+
</button>
|
|
188
|
+
|
|
189
|
+
{/* Unread indicator */}
|
|
190
|
+
{!notification.read && (
|
|
191
|
+
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-primary rounded-full" />
|
|
192
|
+
)}
|
|
193
|
+
</motion.div>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</motion.div>
|
|
199
|
+
)}
|
|
200
|
+
</AnimatePresence>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
2
|
+
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useNotifications } from '../contexts/NotificationContext'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
export function Toaster() {
|
|
8
|
+
const { notifications, removeNotification } = useNotifications()
|
|
9
|
+
const [activeIds, setActiveIds] = useState<Set<string>>(new Set())
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const now = Date.now()
|
|
13
|
+
// Check for new notifications to add to display
|
|
14
|
+
notifications.forEach((n) => {
|
|
15
|
+
if (!n.read && now - n.timestamp < 5000 && !activeIds.has(n.id)) {
|
|
16
|
+
setActiveIds((prev) => new Set(prev).add(n.id))
|
|
17
|
+
|
|
18
|
+
// Set timer to remove this specific ID
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
setActiveIds((prev) => {
|
|
21
|
+
const next = new Set(prev)
|
|
22
|
+
next.delete(n.id)
|
|
23
|
+
return next
|
|
24
|
+
})
|
|
25
|
+
}, 5000)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}, [notifications, activeIds])
|
|
29
|
+
|
|
30
|
+
const visibleNotifications = notifications.filter((n) => activeIds.has(n.id))
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="fixed bottom-8 right-8 z-[2000] flex flex-col gap-3 w-full max-w-sm pointer-events-none">
|
|
34
|
+
<AnimatePresence mode="popLayout">
|
|
35
|
+
{visibleNotifications.map((n) => (
|
|
36
|
+
<motion.div
|
|
37
|
+
key={n.id}
|
|
38
|
+
layout
|
|
39
|
+
initial={{ opacity: 0, x: 50, scale: 0.9 }}
|
|
40
|
+
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
41
|
+
exit={{ opacity: 0, scale: 0.8, x: 20 }}
|
|
42
|
+
className={cn(
|
|
43
|
+
'pointer-events-auto group relative flex items-start gap-4 p-4 rounded-2xl border shadow-2xl backdrop-blur-xl transition-all',
|
|
44
|
+
n.type === 'success' && 'bg-green-500/10 border-green-500/20 text-green-500',
|
|
45
|
+
n.type === 'error' && 'bg-red-500/10 border-red-500/20 text-red-500',
|
|
46
|
+
n.type === 'warning' && 'bg-yellow-500/10 border-yellow-500/20 text-yellow-500',
|
|
47
|
+
n.type === 'info' && 'bg-primary/10 border-primary/20 text-primary'
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<div className="flex-shrink-0 mt-0.5">
|
|
51
|
+
{n.type === 'success' && <CheckCircle2 size={18} />}
|
|
52
|
+
{n.type === 'error' && <AlertCircle size={18} />}
|
|
53
|
+
{n.type === 'warning' && <AlertCircle size={18} />}
|
|
54
|
+
{n.type === 'info' && <Info size={18} />}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex-1 min-w-0">
|
|
57
|
+
<h4 className="text-sm font-black tracking-tight leading-none mb-1">{n.title}</h4>
|
|
58
|
+
<p className="text-xs font-medium opacity-80 leading-relaxed break-words">
|
|
59
|
+
{n.message}
|
|
60
|
+
</p>
|
|
61
|
+
{n.source && (
|
|
62
|
+
<span className="inline-block mt-2 px-1.5 py-0.5 rounded bg-white/10 text-[9px] font-black uppercase tracking-widest">
|
|
63
|
+
{n.source}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => removeNotification(n.id)}
|
|
70
|
+
className="mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-white/10 rounded-lg"
|
|
71
|
+
>
|
|
72
|
+
<X size={14} />
|
|
73
|
+
</button>
|
|
74
|
+
<div className="absolute left-0 bottom-0 h-1 bg-current opacity-20 animate-toast-progress origin-left" />
|
|
75
|
+
</motion.div>
|
|
76
|
+
))}
|
|
77
|
+
</AnimatePresence>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|