@djangocfg/centrifugo 1.0.2 → 1.0.4

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Message Filters Component
3
+ *
4
+ * Filtering controls for MessagesFeed
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React from 'react';
10
+ import { Input, Badge, Button } from '@djangocfg/ui';
11
+ import { Search, Filter, X } from 'lucide-react';
12
+ import type { MessageFilters as MessageFiltersType } from './types';
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────
15
+ // Types
16
+ // ─────────────────────────────────────────────────────────────────────────
17
+
18
+ export interface MessageFiltersProps {
19
+ filters: MessageFiltersType;
20
+ onFiltersChange: (filters: MessageFiltersType) => void;
21
+ autoScroll?: boolean;
22
+ onAutoScrollChange?: (enabled: boolean) => void;
23
+ }
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────
26
+ // Component
27
+ // ─────────────────────────────────────────────────────────────────────────
28
+
29
+ export function MessageFilters({
30
+ filters,
31
+ onFiltersChange,
32
+ autoScroll,
33
+ onAutoScrollChange,
34
+ }: MessageFiltersProps) {
35
+ const hasActiveFilters =
36
+ (filters.channels && filters.channels.length > 0) ||
37
+ (filters.types && filters.types.length > 0) ||
38
+ (filters.levels && filters.levels.length > 0) ||
39
+ filters.searchQuery;
40
+
41
+ const handleClearFilters = () => {
42
+ onFiltersChange({});
43
+ };
44
+
45
+ const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
46
+ onFiltersChange({ ...filters, searchQuery: e.target.value });
47
+ };
48
+
49
+ const handleToggleLevel = (level: 'info' | 'success' | 'warning' | 'error') => {
50
+ const currentLevels = filters.levels || [];
51
+ const newLevels = currentLevels.includes(level)
52
+ ? currentLevels.filter((l) => l !== level)
53
+ : [...currentLevels, level];
54
+
55
+ onFiltersChange({
56
+ ...filters,
57
+ levels: newLevels.length > 0 ? newLevels : undefined,
58
+ });
59
+ };
60
+
61
+ const handleToggleType = (type: 'connection' | 'subscription' | 'publication' | 'error' | 'system' | 'unsubscription') => {
62
+ const currentTypes = filters.types || [];
63
+ const newTypes = currentTypes.includes(type)
64
+ ? currentTypes.filter((t) => t !== type)
65
+ : [...currentTypes, type];
66
+
67
+ onFiltersChange({
68
+ ...filters,
69
+ types: newTypes.length > 0 ? newTypes : undefined,
70
+ });
71
+ };
72
+
73
+ return (
74
+ <div className="space-y-3 p-3 border rounded-lg bg-muted/30">
75
+ {/* Header */}
76
+ <div className="flex items-center justify-between">
77
+ <div className="flex items-center gap-2">
78
+ <Filter className="h-4 w-4 text-muted-foreground" />
79
+ <span className="text-sm font-medium">Filters</span>
80
+ {hasActiveFilters && (
81
+ <Badge variant="secondary" className="text-xs">
82
+ Active
83
+ </Badge>
84
+ )}
85
+ </div>
86
+ {hasActiveFilters && (
87
+ <Button size="sm" variant="ghost" onClick={handleClearFilters}>
88
+ <X className="h-3 w-3 mr-1" />
89
+ Clear
90
+ </Button>
91
+ )}
92
+ </div>
93
+
94
+ {/* Search */}
95
+ <div className="flex items-center gap-2">
96
+ <Search className="h-4 w-4 text-muted-foreground" />
97
+ <Input
98
+ type="text"
99
+ placeholder="Search messages..."
100
+ value={filters.searchQuery || ''}
101
+ onChange={handleSearchChange}
102
+ className="flex-1"
103
+ />
104
+ </div>
105
+
106
+ {/* Level Filters */}
107
+ <div className="space-y-2">
108
+ <span className="text-xs text-muted-foreground">Level:</span>
109
+ <div className="flex flex-wrap gap-2">
110
+ {(['info', 'success', 'warning', 'error'] as const).map((level) => {
111
+ const isActive = filters.levels?.includes(level);
112
+ return (
113
+ <Badge
114
+ key={level}
115
+ variant={isActive ? 'default' : 'outline'}
116
+ className="cursor-pointer"
117
+ onClick={() => handleToggleLevel(level)}
118
+ >
119
+ {level}
120
+ </Badge>
121
+ );
122
+ })}
123
+ </div>
124
+ </div>
125
+
126
+ {/* Type Filters */}
127
+ <div className="space-y-2">
128
+ <span className="text-xs text-muted-foreground">Type:</span>
129
+ <div className="flex flex-wrap gap-2">
130
+ {(['connection', 'subscription', 'publication', 'unsubscription', 'error', 'system'] as const).map((type) => {
131
+ const isActive = filters.types?.includes(type);
132
+ return (
133
+ <Badge
134
+ key={type}
135
+ variant={isActive ? 'default' : 'outline'}
136
+ className="cursor-pointer"
137
+ onClick={() => handleToggleType(type)}
138
+ >
139
+ {type}
140
+ </Badge>
141
+ );
142
+ })}
143
+ </div>
144
+ </div>
145
+
146
+ {/* Options */}
147
+ {onAutoScrollChange && (
148
+ <div className="pt-2 border-t">
149
+ <label className="flex items-center gap-2 text-sm cursor-pointer">
150
+ <input
151
+ type="checkbox"
152
+ checked={autoScroll}
153
+ onChange={(e) => onAutoScrollChange(e.target.checked)}
154
+ className="h-4 w-4 rounded border-gray-300"
155
+ />
156
+ <span>Auto-scroll to latest</span>
157
+ </label>
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Messages Feed Component
3
+ *
4
+ * Universal component for displaying real-time Centrifugo messages
5
+ * Supports filtering, search, pause/play, and export
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
11
+ import {
12
+ Card,
13
+ CardContent,
14
+ CardHeader,
15
+ CardTitle,
16
+ Badge,
17
+ Button,
18
+ ScrollArea,
19
+ } from '@djangocfg/ui';
20
+ import {
21
+ Trash2,
22
+ Pause,
23
+ Play,
24
+ Download,
25
+ Activity,
26
+ AlertCircle,
27
+ CheckCircle2,
28
+ Info,
29
+ Circle,
30
+ } from 'lucide-react';
31
+ import moment from 'moment';
32
+ import { useCentrifugo } from '../../providers/CentrifugoProvider';
33
+ import { MessageFilters as MessageFiltersComponent } from './MessageFilters';
34
+ import type { CentrifugoMessage, MessageFilters, MessagesFeedProps } from './types';
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────
37
+ // Component
38
+ // ─────────────────────────────────────────────────────────────────────────
39
+
40
+ export function MessagesFeed({
41
+ maxMessages = 100,
42
+ showFilters = true,
43
+ showControls = true,
44
+ channels = [],
45
+ autoScroll: initialAutoScroll = true,
46
+ onMessageClick,
47
+ className = '',
48
+ }: MessagesFeedProps) {
49
+ const { isConnected, client } = useCentrifugo();
50
+ const [messages, setMessages] = useState<CentrifugoMessage[]>([]);
51
+ const [isPaused, setIsPaused] = useState(false);
52
+ const [autoScroll, setAutoScroll] = useState(initialAutoScroll);
53
+ const [filters, setFilters] = useState<MessageFilters>({
54
+ channels: channels.length > 0 ? channels : undefined,
55
+ });
56
+ const scrollRef = useRef<HTMLDivElement>(null);
57
+
58
+ // Add message
59
+ const addMessage = useCallback(
60
+ (message: CentrifugoMessage) => {
61
+ if (isPaused) return;
62
+
63
+ setMessages((prev) => {
64
+ const newMessages = [message, ...prev];
65
+ return newMessages.slice(0, maxMessages);
66
+ });
67
+ },
68
+ [isPaused, maxMessages]
69
+ );
70
+
71
+ // Listen to connection events
72
+ useEffect(() => {
73
+ if (!client) return;
74
+
75
+ const centrifuge = client.getCentrifuge();
76
+
77
+ const handleConnected = () => {
78
+ const now = moment.utc().valueOf();
79
+ addMessage({
80
+ id: `conn-${now}`,
81
+ timestamp: now,
82
+ type: 'connection',
83
+ level: 'success',
84
+ message: 'Connected to Centrifugo',
85
+ });
86
+ };
87
+
88
+ const handleDisconnected = () => {
89
+ const now = moment.utc().valueOf();
90
+ addMessage({
91
+ id: `disconn-${now}`,
92
+ timestamp: now,
93
+ type: 'connection',
94
+ level: 'error',
95
+ message: 'Disconnected from Centrifugo',
96
+ });
97
+ };
98
+
99
+ const handleError = (ctx: any) => {
100
+ const now = moment.utc().valueOf();
101
+ addMessage({
102
+ id: `error-${now}`,
103
+ timestamp: now,
104
+ type: 'error',
105
+ level: 'error',
106
+ message: ctx.error?.message || 'Connection error',
107
+ data: ctx,
108
+ });
109
+ };
110
+
111
+ centrifuge.on('connected', handleConnected);
112
+ centrifuge.on('disconnected', handleDisconnected);
113
+ centrifuge.on('error', handleError);
114
+
115
+ return () => {
116
+ centrifuge.off('connected', handleConnected);
117
+ centrifuge.off('disconnected', handleDisconnected);
118
+ centrifuge.off('error', handleError);
119
+ };
120
+ }, [client, addMessage]);
121
+
122
+ // Listen to subscription events
123
+ useEffect(() => {
124
+ if (!client) return;
125
+
126
+ const centrifuge = client.getCentrifuge();
127
+
128
+ const handleSubscribed = (ctx: any) => {
129
+ const now = moment.utc().valueOf();
130
+ addMessage({
131
+ id: `sub-${now}`,
132
+ timestamp: now,
133
+ type: 'subscription',
134
+ level: 'info',
135
+ channel: ctx.channel,
136
+ message: `Subscribed to ${ctx.channel}`,
137
+ data: ctx,
138
+ });
139
+ };
140
+
141
+ const handleUnsubscribed = (ctx: any) => {
142
+ const now = moment.utc().valueOf();
143
+ addMessage({
144
+ id: `unsub-${now}`,
145
+ timestamp: now,
146
+ type: 'unsubscription',
147
+ level: 'info',
148
+ channel: ctx.channel,
149
+ message: `Unsubscribed from ${ctx.channel}`,
150
+ data: ctx,
151
+ });
152
+ };
153
+
154
+ const handlePublication = (ctx: any) => {
155
+ const now = moment.utc().valueOf();
156
+ addMessage({
157
+ id: `pub-${now}-${Math.random()}`,
158
+ timestamp: now,
159
+ type: 'publication',
160
+ level: 'success',
161
+ channel: ctx.channel,
162
+ message: `Message from ${ctx.channel}`,
163
+ data: ctx.data,
164
+ });
165
+ };
166
+
167
+ centrifuge.on('subscribed', handleSubscribed);
168
+ centrifuge.on('unsubscribed', handleUnsubscribed);
169
+ centrifuge.on('publication', handlePublication);
170
+
171
+ return () => {
172
+ centrifuge.off('subscribed', handleSubscribed);
173
+ centrifuge.off('unsubscribed', handleUnsubscribed);
174
+ centrifuge.off('publication', handlePublication);
175
+ };
176
+ }, [client, addMessage]);
177
+
178
+ // Auto-scroll to top when new messages arrive
179
+ useEffect(() => {
180
+ if (autoScroll && scrollRef.current) {
181
+ scrollRef.current.scrollTop = 0;
182
+ }
183
+ }, [messages, autoScroll]);
184
+
185
+ // Filter messages
186
+ const filteredMessages = messages.filter((msg) => {
187
+ if (filters.channels && filters.channels.length > 0) {
188
+ if (!msg.channel || !filters.channels.includes(msg.channel)) {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ if (filters.types && filters.types.length > 0) {
194
+ if (!filters.types.includes(msg.type)) {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ if (filters.levels && filters.levels.length > 0) {
200
+ if (!filters.levels.includes(msg.level)) {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ if (filters.searchQuery) {
206
+ const query = filters.searchQuery.toLowerCase();
207
+ const searchableText = [
208
+ msg.message,
209
+ msg.channel,
210
+ JSON.stringify(msg.data),
211
+ ]
212
+ .filter(Boolean)
213
+ .join(' ')
214
+ .toLowerCase();
215
+
216
+ if (!searchableText.includes(query)) {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ return true;
222
+ });
223
+
224
+ // Clear messages
225
+ const handleClear = () => {
226
+ setMessages([]);
227
+ };
228
+
229
+ // Download messages
230
+ const handleDownload = () => {
231
+ const json = JSON.stringify(filteredMessages, null, 2);
232
+ const blob = new Blob([json], { type: 'application/json' });
233
+ const url = URL.createObjectURL(blob);
234
+ const a = document.createElement('a');
235
+ a.href = url;
236
+ a.download = `centrifugo-messages-${moment.utc().format('YYYY-MM-DD-HHmmss')}.json`;
237
+ document.body.appendChild(a);
238
+ a.click();
239
+ document.body.removeChild(a);
240
+ URL.revokeObjectURL(url);
241
+ };
242
+
243
+ // Get icon for message level
244
+ const getLevelIcon = (level: CentrifugoMessage['level']) => {
245
+ switch (level) {
246
+ case 'error':
247
+ return <AlertCircle className="h-4 w-4 text-red-500" />;
248
+ case 'warning':
249
+ return <AlertCircle className="h-4 w-4 text-yellow-500" />;
250
+ case 'success':
251
+ return <CheckCircle2 className="h-4 w-4 text-green-500" />;
252
+ case 'info':
253
+ return <Info className="h-4 w-4 text-blue-500" />;
254
+ default:
255
+ return <Circle className="h-4 w-4 text-gray-500" />;
256
+ }
257
+ };
258
+
259
+ // Get badge variant for level
260
+ const getLevelVariant = (level: CentrifugoMessage['level']): 'default' | 'destructive' | 'outline' | 'secondary' => {
261
+ switch (level) {
262
+ case 'error':
263
+ return 'destructive';
264
+ case 'warning':
265
+ return 'secondary';
266
+ case 'success':
267
+ return 'outline';
268
+ default:
269
+ return 'default';
270
+ }
271
+ };
272
+
273
+ return (
274
+ <Card className={className}>
275
+ <CardHeader>
276
+ <div className="flex items-center justify-between">
277
+ <CardTitle className="flex items-center gap-2">
278
+ <Activity className="h-5 w-5" />
279
+ Messages Feed
280
+ <Badge variant="outline">{filteredMessages.length}</Badge>
281
+ </CardTitle>
282
+
283
+ {showControls && (
284
+ <div className="flex items-center gap-2">
285
+ <Button
286
+ size="sm"
287
+ variant="outline"
288
+ onClick={() => setIsPaused(!isPaused)}
289
+ >
290
+ {isPaused ? (
291
+ <Play className="h-4 w-4" />
292
+ ) : (
293
+ <Pause className="h-4 w-4" />
294
+ )}
295
+ </Button>
296
+ <Button
297
+ size="sm"
298
+ variant="outline"
299
+ onClick={handleClear}
300
+ disabled={messages.length === 0}
301
+ >
302
+ <Trash2 className="h-4 w-4" />
303
+ </Button>
304
+ <Button
305
+ size="sm"
306
+ variant="outline"
307
+ onClick={handleDownload}
308
+ disabled={filteredMessages.length === 0}
309
+ >
310
+ <Download className="h-4 w-4" />
311
+ </Button>
312
+ </div>
313
+ )}
314
+ </div>
315
+ </CardHeader>
316
+
317
+ <CardContent className="space-y-4">
318
+ {showFilters && (
319
+ <MessageFiltersComponent
320
+ filters={filters}
321
+ onFiltersChange={setFilters}
322
+ autoScroll={autoScroll}
323
+ onAutoScrollChange={setAutoScroll}
324
+ />
325
+ )}
326
+
327
+ <ScrollArea className="h-[400px]" ref={scrollRef}>
328
+ {filteredMessages.length === 0 ? (
329
+ <div className="flex flex-col items-center justify-center py-12 text-center">
330
+ <Activity className="h-12 w-12 text-muted-foreground mb-4" />
331
+ <p className="text-sm text-muted-foreground">
332
+ {isPaused ? 'Paused - Click play to resume' : 'No messages yet'}
333
+ </p>
334
+ </div>
335
+ ) : (
336
+ <div className="space-y-2">
337
+ {filteredMessages.map((msg) => (
338
+ <div
339
+ key={msg.id}
340
+ className="p-3 rounded border hover:bg-muted/50 transition-colors cursor-pointer"
341
+ onClick={() => onMessageClick?.(msg)}
342
+ >
343
+ <div className="flex items-start gap-3">
344
+ <div className="flex-shrink-0 mt-0.5">{getLevelIcon(msg.level)}</div>
345
+ <div className="flex-1 min-w-0 space-y-1">
346
+ <div className="flex items-center gap-2 flex-wrap">
347
+ <Badge variant={getLevelVariant(msg.level)} className="text-xs">
348
+ {msg.type}
349
+ </Badge>
350
+ {msg.channel && (
351
+ <Badge variant="outline" className="text-xs">
352
+ {msg.channel}
353
+ </Badge>
354
+ )}
355
+ <span className="text-xs text-muted-foreground">
356
+ {moment.utc(msg.timestamp).format('HH:mm:ss')}
357
+ </span>
358
+ </div>
359
+ {msg.message && (
360
+ <p className="text-sm break-words">{msg.message}</p>
361
+ )}
362
+ {msg.data && (
363
+ <details className="text-xs">
364
+ <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
365
+ View data
366
+ </summary>
367
+ <pre className="mt-2 p-2 bg-muted rounded overflow-x-auto">
368
+ {JSON.stringify(msg.data, null, 2)}
369
+ </pre>
370
+ </details>
371
+ )}
372
+ </div>
373
+ </div>
374
+ </div>
375
+ ))}
376
+ </div>
377
+ )}
378
+ </ScrollArea>
379
+ </CardContent>
380
+ </Card>
381
+ );
382
+ }
383
+
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Messages Feed Components
3
+ */
4
+
5
+ export { MessagesFeed } from './MessagesFeed';
6
+ export { MessageFilters } from './MessageFilters';
7
+ export type { CentrifugoMessage, MessageFilters as MessageFiltersType, MessagesFeedProps } from './types';
8
+ export type { MessageFiltersProps } from './MessageFilters';
9
+
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Messages Feed Types
3
+ */
4
+
5
+ export interface CentrifugoMessage {
6
+ id: string;
7
+ timestamp: number;
8
+ type: 'connection' | 'subscription' | 'publication' | 'error' | 'system' | 'unsubscription';
9
+ channel?: string;
10
+ data?: any;
11
+ level: 'info' | 'success' | 'warning' | 'error';
12
+ message?: string;
13
+ }
14
+
15
+ export interface MessageFilters {
16
+ channels?: string[];
17
+ types?: CentrifugoMessage['type'][];
18
+ levels?: CentrifugoMessage['level'][];
19
+ searchQuery?: string;
20
+ }
21
+
22
+ export interface MessagesFeedProps {
23
+ maxMessages?: number;
24
+ showFilters?: boolean;
25
+ showControls?: boolean;
26
+ channels?: string[]; // Pre-filter by channels
27
+ autoScroll?: boolean;
28
+ onMessageClick?: (message: CentrifugoMessage) => void;
29
+ className?: string;
30
+ }
31
+