Files

138 lines
4.6 KiB
PHP

<?php
/**
* admin/api/chart.php — Standalone ASCII chart endpoint.
*
* Returns a plain-text ASCII X-bar chart of event counts over time.
* Works in both the browser (injected into a <div>) 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://<domain>/relay/api/chart.php?range=day
*
* It is also reachable (behind Basic Auth) from the admin UI at:
*
* https://<domain>/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);
}