StatsController.php 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Domain\Time\Clock;
  5. use App\Infrastructure\Reputation\DashboardStatsRepository;
  6. use DateTimeImmutable;
  7. use Psr\Http\Message\ResponseInterface;
  8. use Psr\Http\Message\ServerRequestInterface;
  9. /**
  10. * `GET /api/v1/admin/stats/dashboard` — aggregate counters + 24h
  11. * histogram + top reporters/categories + jobs status.
  12. *
  13. * In-process 30 s cache. Multi-replica deployments will see brief
  14. * staleness across replicas — accepted; mirrors the BlocklistCache
  15. * semantics from M07.
  16. *
  17. * RBAC: Viewer.
  18. */
  19. final class StatsController
  20. {
  21. use AdminControllerSupport;
  22. /** @var array<string, mixed>|null */
  23. private ?array $cached = null;
  24. private ?float $cacheExpiresAt = null;
  25. private const TTL_SECONDS = 30.0;
  26. /**
  27. * Expected job intervals (seconds). The status payload flips
  28. * `overdue` when a job's `last_finished_at` is older than its
  29. * interval. Mirrors the values backed by the env vars from §9.
  30. *
  31. * @var array<string, int>
  32. */
  33. private const EXPECTED_INTERVALS = [
  34. 'recompute-scores' => 600, // 10 min
  35. 'cleanup-audit' => 86400, // 1 day
  36. 'enrich-pending' => 3600, // 1 hour
  37. 'refresh-geoip' => 7 * 86400, // 1 week
  38. ];
  39. public function __construct(
  40. private readonly DashboardStatsRepository $stats,
  41. private readonly Clock $clock,
  42. ) {
  43. }
  44. public function dashboard(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  45. {
  46. $now = $this->clock->now();
  47. $nowFloat = (float) $now->getTimestamp();
  48. if ($this->cached !== null && $this->cacheExpiresAt !== null && $nowFloat < $this->cacheExpiresAt) {
  49. return self::json($response, 200, $this->cached);
  50. }
  51. $since = $now->modify('-24 hours');
  52. $weekAgo = $now->modify('-7 days');
  53. $payload = [
  54. 'active_blocks' => $this->stats->activeBlocksApprox(),
  55. 'manual_blocks_count' => $this->stats->manualBlocksCount(),
  56. 'allowlist_count' => $this->stats->allowlistCount(),
  57. 'reports_24h' => $this->stats->reportsSince($since),
  58. 'reports_24h_by_hour' => $this->stats->reportsByHourSince($since),
  59. 'top_reporters_24h' => $this->stats->topReportersSince($since),
  60. 'top_categories_24h' => $this->stats->topCategoriesSince($since),
  61. 'blocked_ips_by_day_7d' => self::buildBlockedSeries(
  62. $this->stats->blockedIpsByDayCategorySince($weekAgo),
  63. $this->stats->activeCategorySlugs(),
  64. $weekAgo,
  65. $now,
  66. ),
  67. 'jobs_status' => $this->stats->jobsStatus(self::EXPECTED_INTERVALS, $now),
  68. 'reference_policy' => 'moderate',
  69. ];
  70. $this->cached = $payload;
  71. $this->cacheExpiresAt = $nowFloat + self::TTL_SECONDS;
  72. return self::json($response, 200, $payload);
  73. }
  74. /**
  75. * Pivot a sparse `(day, category, count)` triple stream into the
  76. * stacked-bar shape consumed by the dashboard chart.
  77. *
  78. * The output guarantees:
  79. * - `days` covers every calendar day in `[from, to]`, in order.
  80. * - `series` contains one entry per category that either appears
  81. * in the rows or in `activeCategories` (so categories with zero
  82. * activity still render as flat zero bars instead of vanishing).
  83. * - Each series carries a counts array aligned 1:1 with `days`.
  84. *
  85. * @param list<array{day: string, category: string, count: int}> $rows
  86. * @param list<string> $activeCategories
  87. * @return array{days: list<string>, series: list<array{category: string, counts: list<int>}>}
  88. */
  89. private static function buildBlockedSeries(
  90. array $rows,
  91. array $activeCategories,
  92. DateTimeImmutable $from,
  93. DateTimeImmutable $to,
  94. ): array {
  95. $days = [];
  96. $cursor = $from->setTime(0, 0, 0);
  97. $end = $to->setTime(0, 0, 0);
  98. while ($cursor <= $end) {
  99. $days[] = $cursor->format('Y-m-d');
  100. $cursor = $cursor->modify('+1 day');
  101. }
  102. $byCategoryDay = [];
  103. $seenCategories = [];
  104. foreach ($rows as $row) {
  105. $cat = $row['category'];
  106. $seenCategories[$cat] = true;
  107. $byCategoryDay[$cat][$row['day']] = $row['count'];
  108. }
  109. // Combine seen + active so categories without any reports still
  110. // appear in the legend; sort for stable order across renders.
  111. $allCategories = array_keys(array_merge(
  112. array_flip($activeCategories),
  113. $seenCategories,
  114. ));
  115. sort($allCategories);
  116. $series = [];
  117. foreach ($allCategories as $cat) {
  118. $counts = [];
  119. foreach ($days as $day) {
  120. $counts[] = (int) ($byCategoryDay[$cat][$day] ?? 0);
  121. }
  122. $series[] = ['category' => $cat, 'counts' => $counts];
  123. }
  124. return ['days' => $days, 'series' => $series];
  125. }
  126. }