1<?php
2/**
3 * Title: Analytics
4 * Icon: fa-bar-chart
5 * Group: Overview
6 * Order: 15
7 * Description: Accounts, activity, shop revenue and where your players come from.
8 */
9
10/*
11 * Everything here is read-only and cached for a few minutes (engine/cache/acp_analytics),
12 * so opening this page never costs more than one batch of queries per cache
13 * window, no matter how many admins are looking at it.
14 */
15
16if (!defined('ACP_ROOT')) {
17 http_response_code(403);
18 die('Direct access denied.');
19}
20
21function acp_country_flag_emoji(string $code): string {
22 $code = strtoupper($code);
23 if (!preg_match('/^[A-Z]{2}$/', $code)) {
24 return '';
25 }
26 $flag = '';
27 foreach (str_split($code) as $letter) {
28 $flag .= mb_chr(0x1F1E6 + (ord($letter) - 65), 'UTF-8');
29 }
30 return $flag;
31}
32
33function acp_analytics_period_count(string $table, string $column, int $since): int {
34 if (!znote_table_exists($table) || !znote_column_exists($table, $column)) {
35 return 0;
36 }
37 return acp_count("SELECT COUNT(*) AS `c` FROM `{$table}` WHERE `{$column}` >= ?;", [$since]);
38}
39
40/** Recent lines in the PHP error log that came from a plugin hook. */
41function acp_analytics_plugin_errors(int $sinceDays): int {
42 $path = trim((string)(@ini_get('error_log') ?: ''));
43 if ($path === '' || !is_file($path) || !is_readable($path)) {
44 return 0;
45 }
46
47 $handle = @fopen($path, 'rb');
48 if ($handle === false) {
49 return 0;
50 }
51
52 $cutoff = time() - ($sinceDays * 86400);
53 $count = 0;
54 $pattern = '/\[(\d{2}-\w{3}-\d{4} \d{2}:\d{2}:\d{2})[^\]]*\].*\[ZnoteX plugin\]/';
55
56 while (($line = fgets($handle)) !== false) {
57 if (strpos($line, '[ZnoteX plugin]') === false) {
58 continue;
59 }
60 if (preg_match($pattern, $line, $m)) {
61 $ts = strtotime($m[1]);
62 if ($ts !== false && $ts < $cutoff) {
63 continue;
64 }
65 }
66 $count++;
67 }
68 fclose($handle);
69
70 return $count;
71}
72
73function znote_analytics_snapshot(): array {
74 $now = time();
75 $day = 86400;
76
77 $data = array();
78
79 // -------------------------------------------------------------- Accounts
80 $data['accounts_24h'] = acp_analytics_period_count('znote_accounts', 'created', $now - $day);
81 $data['accounts_7d'] = acp_analytics_period_count('znote_accounts', 'created', $now - 7 * $day);
82 $data['accounts_30d'] = acp_analytics_period_count('znote_accounts', 'created', $now - 30 * $day);
83
84 $data['accounts_daily'] = array();
85 if (znote_table_exists('znote_accounts')) {
86 $rows = db()->fetchAll("
87 SELECT FLOOR((? - `created`) / {$day}) AS `days_ago`, COUNT(*) AS `c`
88 FROM `znote_accounts`
89 WHERE `created` >= ?
90 GROUP BY `days_ago`;
91 ", [$now, $now - 14 * $day]);
92 $byDay = array();
93 foreach ((is_array($rows) ? $rows : array()) as $row) {
94 $byDay[(int)$row['days_ago']] = (int)$row['c'];
95 }
96 for ($i = 13; $i >= 0; $i--) {
97 $data['accounts_daily'][] = array('label' => date('M j', $now - $i * $day), 'count' => $byDay[$i] ?? 0);
98 }
99 }
100
101 // ---------------------------------------------------------- Active players
102 $data['active_24h'] = acp_analytics_period_count('players', 'lastlogin', $now - $day);
103 $data['active_7d'] = acp_analytics_period_count('players', 'lastlogin', $now - 7 * $day);
104 $data['active_30d'] = acp_analytics_period_count('players', 'lastlogin', $now - 30 * $day);
105
106 // New vs returning, last 30 days: a returning player logged in during the
107 // window but their account existed before it started.
108 $data['new_players_30d'] = 0;
109 $data['returning_players_30d'] = 0;
110 if (znote_table_exists('players') && znote_column_exists('players', 'lastlogin') && znote_table_exists('accounts') && znote_column_exists('accounts', 'created')) {
111 $windowStart = $now - 30 * $day;
112 $data['new_players_30d'] = acp_count("
113 SELECT COUNT(DISTINCT `p`.`id`) AS `c`
114 FROM `players` `p`
115 INNER JOIN `accounts` `a` ON `a`.`id` = `p`.`account_id`
116 WHERE `p`.`lastlogin` >= ? AND `a`.`created` >= ?;
117 ", [$windowStart, $windowStart]);
118 $data['returning_players_30d'] = acp_count("
119 SELECT COUNT(DISTINCT `p`.`id`) AS `c`
120 FROM `players` `p`
121 INNER JOIN `accounts` `a` ON `a`.`id` = `p`.`account_id`
122 WHERE `p`.`lastlogin` >= ? AND `a`.`created` < ?;
123 ", [$windowStart, $windowStart]);
124 }
125
126 // ------------------------------------------------------------- Peak online
127 $record = znote_record_get();
128 $data['peak_online'] = $record['players'];
129 $data['peak_online_at'] = $record['time'];
130 $data['online_now'] = znote_server_adapter()->onlineCount();
131
132 // --------------------------------------------------------- Shop / revenue
133 $data['orders_30d'] = acp_analytics_period_count('znote_shop_orders', 'time', $now - 30 * $day);
134
135 $data['revenue_30d'] = 0.0;
136 $data['revenue_currency'] = '';
137 $data['payments_failed_30d'] = 0;
138 $data['payments_pending_30d'] = 0;
139 if (znote_table_exists('znote_payment_transactions')) {
140 $since = $now - 30 * $day;
141 $paid = db()->fetchOne("
142 SELECT COALESCE(SUM(`price`), 0) AS `total`, MAX(`currency`) AS `currency`, COUNT(*) AS `c`
143 FROM `znote_payment_transactions`
144 WHERE `credited` = 1 AND `created_at` >= ?;
145 ", [$since]);
146 if (is_array($paid)) {
147 $data['revenue_30d'] = (float)$paid['total'];
148 $data['revenue_currency'] = (string)($paid['currency'] ?? '');
149 }
150 $data['payments_failed_30d'] = acp_count("
151 SELECT COUNT(*) AS `c` FROM `znote_payment_transactions`
152 WHERE `credited` = 0 AND `created_at` >= ?
153 AND `status` IN ('failed', 'declined', 'cancelled', 'expired', 'error', 'not_paid', 'not_approved');
154 ", [$since]);
155 $data['payments_pending_30d'] = acp_count("
156 SELECT COUNT(*) AS `c` FROM `znote_payment_transactions`
157 WHERE `credited` = 0 AND `created_at` >= ?
158 AND `status` NOT IN ('failed', 'declined', 'cancelled', 'expired', 'error', 'not_paid', 'not_approved');
159 ", [$since]);
160 }
161
162 // -------------------------------------------------------------- Countries
163 $data['top_countries'] = array();
164 if (znote_table_exists('znote_accounts') && znote_column_exists('znote_accounts', 'flag')) {
165 $rows = db()->fetchAll("
166 SELECT `flag`, COUNT(*) AS `c`
167 FROM `znote_accounts`
168 WHERE `flag` <> ''
169 GROUP BY `flag`
170 ORDER BY `c` DESC
171 LIMIT 10;
172 ");
173 $data['top_countries'] = is_array($rows) ? $rows : array();
174 }
175
176 // ---------------------------------------------------------------- Tickets
177 $data['tickets_open'] = acp_badge_helpdesk();
178 $data['tickets_30d'] = acp_analytics_period_count('znote_tickets', 'creation', $now - 30 * $day);
179
180 // ---------------------------------------------------------- Plugin errors
181 $data['plugin_errors_7d'] = acp_analytics_plugin_errors(7);
182
183 return $data;
184}
185
186$acp_analytics_cache = new Cache('engine/cache/acp_analytics');
187$acp_analytics_cache->setExpiration(300);
188
189if (isset($_GET['refresh'])) {
190 $acp_analytics_cache->delete();
191 acp_redirect('analytics');
192}
193
194if (!$acp_analytics_cache->hasExpired() && is_array($acp_analytics_cache->load())) {
195 $stats = $acp_analytics_cache->load();
196} else {
197 $stats = znote_analytics_snapshot();
198 $acp_analytics_cache->setContent($stats);
199 $acp_analytics_cache->save();
200}
201
202$maxDaily = max(1, ...array_map(static fn($d) => (int)$d['count'], $stats['accounts_daily'] ?: array(array('count' => 0))));
203?>
204
205<div class="acp-toolbar">
206 <div></div>
207 <div class="acp-actions is-tight">
208 <a class="acp-btn" href="<?= h(acp_url('analytics', array('refresh' => 1))) ?>"><i class="fa fa-refresh"></i> <?= t_default('acp.analytics.refresh', 'Refresh now') ?></a>
209 </div>
210</div>
211
212<div class="acp-stats">
213 <?php
214 acp_stat(t_default('acp.analytics.accounts_24h', 'Accounts (24h)'), $stats['accounts_24h'], 'fa-user-plus', null, 'blue');
215 acp_stat(t_default('acp.analytics.accounts_7d', 'Accounts (7d)'), $stats['accounts_7d'], 'fa-user-plus', null, 'blue');
216 acp_stat(t_default('acp.analytics.accounts_30d', 'Accounts (30d)'), $stats['accounts_30d'], 'fa-user-plus', null, 'blue');
217 acp_stat(t_default('acp.analytics.online_now', 'Online now'), $stats['online_now'], 'fa-signal', null, 'teal');
218 acp_stat(t_default('acp.analytics.peak_online', 'Peak online (all-time)'), $stats['peak_online'], 'fa-line-chart', null, 'green');
219 ?>
220</div>
221
222<div class="acp-grid acp-grid--2">
223
224 <section class="acp-card">
225 <header class="acp-card-head">
226 <h2><?= t_default('acp.analytics.accounts_daily_title', 'New accounts, last 14 days') ?></h2>
227 </header>
228 <div class="acp-card-body">
229 <div class="acp-table-wrap">
230 <table class="acp-table">
231 <tbody>
232 <?php foreach ($stats['accounts_daily'] as $day): ?>
233 <tr>
234 <td class="is-nowrap is-muted"><?= h($day['label']) ?></td>
235 <td style="width:100%;">
236 <div style="background:var(--acp-accent, #3b82f6); height:10px; border-radius:3px; width:<?= (int)round($day['count'] / $maxDaily * 100) ?>%;"></div>
237 </td>
238 <td class="is-num is-nowrap"><?= (int)$day['count'] ?></td>
239 </tr>
240 <?php endforeach; ?>
241 </tbody>
242 </table>
243 </div>
244 </div>
245 </section>
246
247 <section class="acp-card">
248 <header class="acp-card-head">
249 <h2><?= t_default('acp.analytics.activity_title', 'Player activity') ?></h2>
250 </header>
251 <div class="acp-card-body is-flush">
252 <div class="acp-table-wrap">
253 <table class="acp-table">
254 <tbody>
255 <tr><td><?= t_default('acp.analytics.active_24h', 'Active players (24h)') ?></td><td class="is-num"><?= (int)$stats['active_24h'] ?></td></tr>
256 <tr><td><?= t_default('acp.analytics.active_7d', 'Active players (7d)') ?></td><td class="is-num"><?= (int)$stats['active_7d'] ?></td></tr>
257 <tr><td><?= t_default('acp.analytics.active_30d', 'Active players (30d)') ?></td><td class="is-num"><?= (int)$stats['active_30d'] ?></td></tr>
258 <tr><td><?= t_default('acp.analytics.new_players', 'New players (30d)') ?></td><td class="is-num"><?= (int)$stats['new_players_30d'] ?></td></tr>
259 <tr><td><?= t_default('acp.analytics.returning_players', 'Returning players (30d)') ?></td><td class="is-num"><?= (int)$stats['returning_players_30d'] ?></td></tr>
260 </tbody>
261 </table>
262 </div>
263 </div>
264 </section>
265</div>
266
267<div class="acp-grid acp-grid--3">
268
269 <section class="acp-card">
270 <header class="acp-card-head"><h2><?= t_default('acp.analytics.shop_title', 'Shop (30d)') ?></h2></header>
271 <div class="acp-card-body is-flush">
272 <table class="acp-table">
273 <tbody>
274 <tr><td><?= t_default('acp.analytics.orders', 'Orders delivered') ?></td><td class="is-num"><?= (int)$stats['orders_30d'] ?></td></tr>
275 <tr><td><?= t_default('acp.analytics.revenue', 'Revenue') ?></td><td class="is-num"><?= number_format($stats['revenue_30d'], 2) ?> <?= h($stats['revenue_currency']) ?></td></tr>
276 <tr><td><?= t_default('acp.analytics.payments_pending', 'Payments pending') ?></td><td class="is-num"><?= (int)$stats['payments_pending_30d'] ?></td></tr>
277 <tr><td><?= t_default('acp.analytics.payments_failed', 'Payments failed') ?></td><td class="is-num"><span class="acp-pill <?= $stats['payments_failed_30d'] > 0 ? 'acp-pill--red' : 'acp-pill--green' ?>"><?= (int)$stats['payments_failed_30d'] ?></span></td></tr>
278 </tbody>
279 </table>
280 </div>
281 </section>
282
283 <section class="acp-card">
284 <header class="acp-card-head"><h2><?= t_default('acp.analytics.top_countries', 'Top countries') ?></h2></header>
285 <div class="acp-card-body is-flush">
286 <?php if ($stats['top_countries']): ?>
287 <table class="acp-table">
288 <tbody>
289 <?php foreach ($stats['top_countries'] as $row): ?>
290 <tr>
291 <td><?= h(acp_country_flag_emoji((string)$row['flag'])) ?> <?= h(strtoupper((string)$row['flag'])) ?></td>
292 <td class="is-num"><?= (int)$row['c'] ?></td>
293 </tr>
294 <?php endforeach; ?>
295 </tbody>
296 </table>
297 <?php else: ?>
298 <?php acp_empty(t_default('acp.analytics.no_countries', 'No data yet.'), 'fa-globe'); ?>
299 <?php endif; ?>
300 </div>
301 </section>
302
303 <section class="acp-card">
304 <header class="acp-card-head"><h2><?= t_default('acp.analytics.support_title', 'Support & health') ?></h2></header>
305 <div class="acp-card-body is-flush">
306 <table class="acp-table">
307 <tbody>
308 <tr><td><?= t_default('acp.analytics.tickets_open', 'Open tickets') ?></td><td class="is-num"><?= (int)$stats['tickets_open'] ?></td></tr>
309 <tr><td><?= t_default('acp.analytics.tickets_30d', 'Tickets opened (30d)') ?></td><td class="is-num"><?= (int)$stats['tickets_30d'] ?></td></tr>
310 <tr><td><?= t_default('acp.analytics.plugin_errors', 'Plugin errors (7d)') ?></td><td class="is-num"><span class="acp-pill <?= $stats['plugin_errors_7d'] > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$stats['plugin_errors_7d'] ?></span></td></tr>
311 </tbody>
312 </table>
313 </div>
314 </section>
315</div>
316