Skip to content

Commit 68d222b

Browse files
committed
Implement admin analytics dashboard
1 parent 26cb050 commit 68d222b

File tree

8 files changed

+366
-0
lines changed

8 files changed

+366
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace BNETDocs\Controllers\Analytics;
4+
5+
use \BNETDocs\Libraries\Core\HttpCode;
6+
use \DateTimeImmutable;
7+
use \DateTimeInterface;
8+
use \PDOStatement;
9+
10+
class Dashboard extends \BNETDocs\Controllers\Base
11+
{
12+
public function __construct()
13+
{
14+
$this->model = new \BNETDocs\Models\Analytics\Dashboard();
15+
}
16+
17+
public function invoke(?array $args): bool
18+
{
19+
$this->model->acl_allowed = ($this->model->active_user && $this->model->active_user->getOption(
20+
\BNETDocs\Libraries\User\User::OPTION_ACL_EVENT_LOG_VIEW // TODO : Use correct ACL permission bit
21+
));
22+
23+
if (!$this->model->acl_allowed)
24+
{
25+
$this->model->_responseCode = HttpCode::HTTP_FORBIDDEN;
26+
return true;
27+
}
28+
29+
$this->handleDateRange();
30+
$this->getAnalytics();
31+
$this->model->_responseCode = HttpCode::HTTP_OK;
32+
return true;
33+
}
34+
35+
private function getAnalytics(): void
36+
{
37+
$tables = [
38+
'comments' => 'count_comments',
39+
'documents' => 'count_documents',
40+
'event_log' => 'count_event_log',
41+
'news_posts' => 'count_news_posts',
42+
'packets' => 'count_packets',
43+
'servers' => 'count_servers',
44+
'tags' => 'count_tags',
45+
'user_profiles' => 'count_user_profiles',
46+
'users' => 'count_users',
47+
];
48+
49+
foreach ($tables as $table => $property)
50+
{
51+
$this->model->$property = $this->countTableRows($table);
52+
}
53+
54+
$start = $this->model->date_start;
55+
$end = $this->model->date_end;
56+
57+
if ($start && $end)
58+
{
59+
$timestamp_columns = [
60+
'comments' => 'created_datetime',
61+
'documents' => 'created_datetime',
62+
'event_log' => 'event_datetime',
63+
'news_posts' => 'created_datetime',
64+
'packets' => 'created_datetime',
65+
'servers' => 'created_datetime',
66+
'users' => 'created_datetime',
67+
// excluded: 'tags', 'user_profiles'
68+
];
69+
70+
foreach ($timestamp_columns as $table => $timestamp_column)
71+
{
72+
$property = 'interval_new_' . $table;
73+
$this->model->$property = $this->countNewRowsInRange($table, $timestamp_column, $start, $end);
74+
}
75+
}
76+
}
77+
78+
private function countNewRowsInRange(
79+
string $table, string $timestamp_column, DateTimeInterface $start, DateTimeInterface $end
80+
): int
81+
{
82+
$pdo = \BNETDocs\Libraries\Db\MariaDb::instance();
83+
$count = 0;
84+
85+
$query = "SELECT COUNT(*) FROM `{$table}` WHERE `{$timestamp_column}` BETWEEN :start AND :end;";
86+
87+
$params = [
88+
':start' => $start->format('Y-m-d 00:00:00'),
89+
':end' => $end->format('Y-m-d 23:59:59'),
90+
];
91+
92+
try
93+
{
94+
$stmt = $pdo->prepare($query);
95+
if ($stmt && $stmt->execute($params) && $stmt->rowCount() === 1)
96+
{
97+
$count = (int) $stmt->fetchColumn();
98+
}
99+
}
100+
finally
101+
{
102+
if (isset($stmt) && $stmt instanceof PDOStatement) $stmt->closeCursor();
103+
}
104+
105+
return $count;
106+
}
107+
108+
private function countTableRows(string $table): int
109+
{
110+
$pdo = \BNETDocs\Libraries\Db\MariaDb::instance();
111+
$count = 0;
112+
113+
try
114+
{
115+
$stmt = $pdo->prepare("SELECT COUNT(*) FROM `{$table}`;");
116+
if ($stmt && $stmt->execute() && $stmt->rowCount() === 1)
117+
{
118+
$count = (int) $stmt->fetchColumn();
119+
}
120+
}
121+
finally
122+
{
123+
if (isset($stmt) && $stmt instanceof PDOStatement) $stmt->closeCursor();
124+
}
125+
126+
return $count;
127+
}
128+
129+
private function handleDateRange(): void
130+
{
131+
$data = \BNETDocs\Libraries\Core\Router::query();
132+
133+
if (isset($data['date_start']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['date_start']))
134+
{
135+
$this->model->date_start = new DateTimeImmutable($data['date_start']);
136+
}
137+
138+
if (isset($data['date_end']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['date_end']))
139+
{
140+
$this->model->date_end = new DateTimeImmutable($data['date_end']);
141+
}
142+
143+
// Fallback: if only one is set, assume the other is today
144+
if (!$this->model->date_end)
145+
{
146+
$this->model->date_end = new DateTimeImmutable('today');
147+
}
148+
149+
if (!$this->model->date_start && $this->model->date_end)
150+
{
151+
// Default to 30-day window
152+
$this->model->date_start = $this->model->date_end->sub(new \DateInterval('P30D'));
153+
}
154+
}
155+
}

