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:
flowchart TD
A["Request arrives"]
B["Load cgpsd-settings.php
(write defaults on first run; die on error)"]
C["Define constants, compute GRAPH_CUTOFF
Define helper & auth functions"]
D{"?ajax param set?"}
E["AJAX Router
header() + echo + exit"]
F{"DASHBOARD_REQUIRE_AUTH and not authed?"}
G["ob_start(minify_html_output)
Render standalone auth gate page
ob_end_flush() + exit"]
H["ob_start(minify_html_output)
Render full dashboard HTML
(ob callback fires; minified bytes sent)"]
A --> B --> C --> D
D -- Yes --> E
D -- No --> F
F -- Yes --> G
F -- No --> H
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:
- Fresh install – if
cgpsd-settings.phpdoes not exist, the dashboard writes$defaultConfigdirectly to disk to create it, thenincludes it. - Installer migration –
install.shreads$defaultConfigdirectly from the downloadedindex.phpat runtime to inject any blocks whose variable is absent from an existing settings file. No separate per-setting maintenance in the installer is required. - Web updater migration – the web updater (
?ajax=do_update) parses$defaultConfigat runtime, extracts every setting block, and injects any that are absent from the user’s livecgpsd-settings.php. Adding a setting to$defaultConfigis 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 user-selectable interval (default 10 s, persisted in localStorage). Returns everything the frontend needs to update the entire dashboard in one round-trip. Note: $GRAPH_SAMPLE_SEC controls the graph data sampling resolution (how many seconds between plotted data points), not the live browser poll interval.
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 Git API |
?ajax=clients |
GET/POST | Optional (CLIENTS_REQUIRE_AUTH) |
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 chronyc makestep directly via sudo |
?ajax=vitals |
POST | Admin token | Return live Node Vitals: host uptime, CPU temp/model/core count, system load, memory and disk usage, OS/kernel platform info, Raspberry Pi throttle status, network I/O (via vnstat or /proc/net/dev), and installed GPSd/Chrony/PHP/ChroGPS Dash version strings |
?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)
flowchart TD
A["gpsd (localhost:2947)"]
B["PHP TCP socket
?WATCH + ?POLL JSON-RPC commands"]
C["SKY messages
satellite az / el / SNR / used"]
D["TPV messages
fix mode, lat/lon/alt, speed"]
E["Batch: merge incremental SKY messages
into one epoch
(multi-constellation: one constellation at a time)"]
F["GPS_DATA_CACHE_FILE
(10 s TTL)"]
G["SAT_CACHE_FILE
(30 s TTL, persistent across connections)"]
H["gps key in AJAX response
Skyview SVG (PHP-rendered)
SNR table (JS-rendered from JSON)"]
A --> B
B --> C
B --> D
C --> E
D --> E
E --> F --> G --> H
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. This is expected behavior, not a bug.Chrony data
flowchart LR
A["chronyc tracking
offset, rms offset,
ref clock, freq error"]
B["chronyc sources
peer table: state, name,
stratum, reachability, offset"]
C["chronyc serverstats
NTP infrastructure counters"]
D["/etc/chrony/chrony.conf
maxdrift, makestep,
refclock directives"]
E["AJAX response
tracking + sources keys
History graphs (via log files)"]
A --> E
B --> E
C --> E
D --> E
All chronyc calls go through sudo with scoped permissions configured by the installer.
History logs and graph rendering
flowchart TD
A["chrogps-poller.timer
(fires every 30 s -- OnUnitActiveSec=30s)"]
B["chrogps-poller.service
curl localhost/index.php?ajax=1"]
C["index.php (ajax=1)
localhost, always exempt from auth gate
Appends one line to each log file"]
D["PPS_LOG_FILE
PPS offset + frequency error"]
E["SNR_LOG_FILE
mean SNR per constellation"]
F["SAT_LOG_FILE
satellite counts (used/seen)"]
G["DOP_LOG_FILE
HDOP/VDOP/PDOP/TDOP"]
H["SNR_HIST_LOG_FILE
per-band SNR histogram data"]
A --> B --> C
C --> D
C --> E
C --> F
C --> G
C --> H
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:
-
CSS –
minify_css()is applied to every<style>...</style>block viapreg_replace_callback. Strips block comments, collapses whitespace, removes redundant semicolons and punctuation.calc()expressions are stashed before whitespace collapse and restored verbatim afterward. -
JS –
minify_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. -
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 callheader()+exitbeforeob_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 whose interval (refreshInterval) is user-selectable via the interval selector in the header and persisted in localStorage (default 10 s). On each tick, a single fetch('?ajax=1') retrieves all dashboard data. There is no separate polling for individual components.
GRAPH_SAMPLE_MS (derived from $GRAPH_SAMPLE_SEC) is a separate concept from refreshInterval above – it controls the data sampling resolution for history graphs (how many seconds between plotted log points), not the live dashboard poll frequency. Do not conflate the two when touching this code.flowchart TD
A["setInterval(update, refreshInterval)
user-selectable, default 10 s"]
B["fetch('?ajax=1')"]
C["Chrony sources table"]
D["Tracking metrics panel"]
E["GPS fix, lat/lon, DOP values,
SNR table"]
F["SVG graph containers
(innerHTML replacement)"]
G["Per-satellite 1-hour SNR sparklines"]
H["chrony/gpsd online/offline state"]
A --> B
B -->|"data.sources"| C
B -->|"data.tracking"| D
B -->|"data.gps"| E
B -->|"data.graphs"| F
B -->|"data.sat_sparklines"| G
B -->|"data.service_status"| H
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, each carrying an identity via a data-view attribute (skyview, tracking, snr, history). Row membership is a separate concern: initRowLayout()’s hardcoded rowDefs array maps each row to the identities it contains (row1: tracking + skyview, row2: snr, row3: history) and queries the DOM by data-view to pull the matching cards into place. 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.
Rows can also be reordered by dragging their view-tab buttons (the row1/row2/row3 tabs in the view-tabs bar) into a new position; initTabDragging() handles the drag events, then syncRowsToTabs() re-appends each .dash-row element in the dashboard to match the new tab order. The resulting order is persisted under dash-row-order and re-applied by initRowLayout() on the next page load.
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:
- The visitor POSTs their token to
?ajax=dashboard_auth. - The server validates the token with
hash_equals(). - 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.
Version Notifications & One-Click Updater
The update system has three distinct phases: version check, notification, and installation.
Version check (?ajax=version)
When CHECK_UPDATES is enabled, the frontend calls ?ajax=version, which is a server-side proxy to the Git repository tree API. The server extracts the blob SHA of index.php from the API response and returns the short (10-character) hash to the browser. Using a server-side proxy avoids CORS restrictions and keeps the remote API URL out of the browser.
If CHECK_UPDATES is false, the server returns {"status": "disabled"} immediately without making any outbound request, and the frontend does nothing.
Frontend throttle and localStorage caching
The checkVersion() function runs 2 seconds after page load (so it never blocks initial rendering) and is re-invoked hourly via setInterval. The actual API fetch is throttled to once per 8 hours using three localStorage keys:
| Key | Contents |
|---|---|
cgpsd-update-check-time |
Timestamp (ms) of the last API call |
cgpsd-update-latest |
Latest remote hash returned by the last check |
cgpsd-update-current |
Installed version hash at the time of the last check |
On every page load, checkVersion() reads these keys first. If a previous check found an update and the installed version has not changed, the update pill appears immediately without waiting for a new API call. If the installed version has changed since the last check (i.e. an update was just applied), all three keys are cleared.
flowchart TD
A["checkVersion()
fires 2 s after page load"]
B{"Cached keys in
localStorage?"}
C{"cachedCurrent
== installed version?"}
D["Show #update-pill
immediately from cache"]
E["Clear all three keys
(version changed -- update was applied)"]
F{"8 h elapsed since
cgpsd-update-check-time?"}
G["fetch('?ajax=version')
Server proxies to Git tree API
Returns 10-char blob SHA"]
H["Persist timestamp + hashes
to localStorage"]
I{"data.latest !=
currentVersion?"}
J["Show #update-pill
with new version hash"]
K["No notification
-- already up to date"]
A --> B
B -- "No keys" --> F
B -- "Keys found" --> C
C -- "Pending update" --> D
C -- "Version changed" --> E --> F
F -- "No" --> K
F -- "Yes" --> G --> H --> I
I -- "Yes" --> J
I -- "No" --> K
Update pill
When data.latest !== currentVersion, an #update-pill button appears in the footer showing both the current and available version hashes. Clicking the pill opens the one-click updater panel in the admin area.
One-click updater (?ajax=do_update)
The endpoint streams real-time progress to the browser using Server-Sent Events (SSE). Once the admin token is validated, the updater executes these steps and streams a JSON event for each:
- Fetches the latest version info from the Git API to confirm the target hash.
- Downloads the new
index.phpfrom the Git raw URL to a temporary file. - Verifies the download is non-empty and syntactically valid PHP (
php -l). - Injects the new version hash into the
DASH_VERSIONconstant in the downloaded source. - Performs an atomic
rename()of the temp file over the liveindex.php– the live file is never partially written. Immediately after, it flushes PHP’s OPcache (opcache_reset()) so the remainder of the request executes the new code instead of the old file’s cached bytecode. - Migrates
cgpsd-settings.php: parses$defaultConfigfrom the newly installed version, identifies any setting blocks absent from the user’s existing settings file, and injects them automatically. No manual settings migration is required after an update. - Purges obsolete settings (
$ENABLE_SAT_HISTORY,$ENABLE_PPS_HISTORY– both now auto-detected from data-file existence instead) and touches any rolling history-log data files that are missing. - Runs an infrastructure health check: verifies
chrogps-poller.timeris active and emits a non-fatal warning – not an error, since the update has already succeeded by this point – if it isn’t running.
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. Each event is a JSON object: {"type": "step"|"ok"|"error"|"done", "msg": "..."}.
flowchart TD
A["User clicks #update-pill
Opens updater panel in admin area"]
B["POST ?ajax=do_update
(admin token in request body)"]
C["SSE: Validate admin token
(hash_equals(); 0.5 s delay on failure)"]
D["SSE: Confirm latest version from Git API"]
E["SSE: Download new index.php to temp file"]
F["SSE: Validate download
(non-empty + php -l syntax check)"]
G["SSE: Inject DASH_VERSION hash
into downloaded source"]
H["SSE: Atomic rename()
temp file over live index.php"]
I["SSE: Migrate cgpsd-settings.php
Inject missing blocks from $defaultConfig"]
M["SSE: Purge obsolete settings
+ initialise missing data-log files"]
N["SSE: Infrastructure health check
(chrogps-poller.timer active?)"]
J["SSE: done event emitted"]
K["Next ?ajax=1 poll detects
server_version != currentVersion"]
L["Hide pill · clear localStorage
Show stale-page toast"]
ERR["SSE: error event
Existing install untouched"]
A --> B --> C --> D --> E --> F --> G --> H --> I --> M --> N --> J --> K --> L
F -. "Fail" .-> ERR
H -. "Fail" .-> ERR
Stale page detection
Every ?ajax=1 response includes a server_version key containing the live DASH_VERSION. After a successful update, the next poll cycle in any open browser tab will detect that data.server_version !== currentVersion (the tab’s embedded version). When that happens, the frontend hides the update pill, clears the localStorage update cache, and displays a #stale-page-toast prompting the user to reload the page.
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 two octets of IPv4 addresses are replaced with * (e.g. 1.2.*.*), and the last five hextets of IPv6 addresses are replaced with * (e.g. 2001:db8:1:*:*:*:*:*). 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 chrogps-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).