Skip to content

Commit 06e5e7e

Browse files
Add stats page
1 parent e6c6976 commit 06e5e7e

File tree

10 files changed

+184
-1
lines changed

10 files changed

+184
-1
lines changed

app/Console/Commands/CacheStats.php

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Stats;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Facades\Cache;
8+
9+
class CacheStats extends Command
10+
{
11+
protected $signature = 'stats:cache {--clear}';
12+
13+
public function handle()
14+
{
15+
if ($this->option('clear')) {
16+
Cache::forget('stats');
17+
18+
$this->info('Cache cleared');
19+
} else {
20+
Cache::forever('stats', (new Stats())());
21+
22+
$this->info('Stats cached');
23+
}
24+
}
25+
}

app/Console/Kernel.php

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ protected function schedule(Schedule $schedule)
2626
{
2727
$schedule->command('horizon:snapshot')->everyFiveMinutes();
2828

29+
$schedule->command('stats:cache')->dailyAt('2:00');
30+
2931
$schedule->command('websites:ping')->dailyAt('4:00');
3032

3133
$schedule->command('extensions:retrieve')->dailyAt('5:00');

app/Http/Controllers/AppController.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use App\Http\Controllers\Api\ScanController;
66
use App\Resources\ScanResource;
7+
use App\Stats;
78
use App\Website;
89
use Illuminate\Database\Eloquent\Builder;
910
use Illuminate\Database\Eloquent\Collection;
11+
use Illuminate\Support\Facades\Cache;
1012
use Spatie\Csp\AddCspHeaders;
1113

1214
class AppController extends Controller
@@ -46,7 +48,11 @@ protected function appView($preload = [])
4648
$preload
4749
);
4850

49-
return view('app')->withPreload($preload);
51+
$stats = Cache::get('stats', function () {
52+
return (new Stats())();
53+
});
54+
55+
return view('app')->withPreload($preload)->withStats($stats);
5056
}
5157

5258
public function home()

app/Stats.php

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace App;
4+
5+
use Carbon\Carbon;
6+
use Illuminate\Database\Eloquent\Builder;
7+
use Illuminate\Database\Query\JoinClause;
8+
9+
class Stats
10+
{
11+
public function __invoke(): array
12+
{
13+
return [
14+
'30d' => $this->period(Carbon::parse('30 days ago')),
15+
'lifetime' => $this->period(Carbon::parse('2018-03-01')),
16+
'time' => Carbon::now()->toW3cString(),
17+
];
18+
}
19+
20+
protected function period(Carbon $from): array
21+
{
22+
$query = Scan::query()
23+
->where('created_at', '>', $from);
24+
25+
return [
26+
'scans' => $this->scans($query->clone()),
27+
'websites' => $this->scans($query->clone()->joinSub(function (\Illuminate\Database\Query\Builder $sub) {
28+
$sub->from('scans')
29+
->selectRaw('max(created_at) as max_created_at, website_id as max_website_id')
30+
->whereNotNull('scanned_at')
31+
->groupBy('website_id');
32+
}, 'last_scans', function (JoinClause $join) {
33+
$join->on('max_created_at', 'created_at');
34+
$join->on('max_website_id', 'website_id');
35+
})),
36+
];
37+
}
38+
39+
protected function scans(Builder $scanQuery): array
40+
{
41+
$ratings = ['A+', 'A', 'A-', 'B', 'B-', 'C', 'C-', 'D'];
42+
43+
$extensionCount = $scanQuery->clone()
44+
->joinSub(function (\Illuminate\Database\Query\Builder $sub) {
45+
$sub->from('extension_scan')
46+
->selectRaw('count(1) as count_extensions, scan_id as count_scan_id')
47+
->groupBy('scan_id');
48+
}, 'extension_scan', 'count_scan_id', 'id');
49+
50+
return [
51+
'total' => $scanQuery->count(),
52+
'ratings' => array_combine($ratings, array_map(function (string $rating) use ($scanQuery) {
53+
return $scanQuery->clone()->where('rating', $rating)->count();
54+
}, $ratings)),
55+
'extensionCount' => [
56+
'avg' => round($extensionCount->avg('count_extensions'), 1),
57+
'max' => $extensionCount->max('count_extensions'),
58+
'min' => $extensionCount->min('count_extensions'),
59+
],
60+
];
61+
}
62+
}

