1<?php
2/**
3 * Title: Dashboard
4 * Icon: fa-tachometer
5 * Group: Overview
6 * Order: 10
7 * Description: Server and community at a glance.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15// OTHIRE has no accounts.name column - it identifies accounts by number.
16$accNameCol = znote_server_adapter()->accountDisplayColumn();
17
18// ---------------------------------------------------------------------------
19// Counters. acp_count() returns 0 for a table this engine does not have,
20// so an unusual schema degrades to a zero instead of a fatal error.
21// ---------------------------------------------------------------------------
22$statAccounts = acp_count("SELECT COUNT(*) AS `c` FROM `accounts`;");
23$statPlayers = acp_count("SELECT COUNT(*) AS `c` FROM `players`;");
24$statGuilds = acp_count("SELECT COUNT(*) AS `c` FROM `guilds`;");
25$statHouses = acp_count("SELECT COUNT(*) AS `c` FROM `houses`;");
26
27$statOnline = znote_server_adapter()->onlineCount();
28
29$statPoints = acp_count("SELECT COALESCE(SUM(`points`), 0) AS `c` FROM `znote_accounts`;");
30$statOrders = acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;");
31
32// Moderation queues - reuse the same counters the sidebar badges use.
33$queueReports = acp_badge_reports();
34$queueTickets = acp_badge_helpdesk();
35$queueImages = acp_badge_gallery();
36$queueFeedback = acp_badge_feedback();
37
38// ---------------------------------------------------------------------------
39// Recent activity
40// ---------------------------------------------------------------------------
41$latestAccounts = db()->fetchAll("
42 SELECT `a`.`id` AS `account_id`, {$accNameCol} AS `account_name`, `za`.`created`, `za`.`points`
43 FROM `znote_accounts` `za`
44 INNER JOIN `accounts` `a` ON `a`.`id` = `za`.`account_id`
45 ORDER BY `za`.`created` DESC
46 LIMIT 10;
47");
48
49$latestPlayers = db()->fetchAll("
50 SELECT `name`, `level`, `vocation`
51 FROM `players`
52 ORDER BY `id` DESC
53 LIMIT 10;
54");
55
56$topPoints = db()->fetchAll("
57 SELECT `a`.`id` AS `account_id`, {$accNameCol} AS `account_name`, `za`.`points`
58 FROM `znote_accounts` `za`
59 INNER JOIN `accounts` `a` ON `a`.`id` = `za`.`account_id`
60 WHERE `za`.`points` > 0
61 ORDER BY `za`.`points` DESC
62 LIMIT 10;
63");
64
65$recentShopPurchases = znote_table_exists('znote_shop_logs')
66 ? db()->fetchAll("
67 SELECT `l`.`account_id`, {$accNameCol} AS `account_name`, `l`.`itemid`, `l`.`type`, `l`.`count`, `l`.`points`, `l`.`time`
68 FROM `znote_shop_logs` `l`
69 LEFT JOIN `accounts` `a` ON `a`.`id` = `l`.`account_id`
70 ORDER BY `l`.`id` DESC
71 LIMIT 8;
72 ")
73 : false;
74$recentShopPurchases = is_array($recentShopPurchases) ? $recentShopPurchases : array();
75
76$shopItemNames = function_exists('getItemList') ? getItemList() : array();
77$shopOrderTypes = array(
78 1 => t('acp.sord.type_item'),
79 2 => t('acp.sord.type_premium'),
80 3 => t('acp.sord.type_gender'),
81 4 => t('acp.sord.type_name'),
82 5 => t('acp.sord.type_outfit'),
83 6 => t('acp.sord.type_mount'),
84 7 => t('acp.sord.type_custom'),
85 8 => t('acp.sord.type_custom'),
86);
87
88$recentPointsPurchases = znote_table_exists('znote_payment_transactions')
89 ? db()->fetchAll("
90 SELECT `t`.`account_id`, {$accNameCol} AS `account_name`, `t`.`provider`, `t`.`price`, `t`.`currency`, `t`.`points`, `t`.`credited`, `t`.`created_at`
91 FROM `znote_payment_transactions` `t`
92 LEFT JOIN `accounts` `a` ON `a`.`id` = `t`.`account_id`
93 ORDER BY `t`.`id` DESC
94 LIMIT 8;
95 ")
96 : false;
97$recentPointsPurchases = is_array($recentPointsPurchases) ? $recentPointsPurchases : array();
98
99// znote row: keep the stored version in step with the running one, the way
100// the old admin.php did on every visit.
101$znote = user_znote_data('version', 'installed', 'cached');
102if (is_array($znote) && ($znote['version'] ?? null) !== $version) {
103 $oldVersion = (string)($znote['version'] ?? '');
104 db()->execute("UPDATE `znote` SET `version` = ?;", [$version]);
105 acp_log('system.version_sync', $version, ['from' => $oldVersion]);
106 $znote['version'] = $version;
107}
108
109$obSteps = array(
110 array(
111 'done' => !empty($config['twoFactorAuthenticator']),
112 'label' => t_default('acp.dash.ob_2fa', 'Enable two-factor authentication'),
113 'url' => acp_url('settings') . '#set_twoFactorAuthenticator',
114 ),
115 array(
116 'done' => function_exists('znote_backups_list') && count(znote_backups_list()) > 0,
117 'label' => t_default('acp.dash.ob_backup', 'Create your first backup'),
118 'url' => acp_url('backups'),
119 ),
120 array(
121 'done' => function_exists('scheduler_enabled') && scheduler_enabled('backups'),
122 'label' => t_default('acp.dash.ob_scheduler', 'Turn on automatic backups'),
123 'url' => acp_url('scheduler'),
124 ),
125 array(
126 'done' => function_exists('znote_migrations_pending') && !znote_migrations_pending(),
127 'label' => t_default('acp.dash.ob_migrations', 'Apply pending database migrations'),
128 'url' => acp_url('migrations'),
129 ),
130);
131$obRemaining = count(array_filter($obSteps, static fn(array $s): bool => !$s['done']));
132?>
133
134<div class="acp-stats">
135 <?php
136 acp_stat(t('acp.dash.stat_accounts'), $statAccounts, 'fa-user-plus', null, 'blue');
137 acp_stat(t('acp.dash.stat_characters'), $statPlayers, 'fa-users', null, 'red');
138 acp_stat(t('acp.dash.stat_online'), $statOnline, 'fa-signal', null, 'teal');
139 acp_stat(t('acp.dash.stat_guilds'), $statGuilds, 'fa-shield', null, 'green');
140 acp_stat(t('acp.dash.stat_houses'), $statHouses, 'fa-home', null, 'amber');
141 acp_stat(t('acp.dash.stat_points'), $statPoints, 'fa-diamond', acp_url('shop'), 'purple');
142 ?>
143</div>
144
145<?php if ($obRemaining > 0): ?>
146 <section class="acp-card">
147 <header class="acp-card-head">
148 <h2><?= t_default('acp.dash.ob_title', 'Getting started') ?></h2>
149 <p><?= h(t_default('acp.dash.ob_sub', '{n} step(s) left - this card goes away once they are all done.', ['n' => $obRemaining])) ?></p>
150 </header>
151 <div class="acp-card-body is-flush">
152 <div class="acp-table-wrap">
153 <table class="acp-table">
154 <tbody>
155 <?php foreach ($obSteps as $step): ?>
156 <tr>
157 <td>
158 <i class="fa <?= $step['done'] ? 'fa-check-circle' : 'fa-circle-o' ?> is-muted"></i>
159 <?= h($step['label']) ?>
160 </td>
161 <td class="is-num">
162 <span class="acp-pill <?= $step['done'] ? 'acp-pill--green' : 'acp-pill--amber' ?>">
163 <?= $step['done'] ? t('acp.dash.enabled') : t_default('acp.dash.ob_todo', 'To do') ?>
164 </span>
165 </td>
166 <td class="is-nowrap is-num">
167 <?php if (!$step['done']): ?>
168 <a href="<?= h($step['url']) ?>"><?= t('acp.dash.review') ?></a>
169 <?php endif; ?>
170 </td>
171 </tr>
172 <?php endforeach; ?>
173 </tbody>
174 </table>
175 </div>
176 </div>
177 </section>
178<?php endif; ?>
179
180<div class="acp-grid acp-grid--2">
181
182 <!-- ------------------------------------------------ Moderation queue -->
183 <section class="acp-card">
184 <header class="acp-card-head">
185 <h2><?= t('acp.dash.attention_title') ?></h2>
186 <p><?= t('acp.dash.attention_sub') ?></p>
187 </header>
188 <div class="acp-card-body is-flush">
189 <div class="acp-table-wrap">
190 <table class="acp-table">
191 <tbody>
192 <tr>
193 <td><i class="fa fa-bug is-muted"></i> <?= t('acp.dash.bug_reports') ?></td>
194 <td class="is-num">
195 <span class="acp-pill <?= $queueReports > 0 ? 'acp-pill--red' : 'acp-pill--green' ?>"><?= (int)$queueReports ?></span>
196 </td>
197 <td class="is-nowrap is-num"><a href="<?= h(acp_url('reports')) ?>"><?= t('acp.dash.review') ?></a></td>
198 </tr>
199 <tr>
200 <td><i class="fa fa-life-ring is-muted"></i> <?= t('acp.dash.helpdesk_open') ?></td>
201 <td class="is-num">
202 <span class="acp-pill <?= $queueTickets > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueTickets ?></span>
203 </td>
204 <td class="is-nowrap is-num"><a href="<?= h(acp_url('helpdesk')) ?>"><?= t('acp.dash.review') ?></a></td>
205 </tr>
206 <tr>
207 <td><i class="fa fa-picture-o is-muted"></i> <?= t('acp.dash.images_pending') ?></td>
208 <td class="is-num">
209 <span class="acp-pill <?= $queueImages > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueImages ?></span>
210 </td>
211 <td class="is-nowrap is-num"><a href="<?= h(acp_url('gallery')) ?>"><?= t('acp.dash.review') ?></a></td>
212 </tr>
213 <tr>
214 <td><i class="fa fa-comments-o is-muted"></i> <?= t('acp.dash.feedback_open') ?></td>
215 <td class="is-num">
216 <span class="acp-pill <?= $queueFeedback > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueFeedback ?></span>
217 </td>
218 <td class="is-nowrap is-num"><a href="<?= h(acp_site('forum.php?cat=4')) ?>"><?= t('acp.dash.open') ?></a></td>
219 </tr>
220 <tr>
221 <td><i class="fa fa-shopping-cart is-muted"></i> <?= t('acp.dash.orders_pending') ?></td>
222 <td class="is-num">
223 <span class="acp-pill <?= $statOrders > 0 ? 'acp-pill--blue' : 'acp-pill--green' ?>"><?= (int)$statOrders ?></span>
224 </td>
225 <td class="is-nowrap is-num"><a href="<?= h(acp_url('shop_orders')) ?>"><?= t('acp.dash.open') ?></a></td>
226 </tr>
227 </tbody>
228 </table>
229 </div>
230 </div>
231 </section>
232
233 <!-- ------------------------------------------------------ Environment -->
234 <section class="acp-card">
235 <header class="acp-card-head">
236 <h2><?= t('acp.dash.env_title') ?></h2>
237 <p><?= t('acp.dash.env_sub') ?></p>
238 </header>
239 <div class="acp-card-body">
240 <dl class="acp-dl">
241 <dt>ZnoteX</dt>
242 <dd><?= h($version) ?><?= is_array($znote) && isset($znote['version']) ? '' : ' <span class="acp-pill acp-pill--red">'. t('acp.dash.znote_table_missing') .'</span>' ?></dd>
243
244 <dt>PHP</dt>
245 <dd><?= h(PHP_VERSION) ?></dd>
246
247 <dt><?= t('acp.dash.server_engine') ?></dt>
248 <dd><span class="acp-pill acp-pill--blue"><?= h(serverEngineReal()) ?></span></dd>
249
250 <dt><?= t('acp.dash.database') ?></dt>
251 <dd><?= h($config['sqlDatabase'] ?? '') ?> @ <?= h($config['sqlHost'] ?? '') ?></dd>
252
253 <dt><?= t('acp.dash.site_url') ?></dt>
254 <dd><a href="<?= h($config['site_url'] ?? '#') ?>" target="_blank" rel="noopener"><?= h($config['site_url'] ?? '') ?></a></dd>
255
256 <dt><?= t('acp.dash.installed') ?></dt>
257 <dd><?= is_array($znote) && !empty($znote['installed']) ? h(getClock((int)$znote['installed'], true)) : '—' ?></dd>
258
259 <dt><?= t('acp.dash.last_cache') ?></dt>
260 <dd><?= is_array($znote) && !empty($znote['cached']) ? h(getClock((int)$znote['cached'], true)) : '—' ?></dd>
261
262 <dt><?= t('acp.dash.two_factor') ?></dt>
263 <dd><?= !empty($config['twoFactorAuthenticator'])
264 ? '<span class="acp-pill acp-pill--green">'. t('acp.dash.enabled') .'</span>'
265 : '<span class="acp-pill acp-pill--grey">'. t('acp.dash.disabled') .'</span>' ?></dd>
266 </dl>
267 </div>
268 </section>
269</div>
270
271<div class="acp-grid acp-grid--3">
272
273 <!-- ------------------------------------------------- Latest accounts -->
274 <section class="acp-card">
275 <header class="acp-card-head"><h2><?= t('acp.dash.newest_accounts') ?></h2></header>
276 <div class="acp-card-body is-flush">
277 <?php if (is_array($latestAccounts) && $latestAccounts): ?>
278 <div class="acp-table-wrap">
279 <table class="acp-table">
280 <thead>
281 <tr><th><?= t('acp.dash.col_account') ?></th><th><?= t('acp.dash.col_created') ?></th><th class="is-num"><?= t('acp.dash.col_points') ?></th></tr>
282 </thead>
283 <tbody>
284 <?php foreach ($latestAccounts as $row): ?>
285 <tr>
286 <td><?= h($row['account_name']) ?></td>
287 <td class="is-nowrap is-muted"><?= h(getClock((int)$row['created'], true)) ?></td>
288 <td class="is-num"><?= (int)$row['points'] ?></td>
289 </tr>
290 <?php endforeach; ?>
291 </tbody>
292 </table>
293 </div>
294 <?php else: ?>
295 <?php acp_empty(t('acp.dash.no_accounts'), 'fa-user-o'); ?>
296 <?php endif; ?>
297 </div>
298 </section>
299
300 <!-- -------------------------------------------------- Latest players -->
301 <section class="acp-card">
302 <header class="acp-card-head"><h2><?= t('acp.dash.newest_characters') ?></h2></header>
303 <div class="acp-card-body is-flush">
304 <?php if (is_array($latestPlayers) && $latestPlayers): ?>
305 <div class="acp-table-wrap">
306 <table class="acp-table">
307 <thead>
308 <tr><th><?= t('acp.dash.col_name') ?></th><th><?= t('acp.dash.col_vocation') ?></th><th class="is-num"><?= t('acp.dash.col_level') ?></th></tr>
309 </thead>
310 <tbody>
311 <?php foreach ($latestPlayers as $row): ?>
312 <tr>
313 <td>
314 <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$row['name']))) ?>" target="_blank" rel="noopener">
315 <?= h($row['name']) ?>
316 </a>
317 </td>
318 <td class="is-muted"><?= h(vocation_id_to_name((int)$row['vocation'])) ?></td>
319 <td class="is-num"><?= (int)$row['level'] ?></td>
320 </tr>
321 <?php endforeach; ?>
322 </tbody>
323 </table>
324 </div>
325 <?php else: ?>
326 <?php acp_empty(t('acp.dash.no_characters'), 'fa-user-o'); ?>
327 <?php endif; ?>
328 </div>
329 </section>
330
331 <!-- ---------------------------------------------------- Top balances -->
332 <section class="acp-card">
333 <header class="acp-card-head"><h2><?= t('acp.dash.top_balances') ?></h2></header>
334 <div class="acp-card-body is-flush">
335 <?php if (is_array($topPoints) && $topPoints): ?>
336 <div class="acp-table-wrap">
337 <table class="acp-table">
338 <thead>
339 <tr><th>#</th><th><?= t('acp.dash.col_account') ?></th><th class="is-num"><?= t('acp.dash.col_points') ?></th></tr>
340 </thead>
341 <tbody>
342 <?php $rank = 0; foreach ($topPoints as $row): $rank++; ?>
343 <tr>
344 <td class="is-muted"><?= $rank ?></td>
345 <td><?= h($row['account_name']) ?></td>
346 <td class="is-num"><strong><?= number_format((int)$row['points']) ?></strong></td>
347 </tr>
348 <?php endforeach; ?>
349 </tbody>
350 </table>
351 </div>
352 <?php else: ?>
353 <?php acp_empty(t('acp.dash.no_points'), 'fa-diamond'); ?>
354 <?php endif; ?>
355 </div>
356 </section>
357</div>
358
359<div class="acp-grid acp-grid--2">
360
361 <!-- ------------------------------------------- Recent shop purchases -->
362 <section class="acp-card">
363 <header class="acp-card-head"><h2><?= t_default('acp.dash.recent_shop', 'Recent Shop Purchases') ?></h2></header>
364 <div class="acp-card-body is-flush">
365 <?php if ($recentShopPurchases): ?>
366 <div class="acp-table-wrap">
367 <table class="acp-table">
368 <thead>
369 <tr>
370 <th><?= t('acp.dash.col_account') ?></th>
371 <th><?= t('acp.sord.col_item') ?></th>
372 <th class="is-num"><?= t('acp.sord.col_points') ?></th>
373 <th><?= t('acp.sord.col_date') ?></th>
374 </tr>
375 </thead>
376 <tbody>
377 <?php foreach ($recentShopPurchases as $row):
378 $itemId = intv($row['itemid'] ?? 0);
379 ?>
380 <tr>
381 <td><?= $row['account_name'] !== null ? h($row['account_name']) : '<span class="is-muted">'. t('acp.sord.deleted_account', ['id' => (int)($row['account_id'] ?? 0)]) .'</span>' ?></td>
382 <td>
383 <?php if ($itemId > 0): ?>
384 <?= h($shopItemNames[$itemId] ?? t('acp.sord.item_unknown')) ?>
385 <?php else: ?>
386 <span class="acp-pill acp-pill--grey"><?= h($shopOrderTypes[(int)($row['type'] ?? 0)] ?? t('acp.sord.type_unknown')) ?></span>
387 <?php endif; ?>
388 </td>
389 <td class="is-num"><?= (int)($row['points'] ?? 0) ?></td>
390 <td class="is-nowrap is-muted"><?= h(getClock((int)($row['time'] ?? 0), true)) ?></td>
391 </tr>
392 <?php endforeach; ?>
393 </tbody>
394 </table>
395 </div>
396 <?php else: ?>
397 <?php acp_empty(t('acp.sord.history_empty'), 'fa-shopping-cart'); ?>
398 <?php endif; ?>
399 </div>
400 </section>
401
402 <!-- ----------------------------------------- Recent points purchases -->
403 <section class="acp-card">
404 <header class="acp-card-head"><h2><?= t_default('acp.dash.recent_points', 'Recent Points Purchases') ?></h2></header>
405 <div class="acp-card-body is-flush">
406 <?php if ($recentPointsPurchases): ?>
407 <div class="acp-table-wrap">
408 <table class="acp-table">
409 <thead>
410 <tr>
411 <th><?= t('acp.dash.col_account') ?></th>
412 <th><?= t_default('acp.dash.col_provider', 'Provider') ?></th>
413 <th class="is-num"><?= t_default('acp.dash.col_amount', 'Amount') ?></th>
414 <th class="is-num"><?= t('acp.dash.col_points') ?></th>
415 <th><?= t('acp.sord.col_date') ?></th>
416 </tr>
417 </thead>
418 <tbody>
419 <?php foreach ($recentPointsPurchases as $row): ?>
420 <tr>
421 <td><?= $row['account_name'] !== null ? h($row['account_name']) : '<span class="is-muted">'. t('acp.sord.deleted_account', ['id' => (int)($row['account_id'] ?? 0)]) .'</span>' ?></td>
422 <td><?= h(ucfirst((string)($row['provider'] ?? ''))) ?></td>
423 <td class="is-num"><?= h(number_format((float)($row['price'] ?? 0), 2, '.', '')) ?> <?= h((string)($row['currency'] ?? '')) ?></td>
424 <td class="is-num">
425 <strong><?= (int)($row['points'] ?? 0) ?></strong>
426 <?= !empty($row['credited']) ? ' <span class="acp-pill acp-pill--green">'. t_default('acp.dash.credited', 'Credited') .'</span>' : ' <span class="acp-pill acp-pill--amber">'. t_default('acp.dash.pending', 'Pending') .'</span>' ?>
427 </td>
428 <td class="is-nowrap is-muted"><?= !empty($row['created_at']) ? h(getClock((int)$row['created_at'], true)) : '—' ?></td>
429 </tr>
430 <?php endforeach; ?>
431 </tbody>
432 </table>
433 </div>
434 <?php else: ?>
435 <?php acp_empty(t_default('acp.dash.no_points_purchases', 'No point purchases yet.'), 'fa-credit-card'); ?>
436 <?php endif; ?>
437 </div>
438 </section>
439
440</div>
441