Skip to main content...

ChroGPS Dash Architecture

ChroGPS Dash is a single index.php file, roughly 15,000 lines long. This page documents how that file is structured, how requests are routed, how data flows from hardware to browser, and how the frontend keeps itself up to date. If you plan to contribute code, read this page alongside the Development and Contributing Guide, which covers coding standards, CSS authoring rules, and style conventions.


File Layout

The file executes top-to-bottom on every request. Its sections, in order:

Section Approximate lines Purpose
$defaultConfig Top of file Default cgpsd-settings.php content (see Settings System)
Settings load After $defaultConfig include cgpsd-settings.php or write it from defaults, or die() with the setup error page
Constants ~305-345 define() calls for log paths, cache paths, binary paths, TTLs, version string
Helper functions ~357-1639 SVG graph generators, getProgressBar(), log seek, formatBytes()
Auth helpers ~1640-1667 dashboardGetToken(), dashboardCookieValue(), dashboardIsAuthed()
AJAX router ~1668-3504 All ?ajax=* handlers - executes and exits before any HTML is produced
Self-minifier ~3506-3717 minify_css(), minify_js(), minify_html_output() - registered as ob_start callback
Dashboard auth gate ~3719-3963 Standalone lock screen rendered when DASHBOARD_REQUIRE_AUTH is on
HTML output ~3966-end ob_start('minify_html_output') then the full page: <style>, HTML, <script>

Request Lifecycle

Every HTTP request to index.php follows the same entry path:

Request arrives
    │
    ├─ Load cgpsd-settings.php (or write defaults / die with setup error page)
    ├─ Define constants, compute $GRAPH_CUTOFF
    ├─ Define helper functions (no execution yet)
    ├─ Define auth helpers
    │
    ├─ isset($_GET['ajax'])? ──► AJAX Router ──► header() + echo + exit
    │
    ├─ $DASHBOARD_REQUIRE_AUTH && !authed?
    │       └─► ob_start('minify_html_output')
    │           Render standalone auth gate page
    │           ob_end_flush() + exit
    │
    └─► ob_start('minify_html_output')
        Render full dashboard HTML
        (PHP closes; ob callback fires; minified bytes sent to client)

No framework, no router class, no autoloader. PHP falls through each conditional in sequence and exits as soon as it has produced a response.


Settings System

$defaultConfig

Near the top of index.php is a PHP string variable called $defaultConfig. It contains the complete, correctly-formatted default content for cgpsd-settings.php – every user-configurable variable with its comment block and default value.

$defaultConfig serves three roles simultaneously:

  1. Fresh install – if cgpsd-settings.php does not exist, the dashboard writes $defaultConfig directly to disk to create it, then includes it.
  2. Installer migrationinstall.sh reads $defaultConfig directly from the downloaded index.php at runtime to inject any blocks whose variable is absent from an existing settings file. No separate per-setting maintenance in the installer is required.
  3. Web updater migration – the web updater (?ajax=do_update) parses $defaultConfig at runtime, extracts every setting block, and injects any that are absent from the user’s live cgpsd-settings.php. Adding a setting to $defaultConfig is all that is required for it to propagate automatically on the next update.

Runtime load

$settingsFile = __DIR__ . '/cgpsd-settings.php';

if (file_exists($settingsFile)) {
    include $settingsFile;          // normal path
} else {
    file_put_contents($settingsFile, $defaultConfig);
    include $settingsFile;          // first-run path
    // OR: die() with setup error page if write fails
}

After the include, all $VARIABLE_NAME settings are live as PHP globals for the rest of the request.


AJAX Router

When $_GET['ajax'] is set, the router handles the request and exits before any HTML is produced. The main data poll endpoint (?ajax=1) collects and returns all live data in a single JSON object. All other endpoints handle discrete actions.

Main poll – ?ajax=1

Called by the frontend on a configurable interval (default 30 s, controlled by $GRAPH_SAMPLE_SEC). Returns everything the frontend needs to update the entire dashboard in one round-trip.

Response shape:

