) and the terminal: * * curl http://localhost:8088/api/chart.php?range=hour * curl http://localhost:8088/api/chart.php?range=day * curl http://localhost:8088/api/chart.php?range=month * curl http://localhost:8088/api/chart.php?range=year * * This endpoint is read-only (a fixed aggregate COUNT query with the only * user input being the `range` selector, which is validated against a * whitelist). It is safe to expose publicly and is served without auth at: * * https:///relay/api/chart.php?range=day * * It is also reachable (behind Basic Auth) from the admin UI at: * * https:///relay/admin/api/chart.php?range=day * * Caching: the hour chart is never cached (live). Day/month/year are * cached to file with TTLs to avoid expensive queries on every request. * * NOTE: Uses first_seen (not created_at) for binning, because some events * have corrupted created_at timestamps (far-future values) that would * place them outside the visible chart range. */ require_once __DIR__ . '/../lib/helpers.php'; require_once __DIR__ . '/../lib/ascii_chart.php'; // --- Range configuration --- $ranges = [ 'hour' => ['span' => 3600, 'bin' => 45, 'bins' => 80, 'ttl' => 0, 'title' => 'New Events — Last Hour'], 'day' => ['span' => 86400, 'bin' => 1080, 'bins' => 80, 'ttl' => 3600, 'title' => 'New Events — Last 24 Hours'], 'month' => ['span' => 2592000, 'bin' => 32400, 'bins' => 80, 'ttl' => 86400, 'title' => 'New Events — Last 30 Days'], 'year' => ['span' => 31536000, 'bin' => 394200, 'bins' => 80, 'ttl' => 2592000, 'title' => 'New Events — Last Year'], ]; $range = $_GET['range'] ?? 'hour'; if (!isset($ranges[$range])) { http_response_code(400); header('Content-Type: text/plain; charset=utf-8'); echo "Invalid range. Use: hour, day, month, or year.\n"; exit; } $cfg = $ranges[$range]; // --- Set content type --- header('Content-Type: text/plain; charset=utf-8'); // --- Check cache --- if ($cfg['ttl'] > 0) { $cached = get_cached_chart($range, $cfg['ttl']); if ($cached !== null) { echo $cached; exit; } } // --- Run binning query --- $pdo = db(); $now = time(); $epoch = $now - $cfg['span']; $bin_size = $cfg['bin']; $num_bins = $cfg['bins']; try { // Calculate the bin offset so the rightmost bin aligns with "now" // bin index 0 = oldest, bin index (num_bins-1) = newest $base_bin = (int)floor($epoch / $bin_size); // Use first_seen (not created_at) for binning, because some events // have corrupted created_at timestamps (far-future values) that // would place them outside the visible chart range. $sql = "SELECT FLOOR(first_seen / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt FROM events WHERE first_seen >= {$epoch} GROUP BY bin ORDER BY bin"; $rows = $pdo->query($sql)->fetchAll(); } catch (PDOException $e) { echo "Chart query failed: " . $e->getMessage() . "\n"; exit; } // --- Build bin array (zero-filled, fixed length) --- // Index 0 = oldest, index N-1 = newest (natural time order from SQL) $bins = build_bin_array($rows, $num_bins); // Reverse so newest bin is at index 0 (leftmost) and oldest at right. // This matches the original text_graph.js display: new data enters // from the left and proceeds rightward as it ages. $bins = array_reverse($bins); // --- Render ASCII chart --- $ascii = render_ascii_chart($bins, [ 'title' => $cfg['title'], 'max_height' => 11, 'bin_duration' => $bin_size, 'label_interval' => max(1, (int)floor($num_bins / 15)), // ~15 labels across ]); // --- Write to cache (if TTL > 0) --- if ($cfg['ttl'] > 0) { set_cached_chart($range, $ascii); } echo $ascii; // ================================ // CACHE HELPERS // ================================ function get_cache_dir(): string { return __DIR__ . '/../cache'; } function get_cached_chart(string $range, int $ttl): ?string { $file = get_cache_dir() . "/chart_{$range}.txt"; if (!file_exists($file)) return null; if (filemtime($file) < (time() - $ttl)) return null; $content = @file_get_contents($file); return ($content !== false) ? $content : null; } function set_cached_chart(string $range, string $ascii): void { $dir = get_cache_dir(); if (!is_dir($dir)) { @mkdir($dir, 0775, true); } @file_put_contents($dir . "/chart_{$range}.txt", $ascii); }