1
|
|
|
<?php |
2
|
|
|
/* For license terms, see /license.txt */ |
3
|
|
|
|
4
|
|
|
/** |
5
|
|
|
* BBB Webhooks Dashboard (Global, grouped by meeting) – “participants activity” style |
6
|
|
|
* - Global KPIs |
7
|
|
|
* - Filters: title, date range, only-online rows, specific meeting |
8
|
|
|
* - Grouped table by meeting with progress bar, icons, and reactions breakdown |
9
|
|
|
* - CSV export and auto-refresh |
10
|
|
|
*/ |
11
|
|
|
|
12
|
|
|
use Chamilo\CoreBundle\Entity\ConferenceActivity; |
13
|
|
|
use Chamilo\CoreBundle\Entity\ConferenceMeeting; |
14
|
|
|
|
15
|
|
|
require_once __DIR__.'/config.php'; |
16
|
|
|
|
17
|
|
|
/* --- Security --- */ |
18
|
|
|
api_block_anonymous_users(); |
19
|
|
|
if (!api_is_platform_admin()) { |
20
|
|
|
api_not_allowed(true); |
21
|
|
|
} |
22
|
|
|
|
23
|
|
|
/* --- Helpers --- */ |
24
|
|
|
function json_out($data) { |
25
|
|
|
header('Content-Type: application/json; charset=utf-8'); |
26
|
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); |
27
|
|
|
exit; |
|
|
|
|
28
|
|
|
} |
29
|
|
|
function csv_out(string $filename, array $rows, array $header) { |
30
|
|
|
header('Content-Type: text/csv; charset=utf-8'); |
31
|
|
|
header('Content-Disposition: attachment; filename="'.$filename.'"'); |
32
|
|
|
$out = fopen('php://output', 'w'); |
33
|
|
|
fputcsv($out, $header); |
34
|
|
|
foreach ($rows as $r) { fputcsv($out, $r); } |
35
|
|
|
fclose($out); |
36
|
|
|
exit; |
|
|
|
|
37
|
|
|
} |
38
|
|
|
function dt_utc($s = 'now'): \DateTime { |
39
|
|
|
return new \DateTime($s, new \DateTimeZone('UTC')); |
40
|
|
|
} |
41
|
|
|
function hms(int $s): string { |
42
|
|
|
$h = intdiv($s, 3600); $m = intdiv($s % 3600, 60); $sec = $s % 60; |
43
|
|
|
return sprintf('%02d:%02d:%02d', $h, $m, $sec); |
44
|
|
|
} |
45
|
|
|
|
46
|
|
|
/* --- Repos --- */ |
47
|
|
|
$em = Database::getManager(); |
48
|
|
|
$actRepo = $em->getRepository(ConferenceActivity::class); |
49
|
|
|
$meetingRepo = $em->getRepository(ConferenceMeeting::class); |
50
|
|
|
|
51
|
|
|
/* --- Constants --- */ |
52
|
|
|
$ROOM_OPEN = BbbPlugin::ROOM_OPEN; |
53
|
|
|
$ROOM_CLOSE = BbbPlugin::ROOM_CLOSE; |
54
|
|
|
|
55
|
|
|
/* --- Filters --- */ |
56
|
|
|
$qTitle = trim((string)($_GET['q'] ?? '')); |
57
|
|
|
$onlyOpen = isset($_GET['only_open']) ? (int)$_GET['only_open'] : 0; |
58
|
|
|
$fromStr = trim((string)($_GET['from'] ?? '')); |
59
|
|
|
$toStr = trim((string)($_GET['to'] ?? '')); |
60
|
|
|
$meetingFilter = isset($_GET['meeting_id']) ? (int)$_GET['meeting_id'] : 0; |
61
|
|
|
|
62
|
|
|
$now = dt_utc('now'); |
63
|
|
|
$today0 = dt_utc('today'); |
64
|
|
|
|
65
|
|
|
$from = $fromStr ? new \DateTime($fromStr.' 00:00:00', new \DateTimeZone('UTC')) : (clone $now)->modify('-24 hours'); |
66
|
|
|
$to = $toStr ? new \DateTime($toStr.' 23:59:59', new \DateTimeZone('UTC')) : (clone $now); |
67
|
|
|
|
68
|
|
|
/* --- Meetings select list --- */ |
69
|
|
|
$meetingsQb = $meetingRepo->createQueryBuilder('m') |
70
|
|
|
->select('m.id,m.title') |
71
|
|
|
->orderBy('m.createdAt', 'DESC'); |
72
|
|
|
if ($qTitle !== '') { |
73
|
|
|
$meetingsQb->andWhere('m.title LIKE :q')->setParameter('q', '%'.$qTitle.'%'); |
74
|
|
|
} |
75
|
|
|
$meetingOptions = $meetingsQb->getQuery()->getArrayResult(); |
76
|
|
|
$meetingIdsInFilter = array_map('intval', array_column($meetingOptions, 'id')); |
77
|
|
|
if ($meetingFilter > 0) { |
78
|
|
|
// If the selected meeting is not in current filtered list, still force it |
79
|
|
|
$meetingIdsInFilter = in_array($meetingFilter, $meetingIdsInFilter, true) ? [$meetingFilter] : [$meetingFilter]; |
80
|
|
|
} |
81
|
|
|
|
82
|
|
|
/* ===================== Stats & grouped data ===================== */ |
83
|
|
|
$statsAndData = (function() use ($actRepo, $meetingRepo, $ROOM_OPEN, $ROOM_CLOSE, $now, $today0, $from, $to, $meetingIdsInFilter) { |
84
|
|
|
|
85
|
|
|
/* KPIs */ |
86
|
|
|
$q1 = $actRepo->createQueryBuilder('a')->select('COUNT(a.id)')->where('a.close = :o')->setParameter('o', $ROOM_OPEN); |
87
|
|
|
if (!empty($meetingIdsInFilter)) $q1->andWhere('a.meeting IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
88
|
|
|
$connected_now = (int)$q1->getQuery()->getSingleScalarResult(); |
89
|
|
|
|
90
|
|
|
$q2 = $meetingRepo->createQueryBuilder('m')->select('COUNT(m.id)')->where('m.status = 1'); |
91
|
|
|
if (!empty($meetingIdsInFilter)) $q2->andWhere('m.id IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
92
|
|
|
$active_by_status = (int)$q2->getQuery()->getSingleScalarResult(); |
93
|
|
|
|
94
|
|
|
$q3 = $actRepo->createQueryBuilder('a')->select('COUNT(DISTINCT IDENTITY(a.meeting))')->where('a.close = :o')->setParameter('o', $ROOM_OPEN); |
95
|
|
|
if (!empty($meetingIdsInFilter)) $q3->andWhere('a.meeting IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
96
|
|
|
$active_by_open = (int)$q3->getQuery()->getSingleScalarResult(); |
97
|
|
|
|
98
|
|
|
$active_meetings = max($active_by_status, $active_by_open); |
99
|
|
|
|
100
|
|
|
$q4 = $actRepo->createQueryBuilder('a')->select('COUNT(a.id)')->where('a.inAt >= :d0')->setParameter('d0', $today0); |
101
|
|
|
if (!empty($meetingIdsInFilter)) $q4->andWhere('a.meeting IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
102
|
|
|
$joins_today = (int)$q4->getQuery()->getSingleScalarResult(); |
103
|
|
|
|
104
|
|
|
$q5 = $actRepo->createQueryBuilder('a')->select('COUNT(a.id)')->where('a.inAt >= :t')->setParameter('t', (clone $now)->modify('-24 hours')); |
105
|
|
|
if (!empty($meetingIdsInFilter)) $q5->andWhere('a.meeting IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
106
|
|
|
$joins_24h = (int)$q5->getQuery()->getSingleScalarResult(); |
107
|
|
|
|
108
|
|
|
$q6 = $actRepo->createQueryBuilder('a')->select('COUNT(a.id)') |
109
|
|
|
->where('a.close = :c')->andWhere('a.outAt IS NOT NULL')->andWhere('a.outAt >= :t') |
110
|
|
|
->setParameter('c', $ROOM_CLOSE)->setParameter('t', (clone $now)->modify('-24 hours')); |
111
|
|
|
if (!empty($meetingIdsInFilter)) $q6->andWhere('a.meeting IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
112
|
|
|
$leaves_24h = (int)$q6->getQuery()->getSingleScalarResult(); |
113
|
|
|
|
114
|
|
|
$events_24h = $joins_24h + $leaves_24h; |
115
|
|
|
|
116
|
|
|
/* Grouped table by meeting */ |
117
|
|
|
$qb = $actRepo->createQueryBuilder('a') |
118
|
|
|
->leftJoin('a.meeting', 'm')->addSelect('m') |
119
|
|
|
->leftJoin('a.participant', 'u')->addSelect('u') |
120
|
|
|
->where('(a.inAt BETWEEN :f AND :t) OR (a.outAt BETWEEN :f AND :t)') |
121
|
|
|
->setParameter('f', $from)->setParameter('t', $to) |
122
|
|
|
->orderBy('m.createdAt','DESC')->addOrderBy('u.lastname','ASC')->addOrderBy('a.id','ASC'); |
123
|
|
|
if (!empty($meetingIdsInFilter)) $qb->andWhere('m.id IN (:mid)')->setParameter('mid', $meetingIdsInFilter); |
124
|
|
|
|
125
|
|
|
$acts = $qb->getQuery()->getResult(); |
126
|
|
|
|
127
|
|
|
$grouped = []; // mid => data |
128
|
|
|
foreach ($acts as $a) { |
129
|
|
|
/** @var ConferenceActivity $a */ |
130
|
|
|
$m = $a->getMeeting(); if (!$m) continue; |
131
|
|
|
$u = $a->getParticipant(); if (!$u) continue; |
132
|
|
|
$mid = (int)$m->getId(); |
133
|
|
|
if (!isset($grouped[$mid])) { |
134
|
|
|
// Meeting duration info (not used for progress bar; bar is per max user time) |
135
|
|
|
$start = $m->getCreatedAt(); $end = $m->getClosedAt(); |
136
|
|
|
$meetingDuration = ($start && $end) ? max(0, $end->getTimestamp() - $start->getTimestamp()) : 0; |
137
|
|
|
|
138
|
|
|
$grouped[$mid] = [ |
139
|
|
|
'meeting' => [ |
140
|
|
|
'id' => $mid, |
141
|
|
|
'title' => (string)$m->getTitle(), |
142
|
|
|
'status' => $m->isOpen() ? 'running' : 'finished', |
143
|
|
|
'created_at' => $start?->format('Y-m-d H:i:s'), |
144
|
|
|
'closed_at' => $end?->format('Y-m-d H:i:s'), |
145
|
|
|
'duration_s' => $meetingDuration, |
146
|
|
|
], |
147
|
|
|
'rows' => [], |
148
|
|
|
'totals' => [ |
149
|
|
|
'users' => 0, |
150
|
|
|
'online_seconds'=>0, 'talk_seconds'=>0, 'camera_seconds'=>0, |
151
|
|
|
'messages'=>0, 'reactions'=>0, 'hands'=>0, |
152
|
|
|
'reactions_breakdown'=>[], |
153
|
|
|
], |
154
|
|
|
'max_online_user_s' => 0, // for progress bar scale |
155
|
|
|
]; |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
$uid = (int)$u->getId(); |
159
|
|
|
$name = method_exists($u,'getCompleteName') ? $u->getCompleteName() : trim(($u->getLastname().' '.$u->getFirstname())); |
160
|
|
|
if (!isset($grouped[$mid]['rows'][$uid])) { |
161
|
|
|
$grouped[$mid]['rows'][$uid] = [ |
162
|
|
|
'user_id'=>$uid, 'user'=>$name ?: ('#'.$uid), |
163
|
|
|
'online_seconds'=>0, 'talk_seconds'=>0, 'camera_seconds'=>0, |
164
|
|
|
'messages'=>0, 'reactions'=>0, 'hands'=>0, |
165
|
|
|
'reactions_breakdown'=>[], |
166
|
|
|
'status'=>'offline', |
167
|
|
|
'first_join'=>$a->getInAt()?->format('Y-m-d H:i:s'), |
168
|
|
|
'last_seen'=>$a->getOutAt()?->format('Y-m-d H:i:s'), |
169
|
|
|
]; |
170
|
|
|
} |
171
|
|
|
|
172
|
|
|
// Accumulate times |
173
|
|
|
$in=$a->getInAt(); $out=$a->getOutAt(); |
174
|
|
|
if ($in instanceof DateTimeInterface && $out instanceof DateTimeInterface) { |
175
|
|
|
$seg = max(0, $out->getTimestamp() - $in->getTimestamp()); |
176
|
|
|
$grouped[$mid]['rows'][$uid]['online_seconds'] += $seg; |
177
|
|
|
} |
178
|
|
|
|
179
|
|
|
// Metrics coming from webhook payload (if any) |
180
|
|
|
$mj = is_array($a->getMetrics()) ? $a->getMetrics() : []; |
181
|
|
|
$grouped[$mid]['rows'][$uid]['talk_seconds'] += (int)($mj['totals']['talk_seconds'] ?? 0); |
182
|
|
|
$grouped[$mid]['rows'][$uid]['camera_seconds'] += (int)($mj['totals']['camera_seconds'] ?? 0); |
183
|
|
|
$grouped[$mid]['rows'][$uid]['messages'] += (int)($mj['counts']['messages'] ?? 0); |
184
|
|
|
$grouped[$mid]['rows'][$uid]['reactions'] += (int)($mj['counts']['reactions'] ?? 0); |
185
|
|
|
$grouped[$mid]['rows'][$uid]['hands'] += (int)($mj['counts']['hands'] ?? 0); |
186
|
|
|
|
187
|
|
|
// Reactions breakdown (emoji => count) if provided |
188
|
|
|
if (!empty($mj['counts']['reactions_breakdown']) && is_array($mj['counts']['reactions_breakdown'])) { |
189
|
|
|
foreach ($mj['counts']['reactions_breakdown'] as $emoji=>$cnt) { |
190
|
|
|
$grouped[$mid]['rows'][$uid]['reactions_breakdown'][$emoji] = |
191
|
|
|
($grouped[$mid]['rows'][$uid]['reactions_breakdown'][$emoji] ?? 0) + (int)$cnt; |
192
|
|
|
} |
193
|
|
|
} |
194
|
|
|
|
195
|
|
|
// Status and last seen |
196
|
|
|
$grouped[$mid]['rows'][$uid]['status'] = $a->isClose() ? 'offline' : 'online'; |
197
|
|
|
if ($a->getOutAt() instanceof DateTimeInterface) { |
198
|
|
|
$grouped[$mid]['rows'][$uid]['last_seen'] = $a->getOutAt()->format('Y-m-d H:i:s'); |
199
|
|
|
} |
200
|
|
|
} |
201
|
|
|
|
202
|
|
|
// Totals and max user online seconds per meeting (for progress bar) |
203
|
|
|
foreach ($grouped as $mid=>&$G) { |
204
|
|
|
$G['rows'] = array_values($G['rows']); |
205
|
|
|
foreach ($G['rows'] as $r) { |
206
|
|
|
$G['totals']['users']++; |
207
|
|
|
$G['totals']['online_seconds'] += (int)$r['online_seconds']; |
208
|
|
|
$G['totals']['talk_seconds'] += (int)$r['talk_seconds']; |
209
|
|
|
$G['totals']['camera_seconds'] += (int)$r['camera_seconds']; |
210
|
|
|
$G['totals']['messages'] += (int)$r['messages']; |
211
|
|
|
$G['totals']['reactions'] += (int)$r['reactions']; |
212
|
|
|
$G['totals']['hands'] += (int)$r['hands']; |
213
|
|
|
$G['max_online_user_s'] = max($G['max_online_user_s'], (int)$r['online_seconds']); |
214
|
|
|
foreach ($r['reactions_breakdown'] as $emoji=>$cnt) { |
215
|
|
|
$G['totals']['reactions_breakdown'][$emoji] = |
216
|
|
|
($G['totals']['reactions_breakdown'][$emoji] ?? 0) + (int)$cnt; |
217
|
|
|
} |
218
|
|
|
} |
219
|
|
|
} |
220
|
|
|
unset($G); |
221
|
|
|
|
222
|
|
|
return [ |
223
|
|
|
'kpis' => compact('connected_now','active_meetings','joins_today','events_24h'), |
224
|
|
|
'grouped' => array_values($grouped), |
225
|
|
|
'now' => $now->format('Y-m-d H:i:s'), |
226
|
|
|
]; |
227
|
|
|
})(); |
228
|
|
|
|
229
|
|
|
/* --- CSV export --- */ |
230
|
|
|
if (isset($_GET['export']) && $_GET['export'] === 'csv') { |
231
|
|
|
$rows = []; |
232
|
|
|
foreach ($statsAndData['grouped'] as $G) { |
233
|
|
|
$rows[] = ['# Meeting', $G['meeting']['title']]; |
234
|
|
|
$rows[] = ['User','Online','Talking','Camera','Messages','Reactions','Hands','Status','First join','Last seen']; |
235
|
|
|
foreach ($G['rows'] as $r) { |
236
|
|
|
$rows[] = [ |
237
|
|
|
(string)$r['user'], |
238
|
|
|
hms((int)$r['online_seconds']), |
239
|
|
|
hms((int)$r['talk_seconds']), |
240
|
|
|
hms((int)$r['camera_seconds']), |
241
|
|
|
(int)$r['messages'], |
242
|
|
|
(int)$r['reactions'], |
243
|
|
|
(int)$r['hands'], |
244
|
|
|
(string)$r['status'], |
245
|
|
|
(string)$r['first_join'], |
246
|
|
|
(string)$r['last_seen'], |
247
|
|
|
]; |
248
|
|
|
} |
249
|
|
|
$rows[] = ['Totals', |
250
|
|
|
hms((int)$G['totals']['online_seconds']), |
251
|
|
|
hms((int)$G['totals']['talk_seconds']), |
252
|
|
|
hms((int)$G['totals']['camera_seconds']), |
253
|
|
|
(int)$G['totals']['messages'], |
254
|
|
|
(int)$G['totals']['reactions'], |
255
|
|
|
(int)$G['totals']['hands'], |
256
|
|
|
'', |
257
|
|
|
'', |
258
|
|
|
'' |
259
|
|
|
]; |
260
|
|
|
$rows[] = []; |
261
|
|
|
} |
262
|
|
|
csv_out('bbb_grouped_dashboard.csv', $rows, []); |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
/* --- AJAX --- */ |
266
|
|
|
if (isset($_GET['ajax'])) { |
267
|
|
|
json_out($statsAndData); |
268
|
|
|
} |
269
|
|
|
|
270
|
|
|
/* ===================== HTML ===================== */ |
271
|
|
|
$tpl = new Template('[BBB] Webhooks Dashboard (Global)'); |
272
|
|
|
ob_start(); ?> |
273
|
|
|
<style> |
274
|
|
|
.cards {display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:18px;} |
275
|
|
|
.card {background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px;box-shadow:0 6px 20px rgba(0,0,0,.06);} |
276
|
|
|
.card .lbl{font-size:12px;color:#64748b} |
277
|
|
|
.card .val{font-size:28px;font-weight:700;color:#0f172a;margin-top:4px} |
278
|
|
|
|
279
|
|
|
.group {background:#fff;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 6px 16px rgba(0,0,0,.05);overflow:hidden;margin-bottom:16px;} |
280
|
|
|
.g-head {display:flex;justify-content:space-between;align-items:center;padding:14px 16px;border-bottom:1px solid #eef2f7;background:#f8fafc} |
281
|
|
|
.g-title {font-weight:700;color:#0f172a} |
282
|
|
|
.badge {display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;margin-left:8px} |
283
|
|
|
.online {background:#e6f8ec;color:#14804a} |
284
|
|
|
.offline {background:#f1f5f9;color:#334155} |
285
|
|
|
|
286
|
|
|
table{width:100%;border-collapse:collapse} |
287
|
|
|
th,td{padding:10px 12px;border-bottom:1px solid #eef2f7;text-align:left;font-size:13px} |
288
|
|
|
thead th{background:#fbfdff;color:#111827} |
289
|
|
|
.usercell{display:flex;align-items:center;gap:10px} |
290
|
|
|
.avatar{width:32px;height:32px;border-radius:50%;background:#f1f5f9;display:flex;align-items:center;justify-content:center;color:#64748b;border:1px solid #e5e7eb} |
291
|
|
|
.muted{color:#64748b;font-size:12px} |
292
|
|
|
|
293
|
|
|
.bar{position:relative;height:8px;border-radius:999px;background:#eef2f7;overflow:hidden} |
294
|
|
|
.bar > span{position:absolute;left:0;top:0;height:100%;background:#22c55e} |
295
|
|
|
.row-meta{display:flex;align-items:center;gap:8px;color:#475569;font-size:12px} |
296
|
|
|
.chip{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;background:#f8fafc;border:1px solid #eef2f7} |
297
|
|
|
.emoji-list span{margin-right:8px} |
298
|
|
|
.btn-slim{padding:8px 14px;border:1px solid #64748b;border-radius:8px;color:#334155;text-decoration:none;background:#fff} |
299
|
|
|
.btn-primary{padding:8px 14px;border-radius:8px;background:#2563eb;color:#fff;border:none} |
300
|
|
|
.toolbar{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:12px} |
301
|
|
|
.field{display:flex;flex-direction:column;gap:6px} |
302
|
|
|
.field input,.field select{padding:6px 10px;border:1px solid #e5e7eb;border-radius:8px} |
303
|
|
|
</style> |
304
|
|
|
|
305
|
|
|
<div class="toolbar"> |
306
|
|
|
<div class="field"> |
307
|
|
|
<label class="muted">Meeting title contains</label> |
308
|
|
|
<input id="q" type="text" value="<?php echo htmlspecialchars($qTitle)?>"> |
309
|
|
|
</div> |
310
|
|
|
<div class="field"> |
311
|
|
|
<label class="muted">From (UTC)</label> |
312
|
|
|
<input id="from" type="date" value="<?php echo htmlspecialchars($fromStr)?>"> |
313
|
|
|
</div> |
314
|
|
|
<div class="field"> |
315
|
|
|
<label class="muted">To (UTC)</label> |
316
|
|
|
<input id="to" type="date" value="<?php echo htmlspecialchars($toStr)?>"> |
317
|
|
|
</div> |
318
|
|
|
<div class="field"> |
319
|
|
|
<label class="chip"><input id="only_open" type="checkbox" <?php echo $onlyOpen?'checked':''?>> Only online rows</label> |
320
|
|
|
</div> |
321
|
|
|
<div class="field"> |
322
|
|
|
<label class="muted">Meeting</label> |
323
|
|
|
<select id="meeting_id"> |
324
|
|
|
<option value="0">All meetings</option> |
325
|
|
|
<?php foreach ($meetingOptions as $m): ?> |
326
|
|
|
<option value="<?php echo $m['id']?>" <?php echo $meetingFilter===(int)$m['id']?'selected':''?>><?php echo htmlspecialchars($m['title'])?></option> |
327
|
|
|
<?php endforeach; ?> |
328
|
|
|
</select> |
329
|
|
|
</div> |
330
|
|
|
<div class="field"> |
331
|
|
|
<button id="apply" class="btn-primary">Apply</button> |
332
|
|
|
</div> |
333
|
|
|
<div class="field"> |
334
|
|
|
<a id="csv" class="btn-slim">Export CSV</a> |
335
|
|
|
</div> |
336
|
|
|
<label class="chip" style="margin-left:auto"><input id="auto" type="checkbox" checked> Auto-refresh (10s)</label> |
337
|
|
|
</div> |
338
|
|
|
|
339
|
|
|
<div class="cards"> |
340
|
|
|
<?php $k=$statsAndData['kpis']; foreach ([['Active meetings',$k['active_meetings']],['Connected now',$k['connected_now']],['Joins today',$k['joins_today']],['Events (24h)',$k['events_24h']]] as $c): ?> |
341
|
|
|
<div class="card"><div class="lbl"><?php echo $c[0]?></div><div class="val kpi-val"><?php echo $c[1]?></div></div> |
342
|
|
|
<?php endforeach; ?> |
343
|
|
|
</div> |
344
|
|
|
|
345
|
|
|
<div id="groups"> |
346
|
|
|
<?php foreach ($statsAndData['grouped'] as $G): $maxBar = max(1,(int)$G['max_online_user_s']); ?> |
347
|
|
|
<div class="group" data-meeting="<?php echo $G['meeting']['id']?>"> |
348
|
|
|
<div class="g-head"> |
349
|
|
|
<div class="g-title"> |
350
|
|
|
<?php echo htmlspecialchars($G['meeting']['title'])?> |
351
|
|
|
<span class="badge <?php echo $G['meeting']['status']==='running'?'online':'offline'?>"><?php echo htmlspecialchars($G['meeting']['status'])?></span> |
352
|
|
|
</div> |
353
|
|
|
<div class="muted"> |
354
|
|
|
Users: <?php echo $G['totals']['users']?> — |
355
|
|
|
Online: <?php echo hms((int)$G['totals']['online_seconds'])?> — |
356
|
|
|
Talk: <?php echo hms((int)$G['totals']['talk_seconds'])?> — |
357
|
|
|
Camera: <?php echo hms((int)$G['totals']['camera_seconds'])?> |
358
|
|
|
</div> |
359
|
|
|
</div> |
360
|
|
|
|
361
|
|
|
<div style="overflow:auto"> |
362
|
|
|
<table> |
363
|
|
|
<thead> |
364
|
|
|
<tr> |
365
|
|
|
<th>USERS</th> |
366
|
|
|
<th style="width:260px">ONLINE TIME</th> |
367
|
|
|
<th>CONVERSATION</th> |
368
|
|
|
<th>CAMERA SHARE</th> |
369
|
|
|
<th>MESSAGES</th> |
370
|
|
|
<th>REACTIONS</th> |
371
|
|
|
<th>HANDS</th> |
372
|
|
|
<th>RESULTS</th> |
373
|
|
|
<th>STATUS</th> |
374
|
|
|
</tr> |
375
|
|
|
</thead> |
376
|
|
|
<tbody> |
377
|
|
|
<?php foreach ($G['rows'] as $r): if ($onlyOpen && $r['status']!=='online') continue; |
378
|
|
|
$pct = round(((int)$r['online_seconds'] / $maxBar) * 100); |
379
|
|
|
$pct = max(2, min(100, $pct)); |
380
|
|
|
// Build reactions string (breakdown if available) |
381
|
|
|
$rx = ''; |
382
|
|
|
if (!empty($r['reactions_breakdown'])) { |
383
|
|
|
foreach ($r['reactions_breakdown'] as $emo=>$cnt) { |
384
|
|
|
$rx .= '<span>'.$emo.' '.(int)$cnt.'</span> '; |
385
|
|
|
} |
386
|
|
|
$rx = trim($rx); |
387
|
|
|
} else { |
388
|
|
|
$rx = (int)$r['reactions']; |
389
|
|
|
} |
390
|
|
|
?> |
391
|
|
|
<tr> |
392
|
|
|
<td> |
393
|
|
|
<div class="usercell"> |
394
|
|
|
<div class="avatar">👤</div> |
395
|
|
|
<div> |
396
|
|
|
<div style="font-weight:600;color:#0f172a"><?php echo htmlspecialchars($r['user'])?></div> |
397
|
|
|
<div class="muted">Joined: <?php echo htmlspecialchars($r['first_join'] ?? '—')?> · Last: <?php echo htmlspecialchars($r['last_seen'] ?? '—')?></div> |
398
|
|
|
</div> |
399
|
|
|
</div> |
400
|
|
|
</td> |
401
|
|
|
|
402
|
|
|
<td> |
403
|
|
|
<div class="row-meta" style="margin-bottom:6px">🔊 <?php echo hms((int)$r['online_seconds'])?></div> |
404
|
|
|
<div class="bar"><span style="width:<?php echo $pct?>%"></span></div> |
405
|
|
|
</td> |
406
|
|
|
|
407
|
|
|
<td> |
408
|
|
|
<div class="row-meta">🎙️ <?php echo hms((int)$r['talk_seconds'])?></div> |
409
|
|
|
</td> |
410
|
|
|
|
411
|
|
|
<td> |
412
|
|
|
<div class="row-meta">📷 <?php echo hms((int)$r['camera_seconds'])?></div> |
413
|
|
|
</td> |
414
|
|
|
|
415
|
|
|
<td> |
416
|
|
|
<div class="row-meta">💬 <?php echo (int)$r['messages'] ?></div> |
417
|
|
|
</td> |
418
|
|
|
|
419
|
|
|
<td> |
420
|
|
|
<div class="row-meta emoji-list"><?php echo $rx ?: '—'?></div> |
421
|
|
|
</td> |
422
|
|
|
|
423
|
|
|
<td> |
424
|
|
|
<div class="row-meta">✋ <?php echo (int)$r['hands'] ?></div> |
425
|
|
|
</td> |
426
|
|
|
|
427
|
|
|
<td class="muted">N/A</td> |
428
|
|
|
|
429
|
|
|
<td> |
430
|
|
|
<span class="badge <?php echo $r['status']==='online'?'online':'offline'?>"><?php echo htmlspecialchars(strtoupper($r['status']))?></span> |
431
|
|
|
</td> |
432
|
|
|
</tr> |
433
|
|
|
<?php endforeach; ?> |
434
|
|
|
</tbody> |
435
|
|
|
</table> |
436
|
|
|
</div> |
437
|
|
|
</div> |
438
|
|
|
<?php endforeach; ?> |
439
|
|
|
</div> |
440
|
|
|
|
441
|
|
|
<div class="muted">Updated: <span id="updatedAt"><?php echo $statsAndData['now']?></span></div> |
442
|
|
|
|
443
|
|
|
<script> |
444
|
|
|
(function(){ |
445
|
|
|
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));} |
446
|
|
|
function hms(s){s=+s||0;const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(sec).padStart(2,'0');} |
447
|
|
|
function qs(){ |
448
|
|
|
const p=new URLSearchParams(); |
449
|
|
|
const q=document.getElementById('q').value.trim(); |
450
|
|
|
const f=document.getElementById('from').value; |
451
|
|
|
const t=document.getElementById('to').value; |
452
|
|
|
const o=document.getElementById('only_open').checked?1:0; |
453
|
|
|
const m=document.getElementById('meeting_id').value; |
454
|
|
|
if(q) p.set('q',q); |
455
|
|
|
if(f) p.set('from',f); |
456
|
|
|
if(t) p.set('to',t); |
457
|
|
|
if(o) p.set('only_open','1'); |
458
|
|
|
if(+m>0) p.set('meeting_id',m); |
459
|
|
|
return p.toString(); |
460
|
|
|
} |
461
|
|
|
function render(d){ |
462
|
|
|
// KPIs |
463
|
|
|
const k=d.kpis||{},kpis=document.querySelectorAll('.kpi-val'); |
464
|
|
|
if(kpis[0]) kpis[0].textContent=k.active_meetings??0; |
465
|
|
|
if(kpis[1]) kpis[1].textContent=k.connected_now??0; |
466
|
|
|
if(kpis[2]) kpis[2].textContent=k.joins_today??0; |
467
|
|
|
if(kpis[3]) kpis[3].textContent=k.events_24h??0; |
468
|
|
|
document.getElementById('updatedAt').textContent=d.now||''; |
469
|
|
|
} |
470
|
|
|
async function fetchStats(){ |
471
|
|
|
const r=await fetch('?ajax=1&'+qs(),{credentials:'same-origin'}); |
472
|
|
|
if(!r.ok) return; |
473
|
|
|
render(await r.json()); |
474
|
|
|
const base=location.pathname, q=qs(); |
475
|
|
|
fetch(base+'?'+q,{credentials:'same-origin'}).then(r=>r.text()).then(html=>{ |
476
|
|
|
const tmp=document.createElement('div'); tmp.innerHTML=html; |
477
|
|
|
const newGroups=tmp.querySelector('#groups'); const newCards=tmp.querySelectorAll('.kpi-val'); |
478
|
|
|
if(newGroups){ document.querySelector('#groups').replaceWith(newGroups); } |
479
|
|
|
const kpis=document.querySelectorAll('.kpi-val'); |
480
|
|
|
kpis.forEach((el,i)=>{ if(newCards[i]) el.textContent=newCards[i].textContent; }); |
481
|
|
|
const updated=tmp.querySelector('#updatedAt'); if(updated) document.getElementById('updatedAt').textContent=updated.textContent; |
482
|
|
|
}).catch(()=>{}); |
483
|
|
|
} |
484
|
|
|
document.getElementById('apply').addEventListener('click',()=>{ |
485
|
|
|
const base=location.pathname, q=qs(); |
486
|
|
|
history.replaceState(null,'', q? (base+'?'+q) : base); |
487
|
|
|
fetchStats(); |
488
|
|
|
}); |
489
|
|
|
document.getElementById('csv').addEventListener('click',e=>{ |
490
|
|
|
e.preventDefault(); window.location.href='?export=csv&'+qs(); |
491
|
|
|
}); |
492
|
|
|
let timer=setInterval(fetchStats,10000); |
493
|
|
|
document.getElementById('auto').addEventListener('change',e=>{ |
494
|
|
|
if(e.target.checked){ timer=setInterval(fetchStats,10000); } |
495
|
|
|
else{ clearInterval(timer); } |
496
|
|
|
}); |
497
|
|
|
})(); |
498
|
|
|
</script> |
499
|
|
|
<?php |
500
|
|
|
$html = ob_get_clean(); |
501
|
|
|
|
502
|
|
|
/* Render */ |
503
|
|
|
$tpl->assign('content', $html); |
504
|
|
|
$actionLinks = Display::toolbarButton( |
505
|
|
|
get_lang('VideoConference'), |
506
|
|
|
api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?global=1&user_id='.api_get_user_id(), |
507
|
|
|
'video', |
508
|
|
|
'primary' |
509
|
|
|
); |
510
|
|
|
$tpl->assign('actions', Display::toolbarAction('toolbar', [$actionLinks])); |
511
|
|
|
$tpl->display_one_col_template(); |
512
|
|
|
|
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.