Passed
Pull Request — master (#6810)
by
unknown
10:30
created

json_out()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 3
c 1
b 0
f 1
nc 1
nop 1
dl 0
loc 4
rs 10
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;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
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;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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