Skip to content

Commit f0bca3d

Browse files
[Feat] [4.x] Site Stats (#1132)
* wip * site setting for disable/enable * post-review * fixes * fixes * remove hidden * fix for non-utf8 data + test
1 parent 1b50455 commit f0bca3d

50 files changed

Lines changed: 2282 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Actions/CronJob/SyncCronJobs.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ private function syncUserCronJobs(Server $server, string $user): void
3434
->where('user', $user)
3535
->get();
3636

37-
// Filter only server-level cronjobs (site_id = null) for status updates
3837
$serverLevelCronJobs = $vitoCronJobs->where('site_id', null);
3938

4039
if (empty($crontabOutput)) {
@@ -135,7 +134,6 @@ private function syncUserCronJobs(Server $server, string $user): void
135134
'user' => $user,
136135
'command' => $cronJobData['command'],
137136
'frequency' => $cronJobData['frequency'],
138-
'hidden' => false,
139137
'status' => $cronJobData['commented'] ? CronjobStatus::DISABLED : CronjobStatus::READY,
140138
]);
141139
}

app/Actions/Service/Manage.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ public function disable(Service $service): void
5959

6060
private function validate(Service $service): void
6161
{
62-
if (! $service->handler()->unit()) {
62+
if (! $service->handler()->canBeManaged()) {
6363
throw ValidationException::withMessages([
64-
'service' => __('This service does not have a systemd unit configured.'),
64+
'service' => __('This service cannot be managed.'),
6565
]);
6666
}
6767
}

app/Actions/Site/DeleteSite.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Actions\Site;
44

5+
use App\Events\SiteDeletedEvent;
56
use App\Exceptions\SSHError;
67
use App\Models\Service;
78
use App\Models\Site;
@@ -83,6 +84,10 @@ public function delete(Site $site, array $input): void
8384
*/
8485
private function deleteRow(Site $site): void
8586
{
87+
$server = $site->server;
88+
$siteId = $site->id;
89+
$domain = $site->domain;
90+
8691
try {
8792
$site->delete();
8893
} catch (Throwable $e) {
@@ -95,6 +100,8 @@ private function deleteRow(Site $site): void
95100

96101
throw $e;
97102
}
103+
104+
SiteDeletedEvent::dispatch($server, $siteId, $domain);
98105
}
99106

100107
/**
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Actions\Site;
4+
5+
use App\Jobs\Site\CleanupSiteStatsJob;
6+
use App\Jobs\Site\WriteSiteStatsConfJob;
7+
use App\Models\Site;
8+
9+
class UpdateSiteStats
10+
{
11+
public function disable(Site $site): void
12+
{
13+
$site->jsonUpdate('type_data', 'stats_disabled', true);
14+
15+
if ($site->server->service('log_analysis')) {
16+
dispatch(new CleanupSiteStatsJob($site->server, $site->id));
17+
}
18+
}
19+
20+
public function enable(Site $site): void
21+
{
22+
$site->jsonForget('type_data', 'stats_disabled');
23+
24+
if ($site->server->service('log_analysis')) {
25+
dispatch(new WriteSiteStatsConfJob($site));
26+
}
27+
}
28+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<?php
2+
3+
namespace App\Actions\SiteStats;
4+
5+
use App\Exceptions\SSHError;
6+
use App\Helpers\SSH;
7+
use App\Models\Site;
8+
use App\Services\LogAnalysis\GoAccess\GoAccess;
9+
use App\Support\Testing\SSHFake;
10+
use Carbon\Carbon;
11+
use Illuminate\Support\Facades\Cache;
12+
13+
class GetSiteStats
14+
{
15+
/**
16+
* @return array{months: array<int, string>, month: string, summary: array<int, array<string, mixed>>, detail: ?array<string, mixed>}
17+
*
18+
* @throws SSHError
19+
*/
20+
public function get(Site $site, ?string $month = null): array
21+
{
22+
$ssh = $site->server->ssh();
23+
24+
$status = Cache::remember(
25+
"site-stats-status:{$site->id}",
26+
10,
27+
fn (): ?array => $this->readJson($ssh, $site, 'status.json'),
28+
);
29+
$token = (string) ($status['last_run_finished_at'] ?? 'none');
30+
31+
return Cache::remember(
32+
"site-stats:{$site->id}:".($month ?? 'current').":{$token}",
33+
300,
34+
function () use ($ssh, $site, $month, $status): array {
35+
$summary = $this->readJson($ssh, $site, 'summary.json') ?? [];
36+
$summary = collect($summary)
37+
->filter(fn ($row): bool => is_array($row) && isset($row['month']))
38+
->sortBy('month')
39+
->values()
40+
->all();
41+
42+
$months = collect($summary)->pluck('month')->sortDesc()->values()->all();
43+
$selected = $month && in_array($month, $months, true)
44+
? $month
45+
: ($months[0] ?? Carbon::now()->format('Y-m'));
46+
47+
return [
48+
'months' => $months,
49+
'month' => $selected,
50+
'summary' => $summary,
51+
'detail' => $this->detail($ssh, $site, $selected, $status),
52+
'status' => $status ? [
53+
'last_success_at' => $status['last_success_at'] ?? null,
54+
'last_run_finished_at' => $status['last_run_finished_at'] ?? null,
55+
'exit_code' => $status['exit_code'] ?? null,
56+
'error' => $this->sanitizeError($status['error'] ?? null),
57+
] : null,
58+
];
59+
}
60+
);
61+
}
62+
63+
private function sanitizeError(mixed $error): ?string
64+
{
65+
if (! is_string($error) || $error === '') {
66+
return null;
67+
}
68+
69+
$clean = trim((string) preg_replace('/[[:cntrl:]]+/', ' ', $error));
70+
71+
return $clean === '' ? null : mb_substr($clean, 0, 200);
72+
}
73+
74+
/**
75+
* @param ?array<string, mixed> $status
76+
* @return ?array<string, mixed>
77+
*
78+
* @throws SSHError
79+
*/
80+
private function detail(SSH|SSHFake $ssh, Site $site, string $month, ?array $status): ?array
81+
{
82+
$report = $this->readMonth($ssh, $site, $month);
83+
if ($report === null) {
84+
return null;
85+
}
86+
87+
return [
88+
'generated_at' => $status['last_success_at'] ?? null,
89+
'totals' => [
90+
'visitors' => (int) ($report['general']['unique_visitors'] ?? 0),
91+
'hits' => (int) ($report['general']['total_requests'] ?? 0),
92+
'bandwidth' => (int) ($report['general']['bandwidth'] ?? 0),
93+
],
94+
'daily' => $this->daily($report, $month),
95+
'top_pages' => $this->panel($report, 'requests', 12),
96+
'referrers' => $this->panel($report, 'referrers', 20),
97+
'status_codes' => $this->statusCodes($report),
98+
'not_found' => $this->panel($report, 'not_found', 20),
99+
];
100+
}
101+
102+
/**
103+
* @param array<string, mixed> $report
104+
* @return array<int, array<string, mixed>>
105+
*/
106+
private function daily(array $report, string $month): array
107+
{
108+
$byDate = [];
109+
foreach ($report['visitors']['data'] ?? [] as $row) {
110+
$byDate[$this->formatDate((string) ($row['data'] ?? ''))] = [
111+
'visitors' => (int) ($row['visitors']['count'] ?? 0),
112+
'hits' => (int) ($row['hits']['count'] ?? 0),
113+
'bandwidth' => (int) ($row['bytes']['count'] ?? 0),
114+
];
115+
}
116+
117+
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
118+
return [];
119+
}
120+
121+
$start = Carbon::createFromFormat('Y-m', $month)->startOfMonth();
122+
123+
$days = [];
124+
for ($day = 1; $day <= $start->daysInMonth; $day++) {
125+
$date = $start->copy()->day($day)->format('Y-m-d');
126+
$days[] = array_merge(
127+
['date' => $date],
128+
$byDate[$date] ?? ['visitors' => 0, 'hits' => 0, 'bandwidth' => 0]
129+
);
130+
}
131+
132+
return $days;
133+
}
134+
135+
/**
136+
* @param array<string, mixed> $report
137+
* @return array<int, array<string, mixed>>
138+
*/
139+
private function panel(array $report, string $key, int $limit): array
140+
{
141+
return collect($report[$key]['data'] ?? [])
142+
->take($limit)
143+
->map(fn (array $row): array => [
144+
'name' => (string) ($row['data'] ?? ''),
145+
'hits' => (int) ($row['hits']['count'] ?? 0),
146+
'visitors' => (int) ($row['visitors']['count'] ?? 0),
147+
'bandwidth' => (int) ($row['bytes']['count'] ?? 0),
148+
])
149+
->values()
150+
->all();
151+
}
152+
153+
/**
154+
* @param array<string, mixed> $report
155+
* @return array<int, array<string, mixed>>
156+
*/
157+
private function statusCodes(array $report): array
158+
{
159+
$codes = [];
160+
foreach ($report['status_codes']['data'] ?? [] as $row) {
161+
$children = $row['items'] ?? [$row];
162+
foreach ($children as $child) {
163+
$codes[] = [
164+
'name' => (string) ($child['data'] ?? ''),
165+
'hits' => (int) ($child['hits']['count'] ?? 0),
166+
];
167+
}
168+
}
169+
170+
return $codes;
171+
}
172+
173+
/**
174+
* @return ?array<string, mixed>
175+
*
176+
* @throws SSHError
177+
*/
178+
private function readJson(SSH|SSHFake $ssh, Site $site, string $file): ?array
179+
{
180+
return $this->cat($ssh, GoAccess::BASE_DIR."/data/{$site->id}/{$file}");
181+
}
182+
183+
/**
184+
* @return ?array<string, mixed>
185+
*
186+
* @throws SSHError
187+
*/
188+
private function readMonth(SSH|SSHFake $ssh, Site $site, string $month): ?array
189+
{
190+
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
191+
return null;
192+
}
193+
194+
$report = $this->cat($ssh, GoAccess::BASE_DIR."/data/{$site->id}/{$month}/report.json");
195+
196+
return ($report !== null && isset($report['general'])) ? $report : null;
197+
}
198+
199+
/**
200+
* @return ?array<string, mixed>
201+
*
202+
* @throws SSHError
203+
*/
204+
private function cat(SSH|SSHFake $ssh, string $path): ?array
205+
{
206+
$out = trim($ssh->exec('cat '.escapeshellarg($path).' 2>/dev/null || echo ""'));
207+
if ($out === '') {
208+
return null;
209+
}
210+
211+
$decoded = json_decode($out, true, 512, JSON_INVALID_UTF8_SUBSTITUTE);
212+
213+
return is_array($decoded) ? $decoded : null;
214+
}
215+
216+
private function formatDate(string $value): string
217+
{
218+
if (preg_match('/^\d{8}$/', $value) === 1) {
219+
return Carbon::createFromFormat('Ymd', $value)->format('Y-m-d');
220+
}
221+
222+
return $value;
223+
}
224+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace App\Actions\SiteStats;
4+
5+
use App\Models\Site;
6+
use Exception;
7+
8+
class RenderSiteStatsConf
9+
{
10+
/**
11+
* @throws Exception
12+
*/
13+
public function render(Site $site, ?string $webserverId = null, ?int $retentionMonths = null): string
14+
{
15+
$domain = $this->safeDomain($site);
16+
$caddy = ($webserverId ?? $this->resolveWebserverId($site)) === 'caddy';
17+
$retention = $retentionMonths ?? (int) ($site->server->service('log_analysis')?->type_data['data_retention'] ?? 12);
18+
19+
$vars = [
20+
'SITE_ID' => (string) $site->id,
21+
'DOMAIN' => $domain,
22+
'LOG_FORMAT' => $caddy ? 'CADDY' : 'COMBINED',
23+
'LIVE_LOG' => $caddy ? "/var/log/caddy/{$domain}-access.log" : "/var/log/nginx/{$domain}-access.log",
24+
'LOG_GLOB' => $caddy ? "/var/log/caddy/{$domain}-access*.log*" : "/var/log/nginx/{$domain}-access.log*",
25+
'RETENTION_MONTHS' => (string) $retention,
26+
'SSH_USER' => $this->safeSshUser($site),
27+
];
28+
29+
$lines = [];
30+
foreach ($vars as $key => $value) {
31+
$lines[] = $key."='".$value."'";
32+
}
33+
34+
return implode("\n", $lines)."\n";
35+
}
36+
37+
private function resolveWebserverId(Site $site): string
38+
{
39+
$webserver = $site->server->webserver();
40+
41+
return $webserver ? $webserver->handler()::id() : 'nginx';
42+
}
43+
44+
private function safeDomain(Site $site): string
45+
{
46+
$domain = (string) $site->domain;
47+
48+
if (! preg_match('/^[A-Za-z0-9.\-]+$/', $domain)) {
49+
throw new Exception('Unsafe site domain for stats processing.');
50+
}
51+
52+
return $domain;
53+
}
54+
55+
private function safeSshUser(Site $site): string
56+
{
57+
$user = (string) $site->server->getSshUser();
58+
59+
if (! preg_match('/^[A-Za-z0-9_.\-]+$/', $user)) {
60+
throw new Exception('Unsafe SSH user for stats processing.');
61+
}
62+
63+
return $user;
64+
}
65+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Actions\SiteStats;
4+
5+
use App\Jobs\Site\ResyncGoAccessJob;
6+
use App\Models\Server;
7+
8+
class ResyncGoAccess
9+
{
10+
public function handle(Server $server): void
11+
{
12+
dispatch(new ResyncGoAccessJob($server));
13+
}
14+
}

0 commit comments

Comments
 (0)