queen-mq 0.1.0
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/API.md +1116 -0
- package/CACHE.md +519 -0
- package/DASHBOARD-V3.md +478 -0
- package/DASHBOARD.md +382 -0
- package/MOD_QUEUE.md +453 -0
- package/PARTITION_LOCKING_DESIGN.md +989 -0
- package/PLAN.md +707 -0
- package/QUERY_ANALSYS.md +72 -0
- package/QUEUE_BUS.md +334 -0
- package/README.md +1495 -0
- package/V2-PLAN.md +236 -0
- package/assets/dashboard.png +0 -0
- package/dashboard/.vscode/extensions.json +3 -0
- package/dashboard/README.md +5 -0
- package/dashboard/index.html +14 -0
- package/dashboard/package-lock.json +1458 -0
- package/dashboard/package.json +25 -0
- package/dashboard/public/vite.svg +1 -0
- package/dashboard/src/App.vue +29 -0
- package/dashboard/src/assets/styles/main.css +908 -0
- package/dashboard/src/assets/vue.svg +1 -0
- package/dashboard/src/components/cards/MetricCard.vue +298 -0
- package/dashboard/src/components/charts/QueueDepthChart.vue +276 -0
- package/dashboard/src/components/charts/QueueLagChart.vue +436 -0
- package/dashboard/src/components/charts/ThroughputChart.vue +302 -0
- package/dashboard/src/components/common/ActivityFeed.vue +251 -0
- package/dashboard/src/components/layout/AppHeader.vue +208 -0
- package/dashboard/src/components/layout/AppLayout.vue +88 -0
- package/dashboard/src/components/layout/AppSidebar.vue +261 -0
- package/dashboard/src/main.js +44 -0
- package/dashboard/src/router.js +54 -0
- package/dashboard/src/services/api.js +187 -0
- package/dashboard/src/services/websocket.js +167 -0
- package/dashboard/src/utils/constants.js +56 -0
- package/dashboard/src/utils/helpers.js +118 -0
- package/dashboard/src/views/Analytics.vue +912 -0
- package/dashboard/src/views/Dashboard.vue +906 -0
- package/dashboard/src/views/Messages.vue +437 -0
- package/dashboard/src/views/QueueDetail.vue +501 -0
- package/dashboard/src/views/Queues.vue +333 -0
- package/dashboard/vite.config.js +30 -0
- package/debug-namespace.js +110 -0
- package/docs/long-polling.md +159 -0
- package/docs/multi-server-cache-solutions.md +185 -0
- package/docs/performance-tuning.md +222 -0
- package/examples/bus-mode.js +239 -0
- package/examples/continuous-consumer-optimized.js +215 -0
- package/examples/continuous-consumer.js +159 -0
- package/examples/continuous-producer.js +343 -0
- package/examples/mixed-mode.js +277 -0
- package/examples/multi-server-test.js +305 -0
- package/examples/single.js +64 -0
- package/examples/smartchat-dealyed.js +42 -0
- package/examples/smartchat.js +52 -0
- package/examples/test-cache-invalidation.js +119 -0
- package/examples/test-cache-multi-server.js +245 -0
- package/examples/test-minimal-client.js +112 -0
- package/examples/test-queue-creation-policy.js +137 -0
- package/init-db.js +20 -0
- package/package.json +36 -0
- package/src/client/client.js +291 -0
- package/src/client/index.js +6 -0
- package/src/client/queenClient.js +513 -0
- package/src/client/utils/http.js +172 -0
- package/src/client/utils/loadBalancer.js +152 -0
- package/src/client/utils/retry.js +35 -0
- package/src/config.js +215 -0
- package/src/database/connection.js +103 -0
- package/src/database/poolManager.js +192 -0
- package/src/database/schema-v2.sql +214 -0
- package/src/managers/eventManager.js +59 -0
- package/src/managers/queueManagerOptimized.js +1512 -0
- package/src/managers/resourceCache.js +96 -0
- package/src/managers/systemEventManager.js +127 -0
- package/src/routes/ack.js +26 -0
- package/src/routes/analytics.js +812 -0
- package/src/routes/configure.js +46 -0
- package/src/routes/messages.js +298 -0
- package/src/routes/pop.js +85 -0
- package/src/routes/push.js +28 -0
- package/src/routes/resources.js +296 -0
- package/src/server.js +1286 -0
- package/src/services/encryptionService.js +82 -0
- package/src/services/evictionService.js +131 -0
- package/src/services/retentionService.js +129 -0
- package/src/services/startupSync.js +35 -0
- package/src/test/test.js +4521 -0
- package/src/utils/logger.js +44 -0
- package/src/utils/uuid.js +5 -0
- package/src/websocket/wsServer.js +221 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="analytics-view">
|
|
3
|
+
<!-- Date/Time Filter Bar -->
|
|
4
|
+
<div class="filter-bar">
|
|
5
|
+
<div class="filter-group">
|
|
6
|
+
<label>Time Range:</label>
|
|
7
|
+
<Select
|
|
8
|
+
v-model="selectedTimeRange"
|
|
9
|
+
:options="timeRangeOptions"
|
|
10
|
+
optionLabel="label"
|
|
11
|
+
optionValue="value"
|
|
12
|
+
placeholder="Select time range"
|
|
13
|
+
@change="onTimeRangeChange"
|
|
14
|
+
/>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="filter-group" v-if="selectedTimeRange === 'custom'">
|
|
17
|
+
<label>From:</label>
|
|
18
|
+
<Calendar
|
|
19
|
+
v-model="fromDateTime"
|
|
20
|
+
showTime
|
|
21
|
+
:showIcon="true"
|
|
22
|
+
dateFormat="yy-mm-dd"
|
|
23
|
+
placeholder="Start date/time"
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="filter-group" v-if="selectedTimeRange === 'custom'">
|
|
27
|
+
<label>To:</label>
|
|
28
|
+
<Calendar
|
|
29
|
+
v-model="toDateTime"
|
|
30
|
+
showTime
|
|
31
|
+
:showIcon="true"
|
|
32
|
+
dateFormat="yy-mm-dd"
|
|
33
|
+
placeholder="End date/time"
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="filter-group">
|
|
37
|
+
<label>Queue:</label>
|
|
38
|
+
<Select
|
|
39
|
+
v-model="selectedQueue"
|
|
40
|
+
:options="queueOptions || []"
|
|
41
|
+
optionLabel="label"
|
|
42
|
+
optionValue="value"
|
|
43
|
+
placeholder="All queues"
|
|
44
|
+
:showClear="true"
|
|
45
|
+
@change="fetchAnalytics"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="filter-group">
|
|
49
|
+
<label>Namespace:</label>
|
|
50
|
+
<Select
|
|
51
|
+
v-model="selectedNamespace"
|
|
52
|
+
:options="namespaceOptions || []"
|
|
53
|
+
optionLabel="label"
|
|
54
|
+
optionValue="value"
|
|
55
|
+
placeholder="All namespaces"
|
|
56
|
+
:showClear="true"
|
|
57
|
+
@change="fetchAnalytics"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="filter-group">
|
|
61
|
+
<Button
|
|
62
|
+
label="Apply Filters"
|
|
63
|
+
icon="pi pi-filter"
|
|
64
|
+
@click="fetchAnalytics"
|
|
65
|
+
:loading="loading"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="analytics-grid">
|
|
70
|
+
<div class="chart-container">
|
|
71
|
+
<div class="chart-header">
|
|
72
|
+
<h3 class="chart-title">System Throughput (Last Hour)</h3>
|
|
73
|
+
<div class="chart-actions">
|
|
74
|
+
<div class="realtime-indicator" v-tooltip="'Real-time updates via WebSocket'">
|
|
75
|
+
<i class="pi pi-circle-fill realtime-dot"></i>
|
|
76
|
+
<span class="realtime-text">Live</span>
|
|
77
|
+
</div>
|
|
78
|
+
<Button
|
|
79
|
+
icon="pi pi-refresh"
|
|
80
|
+
class="p-button-text p-button-sm"
|
|
81
|
+
@click="fetchAnalytics"
|
|
82
|
+
:loading="loading"
|
|
83
|
+
v-tooltip="'Refresh'"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<ThroughputChart :data="throughputData" :loading="loading" />
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="card-v3">
|
|
91
|
+
<h3 class="card-title">Queue Performance</h3>
|
|
92
|
+
<DataTable :value="queueStats" :loading="loading" size="small" class="dark-table-v3">
|
|
93
|
+
<Column field="queue" header="Queue" />
|
|
94
|
+
<Column field="throughput" header="Throughput">
|
|
95
|
+
<template #body="{ data }">
|
|
96
|
+
<span class="metric-value">{{ data.throughput }} msg/min</span>
|
|
97
|
+
</template>
|
|
98
|
+
</Column>
|
|
99
|
+
<Column field="successRate" header="Success Rate">
|
|
100
|
+
<template #body="{ data }">
|
|
101
|
+
<Tag :value="`${data.successRate}%`" :severity="getSuccessRateSeverity(data.successRate)" />
|
|
102
|
+
</template>
|
|
103
|
+
</Column>
|
|
104
|
+
</DataTable>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<!-- Queue Lag Analysis -->
|
|
108
|
+
<div class="lag-chart-wrapper full-width">
|
|
109
|
+
<QueueLagChart />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<script setup>
|
|
116
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
117
|
+
import { useToast } from 'primevue/usetoast'
|
|
118
|
+
import DataTable from 'primevue/datatable'
|
|
119
|
+
import Column from 'primevue/column'
|
|
120
|
+
import Button from 'primevue/button'
|
|
121
|
+
import Tag from 'primevue/tag'
|
|
122
|
+
import Select from 'primevue/select'
|
|
123
|
+
import Calendar from 'primevue/calendar'
|
|
124
|
+
import ThroughputChart from '../components/charts/ThroughputChart.vue'
|
|
125
|
+
import QueueLagChart from '../components/charts/QueueLagChart.vue'
|
|
126
|
+
import api from '../services/api.js'
|
|
127
|
+
import websocket from '../services/websocket.js'
|
|
128
|
+
|
|
129
|
+
const toast = useToast()
|
|
130
|
+
const loading = ref(false)
|
|
131
|
+
|
|
132
|
+
// Filter state
|
|
133
|
+
const selectedTimeRange = ref('1h')
|
|
134
|
+
const fromDateTime = ref(null)
|
|
135
|
+
const toDateTime = ref(null)
|
|
136
|
+
const selectedQueue = ref(null)
|
|
137
|
+
const selectedNamespace = ref(null)
|
|
138
|
+
const selectedTask = ref(null)
|
|
139
|
+
|
|
140
|
+
// Filter options
|
|
141
|
+
const timeRangeOptions = ref([
|
|
142
|
+
{ label: 'Last Hour', value: '1h' },
|
|
143
|
+
{ label: 'Last 6 Hours', value: '6h' },
|
|
144
|
+
{ label: 'Last 24 Hours', value: '24h' },
|
|
145
|
+
{ label: 'Last 7 Days', value: '7d' },
|
|
146
|
+
{ label: 'Last 30 Days', value: '30d' },
|
|
147
|
+
{ label: 'Custom Range', value: 'custom' }
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
const queueOptions = ref([])
|
|
151
|
+
const namespaceOptions = ref([])
|
|
152
|
+
|
|
153
|
+
// Data
|
|
154
|
+
const throughputData = ref({
|
|
155
|
+
labels: [],
|
|
156
|
+
datasets: []
|
|
157
|
+
})
|
|
158
|
+
const queueStats = ref([])
|
|
159
|
+
const systemMetrics = ref({})
|
|
160
|
+
const topQueues = ref([])
|
|
161
|
+
const errorSummary = ref([])
|
|
162
|
+
|
|
163
|
+
// Real-time data tracking
|
|
164
|
+
const realtimeMetrics = ref({
|
|
165
|
+
currentMinuteIncoming: 0,
|
|
166
|
+
currentMinuteCompleted: 0,
|
|
167
|
+
currentMinuteFailed: 0,
|
|
168
|
+
currentMinuteDeadLetter: 0,
|
|
169
|
+
lastMinuteTimestamp: null
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Queue depth cache for smooth updates
|
|
173
|
+
const queueDepthCache = ref(new Map())
|
|
174
|
+
|
|
175
|
+
// Smooth update functions
|
|
176
|
+
const updateThroughputDataPoint = (timestamp, incoming = 0, completed = 0, failed = 0, deadLetter = 0) => {
|
|
177
|
+
const currentData = throughputData.value
|
|
178
|
+
if (!currentData.labels || currentData.labels.length === 0) return
|
|
179
|
+
|
|
180
|
+
const timeLabel = new Date(timestamp).toLocaleTimeString('en-US', {
|
|
181
|
+
hour: 'numeric',
|
|
182
|
+
minute: '2-digit'
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// Add new data point and remove oldest if we have more than 60 points
|
|
186
|
+
currentData.labels.push(timeLabel)
|
|
187
|
+
currentData.datasets[0].data.push(incoming)
|
|
188
|
+
currentData.datasets[1].data.push(completed)
|
|
189
|
+
currentData.datasets[2].data.push(failed)
|
|
190
|
+
currentData.datasets[3].data.push(deadLetter)
|
|
191
|
+
|
|
192
|
+
// Keep only last 60 data points for smooth scrolling
|
|
193
|
+
if (currentData.labels.length > 60) {
|
|
194
|
+
currentData.labels.shift()
|
|
195
|
+
currentData.datasets.forEach(dataset => dataset.data.shift())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Trigger reactivity
|
|
199
|
+
throughputData.value = { ...currentData }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const smoothUpdateQueueStats = (queueName, newStats) => {
|
|
203
|
+
const currentStats = queueStats.value
|
|
204
|
+
const existingIndex = currentStats.findIndex(q => q.queue === queueName)
|
|
205
|
+
|
|
206
|
+
if (existingIndex >= 0) {
|
|
207
|
+
// Smooth transition for existing queue
|
|
208
|
+
const existing = currentStats[existingIndex]
|
|
209
|
+
const updated = {
|
|
210
|
+
...existing,
|
|
211
|
+
throughput: Math.round(newStats.completed / 60),
|
|
212
|
+
successRate: newStats.total > 0 ? ((newStats.completed / newStats.total) * 100).toFixed(1) : existing.successRate
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Only update if values actually changed to avoid unnecessary re-renders
|
|
216
|
+
if (updated.throughput !== existing.throughput || updated.successRate !== existing.successRate) {
|
|
217
|
+
currentStats[existingIndex] = updated
|
|
218
|
+
queueStats.value = [...currentStats]
|
|
219
|
+
}
|
|
220
|
+
} else if (newStats.total > 0) {
|
|
221
|
+
// Add new queue
|
|
222
|
+
currentStats.push({
|
|
223
|
+
queue: queueName,
|
|
224
|
+
throughput: Math.round(newStats.completed / 60),
|
|
225
|
+
successRate: newStats.total > 0 ? ((newStats.completed / newStats.total) * 100).toFixed(1) : 100
|
|
226
|
+
})
|
|
227
|
+
queueStats.value = currentStats.sort((a, b) => b.throughput - a.throughput).slice(0, 10)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const incrementRealtimeMetrics = (eventType) => {
|
|
232
|
+
const now = new Date()
|
|
233
|
+
const currentMinute = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes())
|
|
234
|
+
|
|
235
|
+
// Reset counters if we've moved to a new minute
|
|
236
|
+
if (!realtimeMetrics.value.lastMinuteTimestamp ||
|
|
237
|
+
realtimeMetrics.value.lastMinuteTimestamp.getTime() !== currentMinute.getTime()) {
|
|
238
|
+
|
|
239
|
+
// If we have data from the previous minute, add it to the chart
|
|
240
|
+
if (realtimeMetrics.value.lastMinuteTimestamp) {
|
|
241
|
+
updateThroughputDataPoint(
|
|
242
|
+
realtimeMetrics.value.lastMinuteTimestamp,
|
|
243
|
+
realtimeMetrics.value.currentMinuteIncoming,
|
|
244
|
+
realtimeMetrics.value.currentMinuteCompleted,
|
|
245
|
+
realtimeMetrics.value.currentMinuteFailed,
|
|
246
|
+
realtimeMetrics.value.currentMinuteDeadLetter
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Reset for new minute
|
|
251
|
+
realtimeMetrics.value.currentMinuteIncoming = 0
|
|
252
|
+
realtimeMetrics.value.currentMinuteCompleted = 0
|
|
253
|
+
realtimeMetrics.value.currentMinuteFailed = 0
|
|
254
|
+
realtimeMetrics.value.currentMinuteDeadLetter = 0
|
|
255
|
+
realtimeMetrics.value.lastMinuteTimestamp = currentMinute
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Increment appropriate counter
|
|
259
|
+
switch (eventType) {
|
|
260
|
+
case 'message.pushed':
|
|
261
|
+
realtimeMetrics.value.currentMinuteIncoming++
|
|
262
|
+
break
|
|
263
|
+
case 'message.completed':
|
|
264
|
+
realtimeMetrics.value.currentMinuteCompleted++
|
|
265
|
+
break
|
|
266
|
+
case 'message.failed':
|
|
267
|
+
realtimeMetrics.value.currentMinuteFailed++
|
|
268
|
+
break
|
|
269
|
+
case 'message.dead_letter':
|
|
270
|
+
realtimeMetrics.value.currentMinuteDeadLetter++
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// WebSocket event handlers
|
|
276
|
+
const handleQueueDepthUpdate = (data) => {
|
|
277
|
+
// Update queue depth cache
|
|
278
|
+
queueDepthCache.value.set(data.queue, data)
|
|
279
|
+
|
|
280
|
+
// Calculate aggregated stats for this queue
|
|
281
|
+
const totalStats = {
|
|
282
|
+
pending: data.totalDepth || 0,
|
|
283
|
+
processing: data.totalProcessing || 0,
|
|
284
|
+
completed: 0,
|
|
285
|
+
failed: 0,
|
|
286
|
+
total: 0
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Add partition stats if available
|
|
290
|
+
if (data.partitions) {
|
|
291
|
+
Object.values(data.partitions).forEach(partition => {
|
|
292
|
+
totalStats.completed += partition.completed || 0
|
|
293
|
+
totalStats.failed += partition.failed || 0
|
|
294
|
+
totalStats.total += (partition.depth || 0) + (partition.processing || 0) + (partition.completed || 0) + (partition.failed || 0)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Smooth update queue stats
|
|
299
|
+
smoothUpdateQueueStats(data.queue, totalStats)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const handleSystemStats = (data) => {
|
|
303
|
+
// Smooth update system metrics without full replacement
|
|
304
|
+
const current = systemMetrics.value
|
|
305
|
+
systemMetrics.value = {
|
|
306
|
+
...current,
|
|
307
|
+
connections: data.connections || current.connections || 0,
|
|
308
|
+
// Only update if significantly different to avoid jitter
|
|
309
|
+
requestRate: Math.abs((data.recentCreated || 0) - (current.requestRate || 0)) > 5
|
|
310
|
+
? data.recentCreated || 0
|
|
311
|
+
: current.requestRate || 0,
|
|
312
|
+
latency: current.latency || 0, // Keep existing latency
|
|
313
|
+
errorRate: current.errorRate || 0, // Keep existing error rate
|
|
314
|
+
memory: current.memory || 0, // Keep existing memory
|
|
315
|
+
uptime: current.uptime || 0 // Keep existing uptime
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const handleWebSocketMessage = (wsMessage) => {
|
|
320
|
+
const { event, data } = wsMessage
|
|
321
|
+
|
|
322
|
+
switch (event) {
|
|
323
|
+
case 'queue.depth':
|
|
324
|
+
handleQueueDepthUpdate(data)
|
|
325
|
+
break
|
|
326
|
+
case 'system.stats':
|
|
327
|
+
handleSystemStats(data)
|
|
328
|
+
break
|
|
329
|
+
case 'message.pushed':
|
|
330
|
+
case 'message.completed':
|
|
331
|
+
case 'message.failed':
|
|
332
|
+
case 'message.dead_letter':
|
|
333
|
+
incrementRealtimeMetrics(event)
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Fetch analytics data
|
|
339
|
+
const fetchAnalytics = async (silent = false) => {
|
|
340
|
+
try {
|
|
341
|
+
// Only show loading indicator for initial load, not for periodic refreshes
|
|
342
|
+
if (!silent) {
|
|
343
|
+
loading.value = true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Build filter parameters
|
|
347
|
+
const filters = {}
|
|
348
|
+
|
|
349
|
+
// Handle time range
|
|
350
|
+
if (selectedTimeRange.value === 'custom') {
|
|
351
|
+
if (fromDateTime.value) filters.fromDateTime = fromDateTime.value.toISOString()
|
|
352
|
+
if (toDateTime.value) filters.toDateTime = toDateTime.value.toISOString()
|
|
353
|
+
} else {
|
|
354
|
+
// Calculate from/to based on selected range
|
|
355
|
+
const now = new Date()
|
|
356
|
+
const from = new Date()
|
|
357
|
+
|
|
358
|
+
switch (selectedTimeRange.value) {
|
|
359
|
+
case '1h':
|
|
360
|
+
from.setHours(from.getHours() - 1)
|
|
361
|
+
break
|
|
362
|
+
case '6h':
|
|
363
|
+
from.setHours(from.getHours() - 6)
|
|
364
|
+
break
|
|
365
|
+
case '24h':
|
|
366
|
+
from.setDate(from.getDate() - 1)
|
|
367
|
+
break
|
|
368
|
+
case '7d':
|
|
369
|
+
from.setDate(from.getDate() - 7)
|
|
370
|
+
break
|
|
371
|
+
case '30d':
|
|
372
|
+
from.setDate(from.getDate() - 30)
|
|
373
|
+
break
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
filters.fromDateTime = from.toISOString()
|
|
377
|
+
filters.toDateTime = now.toISOString()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Add other filters
|
|
381
|
+
if (selectedQueue.value) filters.queue = selectedQueue.value
|
|
382
|
+
if (selectedNamespace.value) filters.namespace = selectedNamespace.value
|
|
383
|
+
if (selectedTask.value) filters.task = selectedTask.value
|
|
384
|
+
|
|
385
|
+
// Fetch throughput data with filters
|
|
386
|
+
const throughput = await api.getThroughput(filters)
|
|
387
|
+
processThroughputData(throughput?.throughput || [])
|
|
388
|
+
|
|
389
|
+
// Fetch real queue stats with filters
|
|
390
|
+
try {
|
|
391
|
+
const queueStatsData = await api.getQueueStats(filters)
|
|
392
|
+
if (queueStatsData && Array.isArray(queueStatsData) && queueStatsData.length > 0) {
|
|
393
|
+
// Process the array of queue-partition data and aggregate by queue
|
|
394
|
+
const queueMap = new Map()
|
|
395
|
+
|
|
396
|
+
queueStatsData.forEach(item => {
|
|
397
|
+
const queueName = item.queue
|
|
398
|
+
const stats = item.stats
|
|
399
|
+
|
|
400
|
+
if (!queueMap.has(queueName)) {
|
|
401
|
+
queueMap.set(queueName, {
|
|
402
|
+
queue: queueName,
|
|
403
|
+
pending: 0,
|
|
404
|
+
processing: 0,
|
|
405
|
+
completed: 0,
|
|
406
|
+
failed: 0,
|
|
407
|
+
total: 0
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const aggregated = queueMap.get(queueName)
|
|
412
|
+
aggregated.pending += stats.pending || 0
|
|
413
|
+
aggregated.processing += stats.processing || 0
|
|
414
|
+
aggregated.completed += stats.completed || 0
|
|
415
|
+
aggregated.failed += stats.failed || 0
|
|
416
|
+
aggregated.total += stats.total || 0
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// Convert to array and calculate throughput and success rate
|
|
420
|
+
queueStats.value = Array.from(queueMap.values())
|
|
421
|
+
.filter(queue => queue.total > 0) // Only show queues with activity
|
|
422
|
+
.map(queue => ({
|
|
423
|
+
queue: queue.queue,
|
|
424
|
+
throughput: Math.round(queue.completed / 60), // Rough throughput estimate (completed per minute)
|
|
425
|
+
successRate: queue.total > 0 ? ((queue.completed / queue.total) * 100).toFixed(1) : 0
|
|
426
|
+
}))
|
|
427
|
+
.sort((a, b) => b.throughput - a.throughput) // Sort by throughput descending
|
|
428
|
+
.slice(0, 10) // Limit to top 10
|
|
429
|
+
} else {
|
|
430
|
+
// Fallback: try to get queue data and calculate stats
|
|
431
|
+
const queues = await api.getQueues()
|
|
432
|
+
if (queues?.queues && queues.queues.length > 0) {
|
|
433
|
+
queueStats.value = queues.queues
|
|
434
|
+
.filter(queue => queue.stats && (queue.stats.completed > 0 || queue.stats.pending > 0))
|
|
435
|
+
.map(queue => ({
|
|
436
|
+
queue: queue.name,
|
|
437
|
+
throughput: Math.round((queue.stats.completed || 0) / 60), // Convert to per minute
|
|
438
|
+
successRate: queue.stats.total > 0 ? ((queue.stats.completed / queue.stats.total) * 100).toFixed(1) : 100
|
|
439
|
+
}))
|
|
440
|
+
.slice(0, 10) // Limit to top 10
|
|
441
|
+
} else {
|
|
442
|
+
// Final fallback to mock data
|
|
443
|
+
queueStats.value = [
|
|
444
|
+
{ queue: 'email-queue', throughput: 120, successRate: 98.5 },
|
|
445
|
+
{ queue: 'notification-queue', throughput: 85, successRate: 99.2 },
|
|
446
|
+
{ queue: 'analytics-queue', throughput: 200, successRate: 95.8 },
|
|
447
|
+
{ queue: 'payment-queue', throughput: 45, successRate: 99.9 }
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch (error) {
|
|
452
|
+
console.error('Failed to fetch queue stats:', error)
|
|
453
|
+
// Show empty state on error
|
|
454
|
+
queueStats.value = []
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Fetch real system metrics from API
|
|
458
|
+
try {
|
|
459
|
+
const health = await api.getHealth()
|
|
460
|
+
systemMetrics.value = {
|
|
461
|
+
connections: health?.connections || 0,
|
|
462
|
+
memory: health?.memory || 0,
|
|
463
|
+
requestRate: health?.requestRate || 0,
|
|
464
|
+
latency: health?.latency || 0,
|
|
465
|
+
errorRate: health?.errorRate || 0,
|
|
466
|
+
uptime: parseInt(health?.uptime) || 0
|
|
467
|
+
}
|
|
468
|
+
} catch (error) {
|
|
469
|
+
// Use defaults if API fails
|
|
470
|
+
systemMetrics.value = {
|
|
471
|
+
connections: 0,
|
|
472
|
+
memory: 0,
|
|
473
|
+
requestRate: 0,
|
|
474
|
+
latency: 0,
|
|
475
|
+
errorRate: 0,
|
|
476
|
+
uptime: 0
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Initialize empty arrays - no mock data
|
|
481
|
+
topQueues.value = []
|
|
482
|
+
errorSummary.value = []
|
|
483
|
+
|
|
484
|
+
} catch (error) {
|
|
485
|
+
console.error('Failed to fetch analytics:', error)
|
|
486
|
+
toast.add({
|
|
487
|
+
severity: 'error',
|
|
488
|
+
summary: 'Error',
|
|
489
|
+
detail: 'Failed to load analytics data',
|
|
490
|
+
life: 3000
|
|
491
|
+
})
|
|
492
|
+
} finally {
|
|
493
|
+
loading.value = false
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Process throughput data for chart
|
|
498
|
+
const processThroughputData = (data) => {
|
|
499
|
+
if (!data || data.length === 0) {
|
|
500
|
+
// Only generate mock data if we don't have existing data
|
|
501
|
+
if (!throughputData.value.labels || throughputData.value.labels.length === 0) {
|
|
502
|
+
const now = new Date()
|
|
503
|
+
const mockData = []
|
|
504
|
+
for (let i = 23; i >= 0; i--) {
|
|
505
|
+
const time = new Date(now.getTime() - i * 60 * 60 * 1000)
|
|
506
|
+
mockData.push({
|
|
507
|
+
timestamp: time.toISOString(),
|
|
508
|
+
incoming: { messagesPerMinute: Math.floor(Math.random() * 200) + 100 },
|
|
509
|
+
completed: { messagesPerMinute: Math.floor(Math.random() * 180) + 80 },
|
|
510
|
+
failed: { messagesPerMinute: Math.floor(Math.random() * 20) },
|
|
511
|
+
deadLetter: { messagesPerMinute: Math.floor(Math.random() * 5) }
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
data = mockData
|
|
515
|
+
} else {
|
|
516
|
+
// Keep existing data if API returns empty
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Take last 60 data points for recent view and reverse to show chronological order (oldest to newest)
|
|
522
|
+
const recentData = data.reverse().slice(-60)
|
|
523
|
+
|
|
524
|
+
// Only update if we have significant changes to avoid unnecessary re-renders
|
|
525
|
+
const newLabels = recentData.map(item => {
|
|
526
|
+
const date = new Date(item.timestamp)
|
|
527
|
+
return date.toLocaleTimeString('en-US', {
|
|
528
|
+
hour: 'numeric',
|
|
529
|
+
minute: '2-digit'
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
// Check if data has actually changed
|
|
534
|
+
const currentLabels = throughputData.value.labels || []
|
|
535
|
+
const hasChanged = newLabels.length !== currentLabels.length ||
|
|
536
|
+
newLabels[newLabels.length - 1] !== currentLabels[currentLabels.length - 1]
|
|
537
|
+
|
|
538
|
+
if (hasChanged) {
|
|
539
|
+
throughputData.value = {
|
|
540
|
+
labels: newLabels,
|
|
541
|
+
datasets: [
|
|
542
|
+
{
|
|
543
|
+
label: 'Incoming Messages',
|
|
544
|
+
data: recentData.map(item => item.incoming?.messagesPerMinute || 0)
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
label: 'Completed Messages',
|
|
548
|
+
data: recentData.map(item => item.completed?.messagesPerMinute || 0)
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
label: 'Failed Messages',
|
|
552
|
+
data: recentData.map(item => item.failed?.messagesPerMinute || 0)
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
label: 'Dead Letter Messages',
|
|
556
|
+
data: recentData.map(item => item.deadLetter?.messagesPerMinute || 0)
|
|
557
|
+
}
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Get severity for success rate
|
|
564
|
+
const getSuccessRateSeverity = (rate) => {
|
|
565
|
+
if (rate >= 99) return 'success'
|
|
566
|
+
if (rate >= 95) return 'warning'
|
|
567
|
+
return 'danger'
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Format bytes
|
|
571
|
+
const formatBytes = (bytes) => {
|
|
572
|
+
if (!bytes) return '0 B'
|
|
573
|
+
const k = 1024
|
|
574
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
575
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
576
|
+
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Format uptime
|
|
580
|
+
const formatUptime = (seconds) => {
|
|
581
|
+
if (!seconds) return '-'
|
|
582
|
+
const days = Math.floor(seconds / 86400)
|
|
583
|
+
const hours = Math.floor((seconds % 86400) / 3600)
|
|
584
|
+
if (days > 0) {
|
|
585
|
+
return `${days}d ${hours}h`
|
|
586
|
+
}
|
|
587
|
+
return `${hours}h`
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let refreshInterval = null
|
|
591
|
+
|
|
592
|
+
// Function to handle time range change
|
|
593
|
+
const onTimeRangeChange = () => {
|
|
594
|
+
if (selectedTimeRange.value !== 'custom') {
|
|
595
|
+
fromDateTime.value = null
|
|
596
|
+
toDateTime.value = null
|
|
597
|
+
fetchAnalytics()
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Function to load filter options
|
|
602
|
+
const loadFilterOptions = async () => {
|
|
603
|
+
try {
|
|
604
|
+
const [queues, namespaces] = await Promise.all([
|
|
605
|
+
api.getQueues(),
|
|
606
|
+
api.getNamespaces()
|
|
607
|
+
])
|
|
608
|
+
|
|
609
|
+
// Format queue options
|
|
610
|
+
if (queues?.queues && Array.isArray(queues.queues)) {
|
|
611
|
+
queueOptions.value = queues.queues.map(q => ({
|
|
612
|
+
label: q.queue || 'Unknown',
|
|
613
|
+
value: q.queue || ''
|
|
614
|
+
}))
|
|
615
|
+
} else {
|
|
616
|
+
queueOptions.value = []
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Format namespace options - handle both array and object responses
|
|
620
|
+
if (namespaces) {
|
|
621
|
+
const namespaceList = Array.isArray(namespaces) ? namespaces : (namespaces.namespaces || [])
|
|
622
|
+
if (Array.isArray(namespaceList)) {
|
|
623
|
+
namespaceOptions.value = namespaceList.map(ns => ({
|
|
624
|
+
label: ns.namespace || 'Default',
|
|
625
|
+
value: ns.namespace || ''
|
|
626
|
+
}))
|
|
627
|
+
} else {
|
|
628
|
+
namespaceOptions.value = []
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
namespaceOptions.value = []
|
|
632
|
+
}
|
|
633
|
+
} catch (error) {
|
|
634
|
+
console.error('Failed to load filter options:', error)
|
|
635
|
+
// Ensure arrays are set even on error
|
|
636
|
+
queueOptions.value = []
|
|
637
|
+
namespaceOptions.value = []
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
onMounted(() => {
|
|
642
|
+
loadFilterOptions()
|
|
643
|
+
fetchAnalytics()
|
|
644
|
+
|
|
645
|
+
// Subscribe to WebSocket events for real-time updates
|
|
646
|
+
websocket.on('message', handleWebSocketMessage)
|
|
647
|
+
|
|
648
|
+
// Reduced polling frequency - now every 5 minutes instead of 30 seconds
|
|
649
|
+
// WebSocket handles real-time updates, polling is just for data consistency
|
|
650
|
+
refreshInterval = setInterval(() => {
|
|
651
|
+
fetchAnalytics(true) // Silent refresh to avoid loading indicators
|
|
652
|
+
}, 300000) // 5 minutes
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
onUnmounted(() => {
|
|
656
|
+
// Clean up WebSocket listeners
|
|
657
|
+
websocket.off('message', handleWebSocketMessage)
|
|
658
|
+
|
|
659
|
+
if (refreshInterval) {
|
|
660
|
+
clearInterval(refreshInterval)
|
|
661
|
+
}
|
|
662
|
+
})
|
|
663
|
+
</script>
|
|
664
|
+
|
|
665
|
+
<style scoped>
|
|
666
|
+
.analytics-view {
|
|
667
|
+
padding: 0;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/* Filter Bar */
|
|
671
|
+
.filter-bar {
|
|
672
|
+
display: flex;
|
|
673
|
+
gap: 1rem;
|
|
674
|
+
padding: 1rem;
|
|
675
|
+
background: linear-gradient(135deg, #1e1e2e 0%, #2a2a3e 100%);
|
|
676
|
+
border-radius: 12px;
|
|
677
|
+
margin-bottom: 1.5rem;
|
|
678
|
+
flex-wrap: wrap;
|
|
679
|
+
align-items: flex-end;
|
|
680
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.filter-group {
|
|
684
|
+
display: flex;
|
|
685
|
+
flex-direction: column;
|
|
686
|
+
gap: 0.5rem;
|
|
687
|
+
min-width: 150px;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.filter-group label {
|
|
691
|
+
font-size: 0.875rem;
|
|
692
|
+
color: #a0a0a0;
|
|
693
|
+
font-weight: 500;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.filter-group :deep(.p-select),
|
|
697
|
+
.filter-group :deep(.p-calendar) {
|
|
698
|
+
background: rgba(255, 255, 255, 0.05);
|
|
699
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
700
|
+
border-radius: 8px;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.filter-group :deep(.p-select:hover),
|
|
704
|
+
.filter-group :deep(.p-calendar:hover) {
|
|
705
|
+
border-color: rgba(236, 72, 153, 0.5);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.filter-group :deep(.p-select-label),
|
|
709
|
+
.filter-group :deep(.p-inputtext) {
|
|
710
|
+
color: #ffffff;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.filter-group :deep(.p-button) {
|
|
714
|
+
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
|
|
715
|
+
border: none;
|
|
716
|
+
border-radius: 8px;
|
|
717
|
+
padding: 0.75rem 1.5rem;
|
|
718
|
+
font-weight: 500;
|
|
719
|
+
transition: all 0.3s ease;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.filter-group :deep(.p-button:hover) {
|
|
723
|
+
transform: translateY(-2px);
|
|
724
|
+
box-shadow: 0 4px 12px rgba(236, 72, 153, 0.3);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.analytics-grid {
|
|
728
|
+
display: grid;
|
|
729
|
+
grid-template-columns: 1fr 1fr;
|
|
730
|
+
gap: 1.5rem;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.card-v3 {
|
|
734
|
+
background: transparent;
|
|
735
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
736
|
+
border-radius: 16px;
|
|
737
|
+
padding: 1.5rem;
|
|
738
|
+
position: relative;
|
|
739
|
+
overflow: hidden;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.card-title {
|
|
743
|
+
font-size: 1.125rem;
|
|
744
|
+
font-weight: 600;
|
|
745
|
+
color: var(--surface-700);
|
|
746
|
+
margin: 0 0 1.5rem 0;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.chart-container {
|
|
750
|
+
background: transparent;
|
|
751
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
752
|
+
border-radius: 16px;
|
|
753
|
+
padding: 1.5rem;
|
|
754
|
+
height: 100%;
|
|
755
|
+
display: flex;
|
|
756
|
+
flex-direction: column;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
.chart-header {
|
|
760
|
+
display: flex;
|
|
761
|
+
justify-content: space-between;
|
|
762
|
+
align-items: center;
|
|
763
|
+
margin-bottom: 1.5rem;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.chart-title {
|
|
767
|
+
font-size: 1.125rem;
|
|
768
|
+
font-weight: 600;
|
|
769
|
+
color: var(--surface-700);
|
|
770
|
+
margin: 0;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.chart-actions {
|
|
774
|
+
display: flex;
|
|
775
|
+
align-items: center;
|
|
776
|
+
gap: 1rem;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.realtime-indicator {
|
|
780
|
+
display: flex;
|
|
781
|
+
align-items: center;
|
|
782
|
+
gap: 0.5rem;
|
|
783
|
+
padding: 0.25rem 0.75rem;
|
|
784
|
+
background: rgba(34, 197, 94, 0.1);
|
|
785
|
+
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
786
|
+
border-radius: 12px;
|
|
787
|
+
font-size: 0.75rem;
|
|
788
|
+
font-weight: 500;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.realtime-dot {
|
|
792
|
+
color: #22c55e;
|
|
793
|
+
font-size: 0.5rem;
|
|
794
|
+
animation: pulse 2s infinite;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.realtime-text {
|
|
798
|
+
color: #22c55e;
|
|
799
|
+
text-transform: uppercase;
|
|
800
|
+
letter-spacing: 0.05em;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
@keyframes pulse {
|
|
804
|
+
0%, 100% {
|
|
805
|
+
opacity: 1;
|
|
806
|
+
}
|
|
807
|
+
50% {
|
|
808
|
+
opacity: 0.5;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.system-metrics-card {
|
|
813
|
+
margin-top: 1.5rem;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.metrics-grid {
|
|
817
|
+
display: grid;
|
|
818
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
819
|
+
gap: 1.5rem;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
.metric-item {
|
|
823
|
+
display: flex;
|
|
824
|
+
flex-direction: column;
|
|
825
|
+
gap: 0.5rem;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
.metric-label {
|
|
829
|
+
font-size: 0.75rem;
|
|
830
|
+
color: var(--surface-400);
|
|
831
|
+
text-transform: uppercase;
|
|
832
|
+
letter-spacing: 0.05em;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.metric-value {
|
|
836
|
+
font-size: 1.25rem;
|
|
837
|
+
font-weight: 600;
|
|
838
|
+
color: var(--primary-500);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.queue-name-cell {
|
|
842
|
+
display: flex;
|
|
843
|
+
align-items: center;
|
|
844
|
+
gap: 0.5rem;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.queue-icon-small {
|
|
848
|
+
width: 24px;
|
|
849
|
+
height: 24px;
|
|
850
|
+
border-radius: 6px;
|
|
851
|
+
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%);
|
|
852
|
+
display: flex;
|
|
853
|
+
align-items: center;
|
|
854
|
+
justify-content: center;
|
|
855
|
+
color: white;
|
|
856
|
+
font-weight: 600;
|
|
857
|
+
font-size: 0.75rem;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/* DataTable dark theme overrides */
|
|
861
|
+
:deep(.dark-table-v3) {
|
|
862
|
+
background: transparent !important;
|
|
863
|
+
border: none !important;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
:deep(.dark-table-v3 .p-datatable-thead > tr > th) {
|
|
867
|
+
background: transparent !important;
|
|
868
|
+
color: var(--surface-400) !important;
|
|
869
|
+
border-color: rgba(255, 255, 255, 0.1) !important;
|
|
870
|
+
padding: 0.75rem;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
:deep(.dark-table-v3 .p-datatable-tbody > tr) {
|
|
874
|
+
background: transparent !important;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
:deep(.dark-table-v3 .p-datatable-tbody > tr:hover) {
|
|
878
|
+
background: rgba(236, 72, 153, 0.05) !important;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
:deep(.dark-table-v3 .p-datatable-tbody > tr > td) {
|
|
882
|
+
color: var(--surface-600) !important;
|
|
883
|
+
border-color: rgba(255, 255, 255, 0.05) !important;
|
|
884
|
+
padding: 0.625rem 0.75rem;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
.full-width {
|
|
888
|
+
grid-column: 1 / -1;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.lag-chart-wrapper {
|
|
892
|
+
background: #0a0a0a;
|
|
893
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
894
|
+
border-radius: 16px;
|
|
895
|
+
overflow: hidden;
|
|
896
|
+
position: relative;
|
|
897
|
+
z-index: 1;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* Responsive */
|
|
901
|
+
@media (max-width: 1024px) {
|
|
902
|
+
.analytics-grid {
|
|
903
|
+
grid-template-columns: 1fr;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
@media (max-width: 768px) {
|
|
908
|
+
.metrics-grid {
|
|
909
|
+
grid-template-columns: repeat(2, 1fr);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
</style>
|