|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 */ 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 $rows * @param list $activeCategories * @return array{days: list, series: list}>} */ 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]; } }