{
  "sources":        [ { "state": "...", "name": "...", "stratum": "...", ... } ],
  "tracking":       { "reference": "...", "offset": "...", "rms_offset": "...", ... },
  "gps":            { "fix": "...", "lat": "...", "lon": "...", "device": "...", ... },
  "graphs":         { "pps": "<svg>...</svg>", "snr": "<svg>...</svg>", ... },
  "sat_sparklines": { "G01": "<svg>...</svg>", ... },
  "service_status": { "chrony": true, "gpsd": true },
  "server_version": "abc1234"
}

graphs contains pre-rendered SVG strings for all history panels. The client replaces the innerHTML of each graph container with these strings – no client-side rendering.

History polls that request a specific time window use ?ajax=1&hours=N (N = 1–24). The server computes a UNIX timestamp cutoff ($GRAPH_CUTOFF) and passes it to the binary-search log readers.

Action endpoints

Endpoint Method Auth required Purpose
?ajax=version GET No (config-gated) Proxy version check against the Gitea API
?ajax=clients GET/POST No NTP client list
?ajax=dashboard_auth POST No (validates token) Authenticate a gate session, set cgpsd_auth_ok cookie
?ajax=get_settings POST Admin token Read current cgpsd-settings.php as JSON
?ajax=save_settings POST Admin token Write updated settings to cgpsd-settings.php
?ajax=regen_admin_token POST Admin token Generate and write a new ADMIN_TOKEN
?ajax=purge_logs POST Admin token Execute cgpsd-purge-logs helper script
?ajax=restart_gpsd POST Admin token Execute cgpsd-restart-gpsd helper script
?ajax=restart_chrony POST Admin token Execute cgpsd-restart-chrony helper script
?ajax=restart_poller POST Admin token Execute cgpsd-restart-poller helper script
?ajax=chrony_makestep POST Admin token Execute cgpsd-restart-chrony makestep variant
?ajax=vitals POST Admin token Return live Node Vitals (CPU, memory, temperature, uptime)
?ajax=do_update POST Admin token Download and atomically replace index.php; streams progress as SSE

All admin-token endpoints call hash_equals() for constant-time comparison and apply a 0.5 s usleep() delay on failure to slow brute-force attempts. Token values are accepted only via HTTP POST, never in the URL.


Data Flow

Satellite data (gpsd)

gpsd (localhost:2947)
    │
    └─► PHP TCP socket
            │  ?WATCH + ?POLL JSON-RPC commands
            ├─ SKY messages  ──► satellite az/el/SNR/used
            └─ TPV messages  ──► fix mode, lat/lon/alt, speed
            │
            ├─ Batching: merge incremental SKY messages into one epoch
            │   (multi-constellation receivers report one constellation at a time)
            │
            └─► GPS_DATA_CACHE_FILE (10 s TTL)
                    │
                    └─► SAT_CACHE_FILE (30 s TTL, persistent across connections)
                            │
                            └─► $gpsData array ──► `gps` key in AJAX response
                                                    Skyview SVG (PHP-rendered)
                                                    SNR table (JS-rendered from JSON)

The persistent satellite cache (SAT_CACHE_FILE) accumulates data across gpsd connections and multiple poll cycles. It is the reason the satellite count grows over the first few refreshes when a new session begins – the cache fills as each constellation batch arrives, typically one per second.

Chrony data

chronyc tracking   ──► offset, rms offset, ref clock, frequency error
chronyc sources    ──► peer table (state, name, stratum, reachability, offset)
chronyc serverstats──► NTP server infrastructure counters
/etc/chrony/chrony.conf ──► parsed for maxdrift, makestep, refclock directives
    │
    └─► `tracking` + `sources` keys in AJAX response
        History graphs (via log files -- see below)

All chronyc calls go through sudo with scoped permissions configured by the installer.

History logs and graph rendering

systemd timer: chrogps-poller.timer
    │  (fires every $GRAPH_SAMPLE_SEC seconds)
    └─► chrogps-poller.service
            │  (curl localhost/index.php?ajax=1)
            │
            └─► index.php (ajax=1, localhost, always exempt from auth gate)
                    │  Appends one line to each log file:
                    │
                    ├─ PPS_LOG_FILE    -- PPS offset + frequency error
                    ├─ SNR_LOG_FILE    -- mean SNR per constellation
                    ├─ SAT_LOG_FILE    -- satellite counts (used/seen)
                    ├─ DOP_LOG_FILE    -- HDOP/VDOP/PDOP/TDOP
                    └─ SNR_HIST_LOG_FILE -- per-band SNR histogram data

                    Each file is a rolling buffer capped at SAT_LOG_MAX_LINES
                    (15,000 lines). Older lines are trimmed on every write.

