|
| 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 | +} |
0 commit comments