@djangocfg/centrifugo 1.0.3 → 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.
- package/README.md +534 -83
- package/package.json +5 -5
- package/src/components/CentrifugoMonitor/CentrifugoMonitor.tsx +137 -0
- package/src/components/CentrifugoMonitor/CentrifugoMonitorDialog.tsx +64 -0
- package/src/components/CentrifugoMonitor/CentrifugoMonitorFAB.tsx +81 -0
- package/src/components/CentrifugoMonitor/CentrifugoMonitorWidget.tsx +74 -0
- package/src/components/CentrifugoMonitor/index.ts +14 -0
- package/src/components/ConnectionStatus/ConnectionStatus.tsx +192 -0
- package/src/components/ConnectionStatus/ConnectionStatusCard.tsx +56 -0
- package/src/components/ConnectionStatus/index.ts +9 -0
- package/src/components/MessagesFeed/MessageFilters.tsx +163 -0
- package/src/components/MessagesFeed/MessagesFeed.tsx +383 -0
- package/src/components/MessagesFeed/index.ts +9 -0
- package/src/components/MessagesFeed/types.ts +31 -0
- package/src/components/SubscriptionsList/SubscriptionsList.tsx +179 -0
- package/src/components/SubscriptionsList/index.ts +7 -0
- package/src/components/index.ts +18 -0
- package/src/core/client/CentrifugoRPCClient.ts +212 -15
- package/src/core/logger/createLogger.ts +26 -3
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useRPC.ts +149 -0
- package/src/hooks/useSubscription.ts +44 -10
- package/src/index.ts +3 -4
- package/src/providers/CentrifugoProvider/CentrifugoProvider.tsx +3 -20
|
@@ -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
|
+
|