admin: Reports ▸ Wachstum mit den Abbruchbedingungen, Umami nur lesend angebunden

Roadmap 6.1/6.16: Block 1 bis 3 mit Ist, zeitanteiligem Soll, Schwelle und
Status, Trichter seit 17.09., Transparenzseiten aus Umami. Neue Befehle
growth:report und umami:report. Zugang über UMAMI_API_* (View-only-Benutzer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-13 14:55:29 +00:00
parent e8cf684f02
commit b4870f98bb
12 changed files with 1098 additions and 0 deletions

View file

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Nur lesende Abfrage der Umami-Webanalyse (Roadmap 6.16): Kennzahlen je
* Website, dazu eine Rangliste (Seiten, Herkunft, Kanäle …), optional auf
* Pfade gefiltert. Zugangsdaten aus `UMAMI_API_*`, werden nie ausgegeben.
*/
class ReportUmami extends Command
{
protected $signature = 'umami:report
{--from= : Startdatum (Standard: vor 30 Tagen)}
{--to= : Enddatum (Standard: jetzt)}
{--site=* : pressekonto, presseecho, businessportal24 (Standard: alle)}
{--path= : nur Pfade, die diesen Text enthalten, z. B. gute-pressemitteilung}
{--metric=channel : Rangliste nach path, referrer, channel, country, device, browser, event}
{--limit=10 : Zeilen der Rangliste}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Umami-Kennzahlen je Website (nur lesend).';
private const array METRICS = ['path', 'referrer', 'channel', 'country', 'device', 'browser', 'os', 'event', 'title', 'entry', 'exit'];
public function handle(UmamiClient $umami): int
{
if (! $umami->isConfigured()) {
$this->error('Umami-API nicht konfiguriert: UMAMI_API_URL, UMAMI_API_USERNAME und UMAMI_API_PASSWORD setzen.');
return self::FAILURE;
}
$metric = (string) $this->option('metric');
if (! in_array($metric, self::METRICS, true)) {
$this->error('Unbekannte Rangliste. Möglich: '.implode(', ', self::METRICS).'.');
return self::INVALID;
}
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to'))->endOfDay() : Carbon::now();
$from = $this->option('from') !== null ? Carbon::parse((string) $this->option('from'))->startOfDay() : $to->copy()->subDays(30);
$sites = (array) $this->option('site') ?: UmamiClient::BRANDS;
$filters = filled($this->option('path')) ? ['path' => 'c.'.$this->option('path')] : [];
$limit = max(1, min(500, (int) $this->option('limit')));
$result = [];
try {
foreach ($sites as $site) {
$websiteId = $umami->websiteId((string) $site);
if ($websiteId === null) {
$this->warn("Keine Umami-Website für „{$site}“ in config/domains.php.");
continue;
}
$result[$site] = [
'stats' => $umami->stats($websiteId, $from, $to, $filters),
$metric => $umami->metrics($websiteId, $metric, $from, $to, $filters, $limit),
];
}
} catch (UmamiUnavailable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
if ($this->option('json')) {
$this->line((string) json_encode([
'from' => $from->toIso8601String(),
'to' => $to->toIso8601String(),
'filters' => $filters,
'sites' => $result,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info(sprintf('Umami — %s bis %s%s', $from->format('d.m.Y'), $to->format('d.m.Y'), $filters === [] ? '' : ', Pfad enthält „'.$this->option('path').'“'));
foreach ($result as $site => $data) {
$this->newLine();
$this->line("<comment>{$site}</comment>");
$this->table(
['Aufrufe', 'Besucher', 'Besuche', 'Absprünge'],
[[$data['stats']['pageviews'], $data['stats']['visitors'], $data['stats']['visits'], $data['stats']['bounces']]],
);
$this->table(
[ucfirst($metric), 'Anzahl'],
array_map(static fn (array $row): array => [$row['x'], $row['y']], $data[$metric]),
);
}
return self::SUCCESS;
}
}