| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143 |
- <?php
- declare(strict_types=1);
- namespace App\Application\Admin;
- use App\Domain\Time\Clock;
- use App\Infrastructure\Reputation\DashboardStatsRepository;
- use DateTimeImmutable;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- /**
- * `GET /api/v1/admin/stats/dashboard` — aggregate counters + 24h
- * histogram + top reporters/categories + jobs status.
- *
- * In-process 30 s cache. Multi-replica deployments will see brief
- * staleness across replicas — accepted; mirrors the BlocklistCache
- * semantics from M07.
- *
- * RBAC: Viewer.
- */
- final class StatsController
- {
- use AdminControllerSupport;
- /** @var array<string, mixed>|null */
- private ?array $cached = null;
- private ?float $cacheExpiresAt = null;
- private const TTL_SECONDS = 30.0;
- /**
- * Expected job intervals (seconds). The status payload flips
- * `overdue` when a job's `last_finished_at` is older than its
- * interval. Mirrors the values backed by the env vars from §9.
- *
- * @var array<string, int>
- */
- private const EXPECTED_INTERVALS = [
- 'recompute-scores' => 600, // 10 min
- 'cleanup-audit' => 86400, // 1 day
- 'enrich-pending' => 3600, // 1 hour
- 'refresh-geoip' => 7 * 86400, // 1 week
- ];
- public function __construct(
- private readonly DashboardStatsRepository $stats,
- private readonly Clock $clock,
- ) {
- }
- public function dashboard(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $now = $this->clock->now();
- $nowFloat = (float) $now->getTimestamp();
- if ($this->cached !== null && $this->cacheExpiresAt !== null && $nowFloat < $this->cacheExpiresAt) {
- return self::json($response, 200, $this->cached);
- }
- $since = $now->modify('-24 hours');
- $weekAgo = $now->modify('-7 days');
- $payload = [
- 'active_blocks' => $this->stats->activeBlocksApprox(),
- 'manual_blocks_count' => $this->stats->manualBlocksCount(),
- 'allowlist_count' => $this->stats->allowlistCount(),
- 'reports_24h' => $this->stats->reportsSince($since),
- 'reports_24h_by_hour' => $this->stats->reportsByHourSince($since),
- 'top_reporters_24h' => $this->stats->topReportersSince($since),
- 'top_categories_24h' => $this->stats->topCategoriesSince($since),
- 'blocked_ips_by_day_7d' => self::buildBlockedSeries(
- $this->stats->blockedIpsByDayCategorySince($weekAgo),
- $this->stats->activeCategorySlugs(),
- $weekAgo,
- $now,
- ),
- 'jobs_status' => $this->stats->jobsStatus(self::EXPECTED_INTERVALS, $now),
- 'reference_policy' => 'moderate',
- ];
- $this->cached = $payload;
- $this->cacheExpiresAt = $nowFloat + self::TTL_SECONDS;
- return self::json($response, 200, $payload);
- }
- /**
- * Pivot a sparse `(day, category, count)` triple stream into the
- * stacked-bar shape consumed by the dashboard chart.
- *
- * The output guarantees:
- * - `days` covers every calendar day in `[from, to]`, in order.
- * - `series` contains one entry per category that either appears
- * in the rows or in `activeCategories` (so categories with zero
- * activity still render as flat zero bars instead of vanishing).
- * - Each series carries a counts array aligned 1:1 with `days`.
- *
- * @param list<array{day: string, category: string, count: int}> $rows
- * @param list<string> $activeCategories
- * @return array{days: list<string>, series: list<array{category: string, counts: list<int>}>}
- */
- private static function buildBlockedSeries(
- array $rows,
- array $activeCategories,
- DateTimeImmutable $from,
- DateTimeImmutable $to,
- ): array {
- $days = [];
- $cursor = $from->setTime(0, 0, 0);
- $end = $to->setTime(0, 0, 0);
- while ($cursor <= $end) {
- $days[] = $cursor->format('Y-m-d');
- $cursor = $cursor->modify('+1 day');
- }
- $byCategoryDay = [];
- $seenCategories = [];
- foreach ($rows as $row) {
- $cat = $row['category'];
- $seenCategories[$cat] = true;
- $byCategoryDay[$cat][$row['day']] = $row['count'];
- }
- // Combine seen + active so categories without any reports still
- // appear in the legend; sort for stable order across renders.
- $allCategories = array_keys(array_merge(
- array_flip($activeCategories),
- $seenCategories,
- ));
- sort($allCategories);
- $series = [];
- foreach ($allCategories as $cat) {
- $counts = [];
- foreach ($days as $day) {
- $counts[] = (int) ($byCategoryDay[$cat][$day] ?? 0);
- }
- $series[] = ['category' => $cat, 'counts' => $counts];
- }
- return ['days' => $days, 'series' => $series];
- }
- }
|