9.3 KiB
Server-Side ASCII Chart Plan
Goal
Replace the client-side text_graph.js ASCII chart with a server-side PHP renderer that produces the ASCII X-bar chart string. The chart is served from a dedicated plain-text endpoint (api/chart.php) that works both in the browser (injected into a <div>) and in the terminal via curl — the ASCII art renders correctly either way.
Four time ranges, each with its own bin size and refresh/caching strategy:
| Range | Span | Bin size | Bins | Refresh | Cache TTL |
|---|---|---|---|---|---|
| Hour | last 1h | 10 seconds | 360 | every 10s (live) | none |
| Day | last 24h | 5 minutes | 288 | every 10s* | 1 hour |
| Month | last 30d | 1 hour | 720 | every 10s* | 1 day |
| Year | last 365d | 1 day | 365 | every 10s* | 1 month |
* The client polls every 10s, but the server only re-runs the expensive query when the cache expires. Between cache expirations, the cached ASCII string is returned instantly.
Architecture
flowchart TD
subgraph Clients
T[Terminal — curl]
W[Web UI — app.js]
end
T -->|GET chart.php?range=hour| EP[chart.php]
W -->|GET chart.php?range=hour| EP
T -->|GET chart.php?range=day| EP
W -->|GET chart.php?range=day| EP
EP --> C{Cache valid?}
C -->|Yes| Return[Return cached ASCII string]
C -->|No| Query[Run GROUP BY binning query]
Query --> Render[render_ascii_chart in lib/ascii_chart.php]
Render --> CacheWrite[Write to cache file]
CacheWrite --> Return
Return -->|text/plain; charset=utf-8| T
Return -->|text/plain; charset=utf-8| W
subgraph "Cache files (admin2/cache/)"
H[chart_hour.txt — never cached]
D[chart_day.txt — TTL 1h]
M[chart_month.txt — TTL 1d]
Y[chart_year.txt — TTL 1mo]
end
Components
1. PHP ASCII Chart Renderer — admin2/lib/ascii_chart.php
A pure function that takes an array of bin counts and produces the ASCII X-bar chart string. Mirrors the layout of the original text_graph.js:
New Events
11 | X
10 | X X
9 | X X X X
8 | X X X X X X X
7 | X X X X X X X X X X
6 | X X X X X X X X X X X X X
5 | X X X X X X X X X X X X X X X
4 | X X X X X X X X X X X X X X X X X
3 |X X X X X X X X X X X X X X X X X X X
2 |X X X X X X X X X X X X X X X X X X X
1 |X X X X X X X X X X X X X X X X X X X
+----------------------------------------
0s 50s 100s 150s 200s 250s 300s
Function signature:
function render_ascii_chart(array $bins, array $options = []): string
Options:
title(string, default'New Events')max_height(int, default11) — chart height in rowsx_axis_label(string, default'')bin_duration(int, seconds) — for X-axis elapsed-time labelslabel_interval(int, default5) — label every N bins
Algorithm (same as text_graph.js render method):
max_count = max($bins);scale_factor = max(1, ceil(max_count / max_height))- For each row from
max_heightdown to1:- Y-axis label =
(row - 1) * scale_factor + 1, right-padded to 3 chars - For each bin: if
ceil(count / scale_factor) >= row→X, else space
- Y-axis label =
- X-axis:
+followed by dashes (one per bin) - X-axis labels: elapsed time every
label_intervalbins, formatted asNs/Nm/Hh/Dddepending on magnitude
2. Binning SQL Queries — in stats.php
Each range uses a FLOOR((created_at - epoch) / bin_size) GROUP BY query. The idx_events_created_at index makes these fast.
Hour (live, no cache):
SELECT FLOOR((created_at - :epoch) / 10)::INT AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= :epoch
GROUP BY bin
ORDER BY bin;
epoch = now - 3600,bin_size = 10s, produces up to 360 bins- Runs on every 10s poll (cheap: only scans last hour, indexed)
Day (cache TTL 1h):
SELECT FLOOR((created_at - :epoch) / 300)::INT AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= :epoch
GROUP BY bin
ORDER BY bin;
epoch = now - 86400,bin_size = 300s(5 min), produces up to 288 bins
Month (cache TTL 1d):
SELECT FLOOR((created_at - :epoch) / 3600)::INT AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= :epoch
GROUP BY bin
ORDER BY bin;
epoch = now - 2592000,bin_size = 3600s(1h), produces up to 720 bins
Year (cache TTL 1 month):
SELECT FLOOR((created_at - :epoch) / 86400)::INT AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= :epoch
GROUP BY bin
ORDER BY bin;
epoch = now - 31536000,bin_size = 86400s(1 day), produces up to 365 bins
Bin array assembly: Query returns only non-empty bins. PHP fills a fixed-length array (all zeros) and overlays the counts at the correct positions, so empty time slots show as blank columns — the chart always advances in time.
3. File-Based Cache — admin2/cache/
Simple file cache with TTL. No APCu/Redis dependency.
function get_cached_chart(string $range): ?string
function set_cached_chart(string $range, string $ascii): void
- Cache files:
admin2/cache/chart_{range}.txt - TTLs:
hour= 0 (never cache),day= 3600,month= 86400,year= 2592000 - Check:
filemtime($file) > time() - $ttl - Directory
admin2/cache/created automatically withmkdir(..., 0775, true)
4. Standalone Chart Endpoint — admin2/api/chart.php
A dedicated plain-text endpoint that returns the raw ASCII chart string. Works in both the browser and the terminal.
Request: GET api/chart.php?range=hour|day|month|year
Response: Content-Type: text/plain; charset=utf-8 — just the ASCII chart string, no JSON wrapper.
Terminal usage:
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
Logic:
- Read
rangequery param (default:hour) - Validate against allowed ranges (
hour,day,month,year) - Check cache: if valid, return cached string immediately
- If cache miss/expired: run the binning SQL query, fill the bin array, call
render_ascii_chart(), write to cache, return the string - Set
Content-Type: text/plain; charset=utf-8header
stats.php is unchanged — it continues to return the existing JSON stats (numbers only). The chart is a completely separate endpoint, keeping concerns cleanly separated.
5. Frontend Changes
admin2/index.php
- Replace the single chart div with a chart container + range selector tabs:
<div class="chart-range-tabs"> <button class="chart-tab active" data-range="hour">1H</button> <button class="chart-tab" data-range="day">1D</button> <button class="chart-tab" data-range="month">1M</button> <button class="chart-tab" data-range="year">1Y</button> </div> <div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div> - Remove
<script src="assets/text_graph.js">(no longer needed)
admin2/assets/app.js
- Remove:
eventRateChart,previousTotalEvents,initializeEventRateChart,createChartStubElements, theaddValuecall inloadStats - Add:
currentChartRange = 'hour'state variable - Add:
loadChart(range)function — fetchesapi/chart.php?range=${range}as plain text, injects the response into#event-rate-chartviatextContent - Add: tab click handlers that set
currentChartRangeand callloadChart - In
loadStats: callloadChart(currentChartRange)at the end (so the hour chart refreshes every 10s) - On DOMContentLoaded: call
loadChart('hour')to load the initial chart
admin2/assets/index.css
- Add
.chart-range-tabsstyles (small monospace buttons, active state with accent border)
6. Cache Directory
admin2/cache/— add to.gitignore(runtime artifacts)- Created at runtime by
mkdirif missing
File Summary
| File | Action |
|---|---|
admin2/lib/ascii_chart.php |
New — PHP ASCII chart renderer function |
admin2/api/chart.php |
New — standalone plain-text chart endpoint with 4 binning queries + cache |
admin2/api/stats.php |
Unchanged — continues returning JSON stats as before |
admin2/index.php |
Modify — add range tabs, remove text_graph.js script |
admin2/assets/app.js |
Modify — replace client chart with loadChart() fetch+inject |
admin2/assets/index.css |
Modify — add .chart-range-tabs styles |
admin2/cache/ |
New dir — runtime cache files (gitignored) |
admin2/assets/text_graph.js |
Delete — no longer needed |
Execution Order
- Create
admin2/lib/ascii_chart.php(the renderer) - Create
admin2/api/chart.php(standalone chart endpoint + cache logic) - Modify
admin2/index.php(add range tabs, remove text_graph.js) - Modify
admin2/assets/app.js(replace chart logic withloadChart) - Modify
admin2/assets/index.css(add tab styles) - Add
admin2/cache/to.gitignore - Delete
admin2/assets/text_graph.js - Test: verify all 4 ranges load, cache files appear, hour chart refreshes live