Add Statistics controller

This commit is contained in:
Giuseppe Criscione 2023-12-29 13:53:31 +01:00
parent be2f734ff2
commit 523e23be44
21 changed files with 290 additions and 65 deletions

View File

@ -28,6 +28,7 @@ use Formwork\Services\Loaders\PanelServiceLoader;
use Formwork\Services\Loaders\SchemesServiceLoader;
use Formwork\Services\Loaders\SiteServiceLoader;
use Formwork\Services\Loaders\TranslationsServiceLoader;
use Formwork\Statistics\Statistics;
use Formwork\Traits\SingletonClass;
use Formwork\Translations\Translations;
use Formwork\Utils\Str;

View File

@ -4,7 +4,7 @@ namespace Formwork\Panel\Controllers;
use Formwork\Http\Response;
use Formwork\Parsers\Json;
use Formwork\Statistics;
use Formwork\Statistics\Statistics;
class DashboardController extends AbstractController
{

View File

@ -0,0 +1,31 @@
<?php
namespace Formwork\Panel\Controllers;
use Formwork\Http\Response;
use Formwork\Parsers\Json;
use Formwork\Statistics\Statistics;
class StatisticsController extends AbstractController
{
/**
* Statistics@index action
*/
public function index(Statistics $statistics): Response
{
$this->ensurePermission('statistics');
$pageViews = $statistics->getPageViews();
return new Response($this->view('statistics.index', [
'title' => $this->translate('panel.statistics.statistics'),
'statistics' => Json::encode($statistics->getChartData(30)),
'pageViews' => array_slice($pageViews, 0, 15, preserve_keys: true),
'totalViews' => array_sum($pageViews),
'monthVisits' => array_sum($statistics->getVisits(30)),
'weekVisits' => array_sum($statistics->getVisits(7)),
'monthUniqueVisits' => array_sum($statistics->getUniqueVisits(30)),
'weekUniqueVisits' => array_sum($statistics->getUniqueVisits(7)),
]));
}
}

View File

@ -1,13 +1,16 @@
<?php
namespace Formwork;
namespace Formwork\Statistics;
use Formwork\App;
use Formwork\Http\Request;
use Formwork\Http\Utils\IpAnonymizer;
use Formwork\Http\Utils\Visitor;
use Formwork\Log\Registry;
use Formwork\Utils\Arr;
use Formwork\Utils\Date;
use Formwork\Utils\FileSystem;
use Generator;
class Statistics
{
@ -110,43 +113,74 @@ class Statistics
/**
* Return chart data
*
* @return array{labels: array<string>, series: list<list<int|string>>}
* @return array{labels: array<string>, series: list<list<int>>}
*/
public function getChartData(int $limit = self::CHART_LIMIT): array
{
$visits = $this->visitsRegistry->toArray();
$uniqueVisits = $this->uniqueVisitsRegistry->toArray();
$limit = min($limit, count($visits), count($uniqueVisits));
$visits = $this->getVisits($limit);
$uniqueVisits = $this->getUniqueVisits($limit);
$low = time() - ($limit - 1) * 86400;
$days = [];
for ($i = 0; $i < $limit; $i++) {
$value = date(self::DATE_FORMAT, $low + $i * 86400);
$days[] = $value;
}
$visits = array_slice($visits, -$limit, null, true);
$uniqueVisits = array_slice($uniqueVisits, -$limit, null, true);
$labels = array_map(fn (string $day): string => Date::formatTimestamp(Date::toTimestamp($day, self::DATE_FORMAT), "D\nj M"), $days);
$interpolate = static function (array $data) use ($days): array {
$output = [];
foreach ($days as $day) {
$output[$day] = $data[$day] ?? 0;
}
return $output;
};
$labels = Arr::map(
iterator_to_array($this->generateDays($limit)),
fn (string $day): string => Date::formatTimestamp(Date::toTimestamp($day, self::DATE_FORMAT), "D\nj M")
);
return [
'labels' => $labels,
'series' => [
array_values($interpolate($visits)),
array_values($interpolate($uniqueVisits)),
array_values($visits),
array_values($uniqueVisits),
],
];
}
/**
* @return array<string, int>
*/
public function getPageViews(): array
{
return Arr::sort($this->pageViewsRegistry->toArray(), SORT_DESC);
}
/**
* @return array<string, int>
*/
public function getVisits(int $limit = self::CHART_LIMIT): array
{
return $this->interpolateVisits($this->visitsRegistry->toArray(), $limit);
}
/**
* @return array<string, int>
*/
public function getUniqueVisits(int $limit = self::CHART_LIMIT): array
{
return $this->interpolateVisits($this->uniqueVisitsRegistry->toArray(), $limit);
}
/**
* @param array<string, int> $visits
*
* @return array<string, int>
*/
private function interpolateVisits(array $visits, int $limit): array
{
$result = [];
foreach ($this->generateDays($limit) as $day) {
$result[$day] = $visits[$day] ?? 0;
}
return $result;
}
/**
* @return Generator<int, string>
*/
private function generateDays(int $limit): Generator
{
$low = time() - ($limit - 1) * 86400;
for ($i = 0; $i < $limit; $i++) {
yield date(self::DATE_FORMAT, $low + $i * 86400);
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,3 +8,4 @@ permissions:
options: true
updates: true
users: true
statistics: true

View File

@ -154,6 +154,11 @@ return [
'types' => ['XHR'],
],
'panel.statistics' => [
'path' => '/statistics/',
'action' => 'Formwork\Panel\Controllers\StatisticsController@index',
],
'panel.users' => [
'path' => '/users/',
'action' => 'Formwork\Panel\Controllers\UsersController@index',

View File

@ -10,6 +10,7 @@ import { Tooltips } from "./components/tooltips";
import { Dashboard } from "./components/views/dashboard";
import { Pages } from "./components/views/pages";
import { Statistics } from "./components/views/statistics";
import { Updates } from "./components/views/updates";
class App {
@ -40,6 +41,7 @@ class App {
this.loadComponent(Dashboard);
this.loadComponent(Pages);
this.loadComponent(Statistics);
this.loadComponent(Updates);
}

View File

@ -1,8 +1,11 @@
import { LineChart } from "chartist";
import { passIcon } from "./icons";
import { Tooltip } from "./tooltip";
export class Chart {
export class StatisticsChart {
constructor(element, data) {
const spacing = 100;
const options = {
showArea: true,
fullWidth: true,
@ -17,6 +20,7 @@ export class Chart {
x: 0,
y: 10,
},
labelInterpolationFnc: (value, index, labels) => (index % Math.floor(labels.length / (element.clientWidth / spacing)) ? null : value),
},
axisY: {
onlyInteger: true,
@ -30,6 +34,12 @@ export class Chart {
const chart = new LineChart(element, data, options);
chart.on("draw", (event) => {
if (event.type === "point") {
event.element.attr({ "ct:index": event.index });
}
});
chart.container.addEventListener("mouseover", (event) => {
if (event.target.getAttribute("class") === "ct-point") {
const tooltipOffset = {
@ -41,11 +51,17 @@ export class Chart {
tooltipOffset.x += strokeWidth / 2;
tooltipOffset.y += strokeWidth / 2;
}
const tooltip = new Tooltip(event.target.getAttribute("ct:value"), {
referenceElement: event.target,
offset: tooltipOffset,
const index = event.target.getAttribute("ct:index");
passIcon("circle-small-fill", (icon) => {
const text = `${data.labels[index]}<br><span class="text-color-blue">${icon}</span> ${data.series[0][index]} <span class="text-color-amber ml-2">${icon}</span>${data.series[1][index]}`;
const tooltip = new Tooltip(text, {
referenceElement: event.target,
offset: tooltipOffset,
});
tooltip.show();
});
tooltip.show();
}
});
}

View File

@ -1,19 +1,15 @@
import { $, $$ } from "../../utils/selectors";
import { $ } from "../../utils/selectors";
import { app } from "../../app";
import { Chart } from "../chart";
import { Notification } from "../notification";
import { Request } from "../../utils/request";
import { StatisticsChart } from "../statistics-chart";
import { triggerDownload } from "../../utils/forms";
export class Dashboard {
constructor() {
$$("[data-chart-data]").forEach((element) => {
const data = JSON.parse(element.dataset.chartData);
new Chart(element, data);
});
const clearCacheCommand = $("[data-command=clear-cache]");
const makeBackupCommand = $("[data-command=make-backup]");
const chart = $(".dashboard-chart");
if (clearCacheCommand) {
clearCacheCommand.addEventListener("click", () => {
@ -54,5 +50,9 @@ export class Dashboard {
);
});
}
if (chart) {
new StatisticsChart(chart, JSON.parse(chart.dataset.chartData));
}
}
}

View File

@ -0,0 +1,12 @@
import { $ } from "../../utils/selectors";
import { StatisticsChart } from "../statistics-chart";
export class Statistics {
constructor() {
const chart = $(".statistics-chart");
if (chart) {
new StatisticsChart(chart, JSON.parse(chart.dataset.chartData));
}
}
}

View File

@ -0,0 +1,30 @@
table {
width: 100%;
}
th {
padding: 0.25rem 0;
font-weight: 600;
text-align: left;
}
td {
padding: 0.25rem 0.5rem;
}
.table-bordered {
border-collapse: collapse;
}
.table-bordered td {
border-top: 1px solid $color-base-600;
border-bottom: 1px solid $color-base-600;
}
.table-striped tbody > tr:nth-child(2n + 1) {
background-color: $color-base-800;
}
.table-hoverable tbody > tr:hover {
background-color: $color-base-700;
}

View File

@ -11,3 +11,8 @@
pointer-events: none;
text-align: center;
}
.tooltip .icon {
transform: scale(0.75);
vertical-align: -0.25rem;
}

View File

@ -6,6 +6,7 @@
@import "components/base";
@import "components/typography";
@import "components/table";
@import "components/columns";
@import "components/buttons";
@import "components/badges";

View File

@ -213,6 +213,15 @@ panel.register.createUser: Formwork is installed but no users were found. Please
panel.register.register: Register New User
panel.request.error.postMaxSize: The HTTP POST request exceeds the maximum allowed size
panel.sections.toggle: Toggle Section
panel.statistics.monthlyUniqueVisitors: Monthly Unique Visitors
panel.statistics.monthlyVisits: Monthly Visits
panel.statistics.statistics: Statistics
panel.statistics.totalVisits: Total Visits
panel.statistics.totalVisits.percentTotal: "% Total"
panel.statistics.totalVisits.uri: URI
panel.statistics.totalVisits.visits: Visits
panel.statistics.weeklyUniqueVisitors: Weekly Unique Visitors
panel.statistics.weeklyVisits: Weekly Visits
panel.updates.availableForInstall: is available for install
panel.updates.check: Check Updates
panel.updates.install: Install

View File

@ -213,6 +213,15 @@ panel.register.createUser: Formwork è installato ma non è stato trovato alcun
panel.register.register: Registra nuovo utente
panel.request.error.postMaxSize: La richiesta HTTP con metodo POST supera la dimensione massima consentita
panel.sections.toggle: Mostra/nascondi sezione
panel.statistics.monthlyUniqueVisitors: Visitatori unici ultimo mese
panel.statistics.monthlyVisits: Visite ultimo mese
panel.statistics.statistics: Statistiche
panel.statistics.totalVisits: Visite totali
panel.statistics.totalVisits.percentTotal: "% Totale"
panel.statistics.totalVisits.uri: URI
panel.statistics.totalVisits.visits: Visite
panel.statistics.weeklyUniqueVisitors: Visitatori unici ultima settimana
panel.statistics.weeklyVisits: Visite ultima settimana
panel.updates.availableForInstall: è disponibile per l'installazione
panel.updates.check: Cerca aggiornamenti
panel.updates.install: Installa

View File

@ -47,12 +47,12 @@ endif
<div class="col-xs-1-2"><div class="section-header"><h3 class="caption"><?= $this->translate('panel.dashboard.statistics') ?></h3></div></div>
<div class="col-xs-1-2">
<div class="ct-legend ct-legend-right">
<span class="ct-legend-label ct-series-a"><?= $this->icon('circle-small-fill') ?> <?= $this->translate('panel.dashboard.statistics.visits') ?></span>
<span class="ct-legend-label ct-series-a mr-8"><?= $this->icon('circle-small-fill') ?> <?= $this->translate('panel.dashboard.statistics.visits') ?></span>
<span class="ct-legend-label ct-series-b"><?= $this->icon('circle-small-fill') ?> <?= $this->translate('panel.dashboard.statistics.uniqueVisitors') ?></span>
</div>
</div>
</div>
<div class="ct-chart" data-chart-data="<?= $this->escapeAttr($statistics); ?>"></div>
<div class="dashboard-chart ct-chart" data-chart-data="<?= $this->escapeAttr($statistics); ?>"></div>
</section>
</div>
</div>

View File

@ -52,6 +52,15 @@ if ($panel->user()->permissions()->has('users')):
</li>
<?php
endif
?>
<?php
if ($panel->user()->permissions()->has('statistics')):
?>
<li class="<?= ($location === 'statistics') ? 'active' : '' ?>">
<a href="<?= $panel->uri('/statistics/') ?>"><?= $this->translate('panel.statistics.statistics') ?></a>
</li>
<?php
endif
?>
<li>
<a href="<?= $panel->uri('/logout/') ?>"><?= $this->translate('panel.login.logout') ?></a>

View File

@ -0,0 +1,60 @@
<?php $this->layout('panel') ?>
<div class="header">
<div class="header-title"><?= $this->translate('panel.statistics.statistics') ?></div>
</div>
<section class="section">
<div class="row">
<div class="col-xs-1-2"><div class="section-header"><h3 class="caption"><?= $this->translate('panel.dashboard.statistics') ?></h3></div></div>
<div class="col-xs-1-2">
<div class="ct-legend ct-legend-right">
<span class="ct-legend-label ct-series-a mr-8"><?= $this->icon('circle-small-fill') ?> <?= $this->translate('panel.dashboard.statistics.visits') ?></span>
<span class="ct-legend-label ct-series-b"><?= $this->icon('circle-small-fill') ?> <?= $this->translate('panel.dashboard.statistics.uniqueVisitors') ?></span>
</div>
</div>
</div>
<div class="statistics-chart ct-chart" data-chart-data="<?= $this->escapeAttr($statistics); ?>"></div>
</section>
<section class="section">
<div class="row text-align-center">
<div class="col-xs-1-2 col-m-1-4">
<div class="text-size-xxl text-bold text-color-blue"><?= $monthVisits ?></div>
<span class="text-size-s"><?= $this->translate('panel.statistics.monthlyVisits') ?></span>
</div>
<div class="col-xs-1-2 col-m-1-4">
<div class="text-size-xxl text-bold text-color-amber"><?= $monthUniqueVisits ?></div>
<span class="text-size-s"><?= $this->translate('panel.statistics.monthlyUniqueVisitors') ?></span>
</div>
<div class="col-xs-1-2 col-m-1-4">
<div class="text-size-xxl text-bold text-color-blue"><?= $weekVisits ?></div>
<span class="text-size-s"><?= $this->translate('panel.statistics.weeklyVisits') ?></span>
</div>
<div class="col-xs-1-2 col-m-1-4">
<div class="text-size-xxl text-bold text-color-amber"><?= $weekUniqueVisits ?></div>
<span class="text-size-s"><?= $this->translate('panel.statistics.weeklyUniqueVisitors') ?></span>
</div>
</div>
</section>
<section class="section">
<div class="section-header"><h3 class="caption"><?= $this->translate('panel.statistics.totalVisits') ?></h3></div>
<table class="table-bordered table-striped table-hoverable text-size-s">
<thead>
<tr>
<th><?= $this->translate('panel.statistics.totalVisits.uri') ?></th>
<th style="width: 15%"><?= $this->translate('panel.statistics.totalVisits.visits') ?></th>
<th style="width: 15%"><?= $this->translate('panel.statistics.totalVisits.percentTotal') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($pageViews as $page => $views): ?>
<tr>
<td><a href="<?= $site->uri($page, includeLanguage: false) ?>" target="_blank"><?= $page ?></a></td>
<td><?= $views ?></td>
<td><?= round($views / $totalViews * 100, 2) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>