mirror of
https://github.com/getformwork/formwork.git
synced 2025-01-17 21:49:04 +01:00
Add Statistics controller
This commit is contained in:
parent
be2f734ff2
commit
523e23be44
@ -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;
|
||||
|
@ -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
|
||||
{
|
||||
|
31
formwork/src/Panel/Controllers/StatisticsController.php
Normal file
31
formwork/src/Panel/Controllers/StatisticsController.php
Normal 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)),
|
||||
]));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
2
panel/assets/css/panel-dark.min.css
vendored
2
panel/assets/css/panel-dark.min.css
vendored
File diff suppressed because one or more lines are too long
2
panel/assets/css/panel.min.css
vendored
2
panel/assets/css/panel.min.css
vendored
File diff suppressed because one or more lines are too long
40
panel/assets/js/app.min.js
vendored
40
panel/assets/js/app.min.js
vendored
File diff suppressed because one or more lines are too long
@ -8,3 +8,4 @@ permissions:
|
||||
options: true
|
||||
updates: true
|
||||
users: true
|
||||
statistics: true
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
panel/src/js/components/views/statistics.js
Normal file
12
panel/src/js/components/views/statistics.js
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
30
panel/src/scss/components/_table.scss
Normal file
30
panel/src/scss/components/_table.scss
Normal 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;
|
||||
}
|
@ -11,3 +11,8 @@
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tooltip .icon {
|
||||
transform: scale(0.75);
|
||||
vertical-align: -0.25rem;
|
||||
}
|
||||
|
@ -6,6 +6,7 @@
|
||||
|
||||
@import "components/base";
|
||||
@import "components/typography";
|
||||
@import "components/table";
|
||||
@import "components/columns";
|
||||
@import "components/buttons";
|
||||
@import "components/badges";
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
60
panel/views/statistics/index.php
Normal file
60
panel/views/statistics/index.php
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user