resources/assets/js/layouts/LabLayout.js

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default {
2323
href: App.showcaseDomain || '/showcase',
2424
}, 'Showcase')),
2525
m('li.nav-item', link('/opt-out', {className: 'nav-link'}, 'Opt Out')),
26+
m('li.nav-item', link('/stats', {className: 'nav-link'}, 'Stats')),
2627
m('li.nav-item', m('a.nav-link', {
2728
href: App.discuss,
2829
target: '_blank',
+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import m from 'mithril';
2+
import moment from 'moment';
3+
import App from '../utils/App';
4+
import Rating from '../components/Rating';
5+
6+
export default {
7+
view(vnode) {
8+
const ratings = ['A+', 'A', 'A-', 'B', 'B-', 'C', 'C-', 'D'].filter(rating => {
9+
// Only show ratings that have at least one result
10+
return [App.stats['30d'], App.stats.lifetime].some(group => {
11+
// We don't need to check both "scans" and "website" groups, because websites is just a subset of scans
12+
return group.scans.ratings[rating] > 0;
13+
})
14+
});
15+
16+
const columns = [
17+
[
18+
m('th', 'Total'),
19+
m('th', 'Rating'),
20+
...ratings.map(rating => m('th', m(Rating, {
21+
rating,
22+
}))),
23+
m('th', 'Extension count'),
24+
m('th', 'max'),
25+
m('th', 'avg'),
26+
m('th', 'min'),
27+
],
28+
...[App.stats['30d'], App.stats.lifetime].map(statGroups => {
29+
const stats = statGroups[vnode.state.websites ? 'websites' : 'scans'];
30+
31+
return [
32+
m('td', stats.total),
33+
m('td'),
34+
...ratings.map(rating => m('td', stats.ratings[rating])),
35+
m('td'),
36+
m('td', stats.extensionCount.max),
37+
m('td', stats.extensionCount.avg),
38+
m('td', stats.extensionCount.min),
39+
];
40+
}),
41+
];
42+
43+
return m('.row.justify-content-center', [
44+
m('.col-md-6', [
45+
m('.btn-group.float-right', [
46+
m('.btn', {
47+
className: vnode.state.websites ? 'btn-outline-primary' : 'btn-primary active',
48+
onclick: () => {
49+
vnode.state.websites = false;
50+
},
51+
}, 'Scans'),
52+
m('.btn', {
53+
className: vnode.state.websites ? 'btn-primary active' : 'btn-outline-primary',
54+
onclick: () => {
55+
vnode.state.websites = true;
56+
},
57+
}, 'Websites'),
58+
]),
59+
m('h2', 'Stats'),
60+
m('table.table.table-sm.table-hover', m('tbody', [
61+
m('tr', [
62+
m('th'),
63+
m('th', 'last 30 days'),
64+
m('th', 'lifetime'),
65+
]),
66+
columns[0].map((header, index) => {
67+
return m('tr', [
68+
header,
69+
columns[1][index],
70+
columns[2][index],
71+
]);
72+
}),
73+
])),
74+
m('p.text-muted', 'Updated ' + moment(App.stats.time).fromNow()),
75+
]),
76+
]);
77+
},
78+
}

resources/assets/js/routes.js

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import HomePage from './pages/HomePage';
44
import ScanPage from './pages/ScanPage';
55
import App from './utils/App';
66
import OptOutPage from './pages/OptOutPage';
7+
import StatsPage from './pages/StatsPage';
78
import TasksPage from './pages/TasksPage';
89

910
let root = document.getElementById('app');
@@ -14,6 +15,7 @@ const routes = {
1415
'/': HomePage,
1516
'/scans/:key': ScanPage,
1617
'/opt-out': OptOutPage,
18+
'/stats': StatsPage,
1719
'/tasks': TasksPage,
1820
};
1921

resources/assets/js/utils/App.js

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default {
1313
secretExtensionProbability: 0,
1414
baseDomain: null,
1515
showcaseDomain: null,
16+
stats: null,
1617
init(root) {
1718
this.csrfToken = root.dataset.csrf;
1819

@@ -32,6 +33,10 @@ export default {
3233
this.showcaseDomain = root.dataset.showcaseDomain || null;
3334
}
3435

36+
if (root.dataset.hasOwnProperty('stats')) {
37+
this.stats = JSON.parse(root.dataset.stats);
38+
}
39+
3540
if (root.dataset.hasOwnProperty('preload')) {
3641
const preload = JSON.parse(root.dataset.preload);
3742
Store.load(preload);

resources/views/app.blade.php

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
'base-domain' => url(''),
1717
'showcase-domain' => config('scanner.showcase_domain'),
1818
'preload' => isset($preload) ? \GuzzleHttp\json_encode($preload) : '[]',
19+
'stats' => \GuzzleHttp\json_encode($stats),
1920
'sponsoring' => \GuzzleHttp\json_encode(config('sponsoring')),
2021
];
2122
if ($probability = config('scanner.secret_extensions_probability')) {

routes/web.php

+1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
Route::get('/', AppController::class . '@home');
66
Route::get('/scans/{id}', AppController::class . '@scan');
77
Route::get('/opt-out', AppController::class . '@home');
8+
Route::get('/stats', AppController::class . '@home');
89
Route::get('/tasks', AppController::class . '@home');
910
Route::get('/showcase', AppController::class . '@showcase');

0 commit comments

Comments
 (0)