1<?php
2/**
3 * Title: Visitors
4 * Icon: fa-line-chart
5 * Group: Overview
6 * Order: 20
7 * Description: Traffic ZnoteX has been recording all along.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15/**
16 * ZnoteX has written to znote_visitors and znote_visitors_details on every page
17 * load since forever, and until now nothing read them back - a SQL write per
18 * visitor, for nobody. This page is that data, finally shown.
19 *
20 * Turn the collection off entirely in Settings > Privacy if you would rather
21 * not keep it.
22 */
23
24// The type column, as written by znote_visitor_insert_detailed_data().
25function acp_visit_types(): array {
26 return array(
27 0 => t('acp.vis.type_pageview'),
28 1 => t('acp.vis.type_register'),
29 2 => t('acp.vis.type_char_created'),
30 3 => t('acp.vis.type_highscores'),
31 4 => t('acp.vis.type_char_search'),
32 5 => t('acp.vis.type_other'),
33 );
34}
35
36$days = intv($_GET['days'] ?? 7);
37if (!in_array($days, array(1, 7, 30, 90), true)) {
38 $days = 7;
39}
40$since = time() - ($days * 86400);
41
42$collecting = !empty($config['log_ip']);
43
44$totalRows = acp_count("SELECT COUNT(*) AS `c` FROM `znote_visitors_details`;");
45$uniqueAll = acp_count("SELECT COUNT(DISTINCT `ip`) AS `c` FROM `znote_visitors_details`;");
46$rowsPeriod = acp_count("SELECT COUNT(*) AS `c` FROM `znote_visitors_details` WHERE `time` >= ?;", [$since]);
47$uniquePeriod= acp_count("SELECT COUNT(DISTINCT `ip`) AS `c` FROM `znote_visitors_details` WHERE `time` >= ?;", [$since]);
48
49// Visits per day
50$perDay = db()->fetchAll("
51 SELECT FROM_UNIXTIME(`time`, '%Y-%m-%d') AS `day`,
52 COUNT(*) AS `hits`,
53 COUNT(DISTINCT `ip`) AS `uniques`
54 FROM `znote_visitors_details`
55 WHERE `time` >= ?
56 GROUP BY `day`
57 ORDER BY `day` DESC;
58", [$since]);
59$perDay = is_array($perDay) ? $perDay : array();
60
61// Breakdown by what people did
62$byType = db()->fetchAll("
63 SELECT `type`, COUNT(*) AS `hits`
64 FROM `znote_visitors_details`
65 WHERE `time` >= ?
66 GROUP BY `type`
67 ORDER BY `hits` DESC;
68", [$since]);
69$byType = is_array($byType) ? $byType : array();
70
71// Busiest addresses
72$topIps = db()->fetchAll("
73 SELECT `ip`, COUNT(*) AS `hits`, MAX(`time`) AS `last`, MAX(`account_id`) AS `account_id`
74 FROM `znote_visitors_details`
75 WHERE `time` >= ?
76 GROUP BY `ip`
77 ORDER BY `hits` DESC
78 LIMIT 15;
79", [$since]);
80$topIps = is_array($topIps) ? $topIps : array();
81
82$peak = 0;
83foreach ($perDay as $row) {
84 $peak = max($peak, (int)$row['hits']);
85}
86?>
87
88<?php if (!$collecting): ?>
89 <div class="acp-flash acp-flash--info">
90 <i class="fa fa-info-circle"></i>
91 <span>
92 <?= t('acp.vis.off_notice', [
93 'off' => '<strong>' . t('acp.vis.off') . '</strong>',
94 'link' => '<a href="' . h(acp_url('settings')) . '">' . t('acp.vis.settings_privacy') . '</a>',
95 ]) ?>
96 </span>
97 </div>
98<?php endif; ?>
99
100<div class="acp-toolbar">
101 <div class="acp-actions is-tight">
102 <?php foreach (array(1 => t('acp.vis.today'), 7 => t('acp.vis.7days'), 30 => t('acp.vis.30days'), 90 => t('acp.vis.90days')) as $d => $label): ?>
103 <a class="acp-btn <?= $d === $days ? '' : 'acp-btn--ghost' ?> acp-btn--sm"
104 href="<?= h(acp_url('visitors', array('days' => $d))) ?>"><?= h($label) ?></a>
105 <?php endforeach; ?>
106 </div>
107 <span class="is-muted"><?= t('acp.vis.events_recorded', ['n' => number_format($totalRows)]) ?></span>
108</div>
109
110<div class="acp-stats">
111 <?php
112 acp_stat(t('acp.vis.stat_visits'), $rowsPeriod, 'fa-eye', null, 'blue');
113 acp_stat(t('acp.vis.stat_unique_period'), $uniquePeriod, 'fa-users', null, 'teal');
114 acp_stat(t('acp.vis.stat_unique_all'), $uniqueAll, 'fa-globe', null, 'purple');
115 acp_stat(t('acp.vis.stat_events_all'), $totalRows, 'fa-database', null, 'green');
116 ?>
117</div>
118
119<div class="acp-grid acp-grid--2">
120
121 <section class="acp-card">
122 <header class="acp-card-head">
123 <h2><?= t('acp.vis.per_day') ?></h2>
124 <p><?= t('acp.vis.last_n_days', ['n' => (int)$days]) ?></p>
125 </header>
126 <div class="acp-card-body is-flush">
127 <?php if ($perDay): ?>
128 <div class="acp-table-wrap">
129 <table class="acp-table">
130 <thead>
131 <tr><th><?= t('acp.vis.col_day') ?></th><th class="is-num"><?= t('acp.vis.col_visits') ?></th><th class="is-num"><?= t('acp.vis.col_unique') ?></th><th> </th></tr>
132 </thead>
133 <tbody>
134 <?php foreach ($perDay as $row):
135 $hits = (int)$row['hits'];
136 $pct = $peak > 0 ? round(($hits / $peak) * 100) : 0;
137 ?>
138 <tr>
139 <td class="is-nowrap"><?= h((string)$row['day']) ?></td>
140 <td class="is-num"><strong><?= number_format($hits) ?></strong></td>
141 <td class="is-num is-muted"><?= number_format((int)$row['uniques']) ?></td>
142 <td style="width:45%;">
143 <div style="height:8px;border-radius:4px;background:var(--acp-panel-2);overflow:hidden;">
144 <div style="height:8px;width:<?= $pct ?>%;background:var(--acp-blue);"></div>
145 </div>
146 </td>
147 </tr>
148 <?php endforeach; ?>
149 </tbody>
150 </table>
151 </div>
152 <?php else: ?>
153 <?php acp_empty(t('acp.vis.empty'), 'fa-line-chart'); ?>
154 <?php endif; ?>
155 </div>
156 </section>
157
158 <section class="acp-card">
159 <header class="acp-card-head">
160 <h2><?= t('acp.vis.what_they_did') ?></h2>
161 <p><?= t('acp.vis.last_n_days', ['n' => (int)$days]) ?></p>
162 </header>
163 <div class="acp-card-body is-flush">
164 <?php if ($byType): ?>
165 <div class="acp-table-wrap">
166 <table class="acp-table">
167 <thead><tr><th><?= t('acp.vis.col_action') ?></th><th class="is-num"><?= t('acp.vis.col_count') ?></th></tr></thead>
168 <tbody>
169 <?php foreach ($byType as $row): ?>
170 <tr>
171 <td><?= h(acp_visit_types()[(int)$row['type']] ?? t('acp.vis.type_n', ['n' => (int)$row['type']])) ?></td>
172 <td class="is-num"><?= number_format((int)$row['hits']) ?></td>
173 </tr>
174 <?php endforeach; ?>
175 </tbody>
176 </table>
177 </div>
178 <?php else: ?>
179 <?php acp_empty(t('acp.vis.empty'), 'fa-list'); ?>
180 <?php endif; ?>
181 </div>
182 </section>
183</div>
184
185<section class="acp-card">
186 <header class="acp-card-head">
187 <h2><?= t('acp.vis.busiest') ?></h2>
188 <p><?= t('acp.vis.busiest_sub') ?></p>
189 </header>
190 <div class="acp-card-body is-flush">
191 <?php if ($topIps): ?>
192 <div class="acp-table-wrap">
193 <table class="acp-table">
194 <thead>
195 <tr><th><?= t('acp.vis.col_ip') ?></th><th class="is-num"><?= t('acp.vis.col_visits') ?></th><th><?= t('acp.vis.col_last_seen') ?></th><th><?= t('acp.vis.col_account') ?></th></tr>
196 </thead>
197 <tbody>
198 <?php foreach ($topIps as $row):
199 $accountId = (int)$row['account_id'];
200 ?>
201 <tr>
202 <td class="is-nowrap"><code><?= h(long2ip((int)$row['ip'])) ?></code></td>
203 <td class="is-num"><strong><?= number_format((int)$row['hits']) ?></strong></td>
204 <td class="is-nowrap is-muted"><?= h(getClock((int)$row['last'], true)) ?></td>
205 <td>
206 <?php if ($accountId > 0): ?>
207 <a href="<?= h(acp_url('accounts', array('id' => $accountId))) ?>">#<?= $accountId ?></a>
208 <?php else: ?>
209 <span class="is-muted">—</span>
210 <?php endif; ?>
211 </td>
212 </tr>
213 <?php endforeach; ?>
214 </tbody>
215 </table>
216 </div>
217 <?php else: ?>
218 <?php acp_empty(t('acp.vis.empty'), 'fa-globe'); ?>
219 <?php endif; ?>
220 </div>
221</section>
222