When the browser requests graphs, the PHP graph functions use a binary search (seekToCutoff()) to jump directly to the first log line within the requested time window – O(log n) file seeks regardless of how full the log is. The resulting data array is rendered to an SVG string server-side and returned in the graphs key of the AJAX response. The performance characteristics of the binary search and SVG engine are detailed in Technical Specifications.


Minifier Pipeline

Output buffering is the mechanism that lets the source file stay readable while the client receives compact bytes.

ob_start('minify_html_output');
// ... all HTML, <style>, <script> output ...
// PHP execution ends; output buffer flushes automatically,
// passing the full HTML string to minify_html_output().

minify_html_output() processes the string in three passes:

  1. CSSminify_css() is applied to every <style>...</style> block via preg_replace_callback. Strips block comments, collapses whitespace, removes redundant semicolons and punctuation. calc() expressions are stashed before whitespace collapse and restored verbatim afterward.

  2. JSminify_js() is applied to every <script>...</script> block. Strips // single-line comments (preserving newlines so code on the next line is not merged into the comment), collapses runs of whitespace outside strings and template literals. Template literal content (backtick strings) passes through unchanged.

  3. HTML – strips HTML comments (<!-- -->), then collapses inter-tag whitespace in the markup. SSE, JSON, and other non-HTML responses are never passed through this callback because those code paths call header() + exit before ob_start() runs.

The result is ~27% smaller than the unminified source, with zero runtime dependencies and no impact on the source file.


Frontend Architecture

Single poll loop

The entire frontend is driven by one setInterval loop (interval = GRAPH_SAMPLE_MS, derived from $GRAPH_SAMPLE_SEC injected by PHP). On each tick, a single fetch('?ajax=1') retrieves all dashboard data. There is no separate polling for individual components.

setInterval(update, GRAPH_SAMPLE_MS)
    │
    └─► fetch('?ajax=1')
            │
            ├─ data.sources   ──► Chrony sources table
            ├─ data.tracking  ──► Tracking metrics panel
            ├─ data.gps       ──► GPS fix, lat/lon, DOP values, SNR table
            ├─ data.graphs    ──► innerHTML replacement for all SVG containers
            ├─ data.sat_sparklines ──► per-satellite 1-hour SNR sparklines
            └─ data.service_status ──► online/offline state of chrony and gpsd

A separate setInterval(updateClocks, 1000) ticks every second to update the live UTC clock display without waiting for the full poll cycle.

View and row system

Cards are bare .card elements in the HTML source. On page load, initRowLayout() groups them into .dash-row wrappers (row1 = Skyview + Tracking, row2 = SNR, row3 = History). Each card declares its row via a data-view attribute. The active view is tracked in localStorage under active-view-tab.

Row collapse state is persisted in localStorage under row-collapse-{rowId}. The SNR table sort key and direction are persisted under snr-sort-key and snr-sort-asc.

Theme detection

On page load, a small inline <script> (outside the main JS block) reads localStorage.getItem('theme'). If the stored value is a named theme (dark, terminal, ctp-mocha, etc.), it sets data-theme on <html> before the first paint to prevent a flash of the default light theme. auto (the default) falls back to the OS prefers-color-scheme media query.


Standalone Pages

Two pages render instead of the main dashboard and have completely self-contained HTML, CSS, and (minimal) JS:

Page Trigger Rendered via
Setup error page cgpsd-settings.php missing and unwritable die() with a literal HTML string
Dashboard auth gate DASHBOARD_REQUIRE_AUTH on, no valid session cookie ob_start('minify_html_output') + ob_end_flush() + exit before the main page

Both pages carry their own :root CSS variable block (light values) and a @media (prefers-color-scheme: dark) override. They do not read localStorage, do not set data-theme, and contain no theme-switching JavaScript – they track only the OS preference.


