Files
c-relay-pg/plans/server_side_ascii_chart_plan.md
T

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, default 11) — chart height in rows
  • x_axis_label (string, default '')
  • bin_duration (int, seconds) — for X-axis elapsed-time labels
  • label_interval (int, default 5) — label every N bins

Algorithm (same as text_graph.js render method):

  1. max_count = max($bins); scale_factor = max(1, ceil(max_count / max_height))
  2. For each row from max_height down to 1:
    • 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
  3. X-axis: + followed by dashes (one per bin)
  4. X-axis labels: elapsed time every label_interval bins, formatted as Ns / Nm / Hh / Dd depending 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 with mkdir(..., 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:

  1. Read range query param (default: hour)
  2. Validate against allowed ranges (hour, day, month, year)
  3. Check cache: if valid, return cached string immediately
  4. If cache miss/expired: run the binning SQL query, fill the bin array, call render_ascii_chart(), write to cache, return the string
  5. Set Content-Type: text/plain; charset=utf-8 header

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, the addValue call in loadStats
  • Add: currentChartRange = 'hour' state variable
  • Add: loadChart(range) function — fetches api/chart.php?range=${range} as plain text, injects the response into #event-rate-chart via textContent
  • Add: tab click handlers that set currentChartRange and call loadChart
  • In loadStats: call loadChart(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-tabs styles (small monospace buttons, active state with accent border)

6. Cache Directory

  • admin2/cache/ — add to .gitignore (runtime artifacts)
  • Created at runtime by mkdir if 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

  1. Create admin2/lib/ascii_chart.php (the renderer)
  2. Create admin2/api/chart.php (standalone chart endpoint + cache logic)
  3. Modify admin2/index.php (add range tabs, remove text_graph.js)
  4. Modify admin2/assets/app.js (replace chart logic with loadChart)
  5. Modify admin2/assets/index.css (add tab styles)
  6. Add admin2/cache/ to .gitignore
  7. Delete admin2/assets/text_graph.js
  8. Test: verify all 4 ranges load, cache files appear, hour chart refreshes live