138 lines
4.6 KiB
PHP
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);
|
|
}
|