Security

Admin token

All privileged operations are gated by a single shared secret stored in cgpsd-settings.php as $ADMIN_TOKEN. Tokens are 32-character lowercase hex strings generated with bin2hex(random_bytes(16)) – 128 bits of cryptographic entropy from the OS CSPRNG.

Every admin endpoint validates the token with hash_equals(), which performs a constant-time comparison to prevent timing-based oracle attacks. A usleep(500000) call (0.5 s) is inserted on every failed validation to slow brute-force attempts. Tokens are accepted only via HTTP POST body – never in GET parameters or URLs – so they do not appear in server access logs or browser history.

Regenerating the token via ?ajax=regen_admin_token atomically rewrites cgpsd-settings.php in place and returns the new value to the browser. Existing dashboard gate sessions are invalidated automatically because the session cookie is an HMAC derived from the token (see below).

Dashboard authentication gate

When DASHBOARD_REQUIRE_AUTH is enabled, unauthenticated visitors see a minimal standalone lock screen instead of the dashboard. Authentication works as follows:

  1. The visitor POSTs their token to ?ajax=dashboard_auth.
  2. The server validates the token with hash_equals().
  3. On success, the server sets a session cookie:
Name:     cgpsd_auth_ok
Value:    HMAC-SHA256(key=$ADMIN_TOKEN, data="cgpsd-dash-gate-v1")
Flags:    HttpOnly, SameSite=Strict, session lifetime (no Max-Age)

The cookie value is a keyed HMAC rather than the raw token. This means the token itself is never transmitted to the client. On every subsequent request, dashboardIsAuthed() recomputes the expected HMAC from the live $ADMIN_TOKEN and compares it against the submitted cookie with hash_equals(). Regenerating the token changes the HMAC, immediately invalidating all existing sessions without requiring a separate session store.

The gate is enforced at two layers: the HTML page response (replaced by the standalone lock screen) and every AJAX data endpoint (which returns a 200 JSON error rather than data). Localhost requests (127.0.0.1 / ::1) are always exempt so the systemd poller timer can continue writing history logs uninterrupted.

One-click updater (SSE)

The ?ajax=do_update endpoint streams real-time progress to the browser using Server-Sent Events (SSE). Once the token is validated, the updater:

  1. Downloads the new index.php from the Gitea raw URL to a temporary file.
  2. Verifies the download is non-empty and syntactically valid PHP (php -l).
  3. Performs an atomic rename() of the temp file over the live index.php.

SSE headers (Content-Type: text/event-stream, X-Accel-Buffering: no) are set before streaming begins. The output buffer is flushed before the SSE loop starts so progress events reach the browser in real time rather than being held until the response is complete. Each event is a JSON object with a type (step, ok, error, done) and a msg string.

IP masking

When MASK_CLIENT_IPS is enabled, the NTP client list applies partial redaction before any IP is included in a response: the last octet of IPv4 addresses and the last three hextets of IPv6 addresses are replaced with x. All client data is HTML-escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8') before being serialised to JSON.

Cache-Control

The main page response sets Cache-Control: no-store, must-revalidate so that navigating back to the dashboard always fetches a fresh copy. This prevents stale post-update page loads where the browser might serve a cached pre-update version of the JavaScript.


Helper Binaries

Admin actions that require elevated privileges are delegated to small wrapper scripts in /usr/local/bin/. The installer configures /etc/sudoers.d/ to allow www-data to run these specific binaries with sudo, and nothing else.

Binary Purpose
cgpsd-purge-logs Delete and recreate all rolling log files
cgpsd-restart-gpsd systemctl restart gpsd
cgpsd-restart-chrony systemctl restart chrony
cgpsd-restart-poller systemctl restart chrogps-poller.timer

The cgpsd-poller.service (triggered by the timer) is also the mechanism that drives history logging – it calls curl localhost/index.php?ajax=1, which causes index.php to collect live data and append to the log files as a side effect of the normal poll response. The poller is always exempt from the dashboard auth gate (localhost requests bypass it).

Document Version: 52b62c1 -- Last Revision: 2026-06-21
Permanent Link: <https://w0chp.radio/chrogps-dash/architecture/>