src/Models/Analytics/Dashboard.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace BNETDocs\Models\Analytics;
4+
5+
use \DateInterval;
6+
use \DateTimeInterface;
7+
8+
class Dashboard extends \BNETDocs\Models\Core\AccessControl implements \JsonSerializable
9+
{
10+
public int $count_comments = 0;
11+
public int $count_documents = 0;
12+
public int $count_event_log = 0;
13+
public int $count_news_posts = 0;
14+
public int $count_packets = 0;
15+
public int $count_servers = 0;
16+
public int $count_tags = 0;
17+
public int $count_user_profiles = 0;
18+
public int $count_users = 0;
19+
public ?DateTimeInterface $date_end = null;
20+
public ?DateTimeInterface $date_start = null;
21+
public int $interval_new_comments = 0;
22+
public int $interval_new_documents = 0;
23+
public int $interval_new_event_log = 0;
24+
public int $interval_new_news_posts = 0;
25+
public int $interval_new_packets = 0;
26+
public int $interval_new_servers = 0;
27+
public int $interval_new_users = 0;
28+
29+
public function jsonSerialize(): mixed
30+
{
31+
return \array_merge(parent::jsonSerialize(), [
32+
'count_comments' => $this->count_comments,
33+
'count_documents' => $this->count_documents,
34+
'count_event_log' => $this->count_event_log,
35+
'count_news_posts' => $this->count_news_posts,
36+
'count_packets' => $this->count_packets,
37+
'count_servers' => $this->count_servers,
38+
'count_tags' => $this->count_tags,
39+
'count_user_profiles' => $this->count_user_profiles,
40+
'count_users' => $this->count_users,
41+
'date_end' => $this->date_end,
42+
'date_start' => $this->date_start,
43+
'interval_new_comments' => $this->interval_new_comments,
44+
'interval_new_documents' => $this->interval_new_documents,
45+
'interval_new_event_log' => $this->interval_new_event_log,
46+
'interval_new_news_posts' => $this->interval_new_news_posts,
47+
'interval_new_packets' => $this->interval_new_packets,
48+
'interval_new_servers' => $this->interval_new_servers,
49+
'interval_new_users' => $this->interval_new_users,
50+
]);
51+
}
52+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace BNETDocs\Templates\Analytics;
4+
5+
use \DateTimeInterface;
6+
7+
$title = 'Analytics Dashboard';
8+
9+
if (!$this->getContext()->acl_allowed)
10+
{
11+
require('./Includes/header.inc.phtml');
12+
echo '<div class="container">';
13+
require('./Includes/LoginRequired.inc.phtml');
14+
echo '</div>';
15+
require('./Includes/footer.inc.phtml');
16+
return;
17+
}
18+
19+
require('./Includes/header.inc.phtml');
20+
21+
$today = new \DateTimeImmutable('today');
22+
$last7 = $today->sub(new \DateInterval('P6D'))->format('Y-m-d'); // includes today
23+
$firstOfMonth = $today->modify('first day of this month')->format('Y-m-d');
24+
$todayStr = $today->format('Y-m-d');
25+
26+
$ctx = $this->getContext();
27+
$date_start = $ctx->date_start instanceof DateTimeInterface ?
28+
$ctx->date_start->format('Y-m-d') : '';
29+
$date_end = $ctx->date_end instanceof DateTimeInterface ?
30+
$ctx->date_end->format('Y-m-d') : '';
31+
32+
?><div class="container">
33+
<h1 class="mb-4">Analytics Dashboard</h1>
34+
35+
<h3>Date Range</h3>
36+
<div class="row mb-4">
37+
<div class="col-12">
38+
<form class="form-inline">
39+
<div class="form-group mr-2 mb-2">
40+
<label for="date-start" class="sr-only">Start Date</label>
41+
<input type="date" class="form-control form-control-sm" id="date-start" name="date_start"
42+
value="<?= $date_start ?>">
43+
</div>
44+
<div class="form-group mr-2 mb-2">
45+
<label for="date-end" class="sr-only">End Date</label>
46+
<input type="date" class="form-control form-control-sm" id="date-end" name="date_end"
47+
value="<?= $date_end ?>">
48+
</div>
49+
<button type="submit" class="btn btn-sm btn-primary mb-2">Apply</button>
50+
</form>
51+
<div class="btn-group" role="group">
52+
<form method="get" class="d-inline">
53+
<input type="hidden" name="date_start" value="<?= $last7 ?>">
54+
<input type="hidden" name="date_end" value="<?= $todayStr ?>">
55+
<button type="submit" class="btn btn-sm btn-outline-secondary">Last 7 Days</button>
56+
</form>
57+
<form method="get" class="d-inline">
58+
<input type="hidden" name="date_start" value="<?= $firstOfMonth ?>">
59+
<input type="hidden" name="date_end" value="<?= $todayStr ?>">
60+
<button type="submit" class="btn btn-sm btn-outline-secondary">This Month</button>
61+
</form>
62+
<form method="get" class="d-inline">
63+
<input type="hidden" name="date_start" value="1996-01-01"><!-- DRTL released in 1996 -->
64+
<input type="hidden" name="date_end" value="">
65+
<button type="submit" class="btn btn-sm btn-outline-secondary">All Time</button>
66+
</form>
67+
</div>
68+
</div>
69+
</div>
70+
71+
<h3>Summary</h3>
72+
<div class="row">
73+
<?php
74+
$metrics = [
75+
'Comments' => ['count' => $ctx->count_comments ?? 0, 'new' => $ctx->interval_new_comments ?? 0],
76+
'Documents' => ['count' => $ctx->count_documents ?? 0, 'new' => $ctx->interval_new_documents ?? 0],
77+
'Event Logs' => ['count' => $ctx->count_event_log ?? 0, 'new' => $ctx->interval_new_event_log ?? 0],
78+
'News Posts' => ['count' => $ctx->count_news_posts ?? 0, 'new' => $ctx->interval_new_news_posts ?? 0],
79+
'Packets' => ['count' => $ctx->count_packets ?? 0, 'new' => $ctx->interval_new_packets ?? 0],
80+
'Servers' => ['count' => $ctx->count_servers ?? 0, 'new' => $ctx->interval_new_servers ?? 0],
81+
'Tags' => ['count' => $ctx->count_tags ?? 0],
82+
'User Profiles' => ['count' => $ctx->count_user_profiles ?? 0],
83+
'Users' => ['count' => $ctx->count_users ?? 0, 'new' => $ctx->interval_new_users ?? 0],
84+
];
85+
86+
foreach ($metrics as $label => $data): ?>
87+
<div class="col-12 col-md-6 col-lg-4 mb-4">
88+
<div class="card shadow-sm h-100">
89+
<div class="card-body">
90+
<h5 class="card-title"><?= filter_var($label, FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?></h5>
91+
<p class="card-text mb-1"><strong>Total:</strong> <?= number_format($data['count']) ?></p>
92+
<? $new_class = isset($data['new']) ? (($data['new'] > 0 ? 'text-success' : ($data['new'] < 0 ? 'text-danger' : 'text-muted'))) : null; ?>
93+
<? if (isset($new_class)): ?>
94+
<p class="card-text <?= $new_class ?>"><small>New since interval: <?= number_format($data['new']) ?></small></p>
95+
<? endif; ?>
96+
</div>
97+
</div>
98+
</div>
99+
<?php endforeach; ?>
100+
</div>
101+
102+
</div>
103+
<?php require('./Includes/footer.inc.phtml'); ?>

src/Templates/Includes/header.inc.phtml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ $_header_nav = [
7979
['label' => 'Admin', 'acl' => (
8080
User::OPTION_ACL_SERVER_CREATE | User::OPTION_ACL_EVENT_LOG_VIEW | User::OPTION_ACL_PHPINFO
8181
), 'class' => 'text-danger', 'dropdown' => [
82+
['label' => 'Analytics', 'url' => '/analytics/dashboard', 'acl' => User::OPTION_ACL_EVENT_LOG_VIEW],
8283
['label' => 'Event Logs', 'url' => '/eventlog/index', 'acl' => User::OPTION_ACL_EVENT_LOG_VIEW],
8384
['label' => '-', 'acl' => (User::OPTION_ACL_EVENT_LOG_VIEW | User::OPTION_ACL_PHPINFO)],
8485
['label' => 'Php Info', 'url' => '/phpinfo', 'acl' => User::OPTION_ACL_PHPINFO],

src/Views/Analytics/DashboardHtml.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace BNETDocs\Views\Analytics;
4+
5+
class DashboardHtml extends \BNETDocs\Views\Base\Html
6+
{
7+
public static function invoke(\BNETDocs\Interfaces\Model $model): void
8+
{
9+
if (!$model instanceof \BNETDocs\Models\Analytics\Dashboard)
10+
{
11+
throw new \BNETDocs\Exceptions\InvalidModelException($model);
12+
}
13+
14+
(new \BNETDocs\Libraries\Core\Template($model, 'Analytics/Dashboard'))->invoke();
15+
$model->_responseHeaders['Content-Type'] = self::mimeType();
16+
}
17+
}

src/Views/Analytics/DashboardJson.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace BNETDocs\Views\Analytics;
4+
5+
class DashboardJson extends \BNETDocs\Views\Base\Json
6+
{
7+
public static function invoke(\BNETDocs\Interfaces\Model $model): void
8+
{
9+
if (!$model instanceof \BNETDocs\Models\Analytics\Dashboard)
10+
{
11+
throw new \BNETDocs\Exceptions\InvalidModelException($model);
12+
}
13+
14+
echo \json_encode($model, self::jsonFlags());
15+
$model->_responseHeaders['Content-Type'] = self::mimeType();
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace BNETDocs\Views\Analytics;
4+
5+
class DashboardPlain extends \BNETDocs\Views\Base\Plain
6+
{
7+
public static function invoke(\BNETDocs\Interfaces\Model $model): void
8+
{
9+
if (!$model instanceof \BNETDocs\Models\Analytics\Dashboard)
10+
{
11+
throw new \BNETDocs\Exceptions\InvalidModelException($model);
12+
}
13+
14+
echo $model;
15+
$model->_responseHeaders['Content-Type'] = self::mimeType();
16+
}
17+
}

src/main.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ public static function main(int $argc, array $argv): int
5858
Router::$routes = [
5959
['#^/\.well-known/change-password$#', 'Core\\Redirect', ['Core\\RedirectHtml', 'Core\\RedirectJson', 'Core\\RedirectPlain'], '/user/changepassword'],
6060
['#^/$#', 'Core\\Legacy', ['Core\\LegacyHtml']],
61+
['#^/analytics/dashboard/?$#', 'Analytics\\Dashboard', ['Analytics\\DashboardHtml', 'Analytics\\DashboardJson', 'Analytics\\DashboardPlain']],
62+
['#^/analytics/dashboard\.html?$#', 'Analytics\\Dashboard', ['Analytics\\DashboardHtml']],
63+
['#^/analytics/dashboard\.json$#', 'Analytics\\Dashboard', ['Analytics\\DashboardJson']],
64+
['#^/analytics/dashboard\.txt$#', 'Analytics\\Dashboard', ['Analytics\\DashboardPlain']],
6165
['#^/comment/create/?$#', 'Comment\\Create', ['Comment\\CreateJson']],
6266
['#^/comment/delete/?$#', 'Comment\\Delete', ['Comment\\DeleteHtml']],
6367
['#^/comment/edit/?$#', 'Comment\\Edit', ['Comment\\EditHtml']],

0 commit comments

Comments
 (0)