diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0a419fa
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+# Run the automated test suite (issue #26) on every push and pull request so a
+# broken calculation can't merge unnoticed.
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+
+# Cancel an in-progress run when a newer commit is pushed to the same ref.
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@v4
+
+ - name: Set up Deno
+ uses: denoland/setup-deno@v2
+ with:
+ deno-version: v2.x
+
+ - name: Lint
+ run: deno lint js/ ts/
+
+ - name: Run tests
+ run: deno task test
diff --git a/Dockerfile b/Dockerfile
index 56f6c29..b726cda 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,8 +6,7 @@ RUN apt-get update -q && \
WORKDIR /local
-# Prefer not to be on latest commit to ensure consistency. (This may change)
-RUN git clone --depth 1 --branch v0.0.6-beta https://github.com/wittend/mag-usb
+RUN git clone https://github.com/wittend/mag-usb
RUN cd mag-usb && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DENABLE_WEBSOCKET=ON && \
diff --git a/README.md b/README.md
index 01de5c3..20dd832 100644
--- a/README.md
+++ b/README.md
@@ -26,34 +26,45 @@ This repository is designed to be used alongside the HamSCI ground magnetometer.
Instructions for setting up the magnetometer are available
[here](https://hamsci.org/mag_install).
-An additional computer running Linux or macOS is required to host the
-magnetometer.
+A computer running Linux or macOS is required to host the magnetometer. The
+dashboard can be hosted on the same computer as the magnetometer or a separate
+client.
* If using Linux, Ubuntu is recommended, but other distributions should also
work.
* If using macOS, Docker Desktop is required to run mag-usb.
-* Raspberry Pis and Windows are unsupported.
+* Windows is unsupported.
### Software
[Mag-usb](https://github.com/wittend/mag-usb) must be installed on the host
machine with WebSocket mode enabled.
+[Deno](https://deno.com) is used to serve the dashboard. Installing Deno is
+preferred for Linux clients.
+
[Docker Desktop](https://www.docker.com/products/docker-desktop/) is preferred
-for running the project. The dashboard is served via a Deno container.
+for Windows or macOS clients. The project is run through a Deno container.
## Usage
-In the vendor folder, unzip the archive containing fontawesome.
+In the project's vendor folder, unzip the archive containing Font Awesome.
+
+In a CLI, navigate to the project's root directory. Start the dashboard with
+Deno:
+
+```bash
+deno task dev
+```
-In a CLI, navigate to the project's root directory. Use `docker compose` to
-create a container:
+Or use `docker compose` to create a container:
```bash
docker compose up -d frontend
```
By default, the dashboard will be available at `localhost:8000`. However, the
-hostname and port can be changed as needed.
+hostname and port can be changed as needed through a .env file. A .env.example
+file is included to demonstrate how to configure the environment.
The dashboard must be given a host to connect to before any data is displayed.
The host is the IP address of the computer running mag-usb. A simple `ifconfig`
@@ -64,9 +75,17 @@ If your dashboard does not show a "Connected" status within a few seconds or
switches to a "Failed" status, check that you entered the host correctly, then
check mag-usb to make sure it is configured correctly.
-The dashboard is currently limited to one host at a time. This can be mitigated
-by opening a duplicate dashboard in another tab.
-
The dashboard will autoscroll with the most recently collected data. You can
zoom in/pan on specific regions of the plots to disable the autoscroll behavior.
-The behavior can be re-enabled by double clicking on the plots.
\ No newline at end of file
+The behavior can be re-enabled by double clicking on the plots.
+
+## Features
+
+* Real-Time data streaming via WebSocket protocol or MQTT
+* .log file uploading for historical data viewing
+* Tabbed interface for tracking multiple sources
+* Baseline post-processing and rate of change
+* Rotation post-processing
+* Moving average to smooth noisy data
+* Export live data in JSONL or CSV format (up to 1 hour continuous)
+* Light/dark theme support
\ No newline at end of file
diff --git a/ai/ai_usage_log.md b/ai/ai_usage_log.md
index 0dc52b3..eada67a 100644
--- a/ai/ai_usage_log.md
+++ b/ai/ai_usage_log.md
@@ -72,3 +72,115 @@ Required per University of Scranton AI Policy, HamSCI Generative AI Use Agreemen
- **Nature of Contribution**: Code generation (feature + bug fix)
- **Human Review Status**: Reviewed and verified (create/switch/close multiple tabs, two independent live sources with background buffering, persistence + reconnect-all on reload, and a clean switch to an empty source — all verified via headless browser against two local fake hosts)
- **Git Hash**: 66813ae
+
+## [2026-07-06 19:34 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Finish delta-B (issue #23) and dB/dt (issue #29), plus a magnitude-filter correctness fix and a spreadsheet header-alignment polish. (1) #23: reworked delta-B to a fixed snapshot baseline — Constant uses the typed ΔH/ΔE/ΔZ; Moving Average snapshots the current trailing moving average at Save — so applyDeltaB is an O(1) fixed subtraction. This fixes the earlier moving-dB overlay-cancels-to-0 bug and the O(N²·W) slowdown. Made usesDeltaB numeric, added a Reset (absolute) control, and made updateCoordGraphs always restyle H/E/Z + magnitude. (2) #29: compute dB/dt via real Δt in nT/s from the rotated raw magnitude (baseline-independent); show it in the Current Reading and as a spreadsheet column toggled by "Show dB/dt". (3) Magnitude-filter correctness: the moving-average overlay's magnitude now smooths the displayed magnitude (average of the per-sample magnitudes) rather than the magnitude of the averaged vector, so it tracks the raw scatter under delta-B; H/E/Z keep the vector average. (4) Spreadsheet polish: reserve the scrollbar gutter on the header and body so column labels stay centered over the data when the body scrolls.
+- **Sections/Files Affected**: `js/index.js` (delta-B snapshot baseline + O(1) applyDeltaB, Reset handler, updateCoordGraphs, dB/dt via real Δt, spreadsheet dB/dt column, magnitude filter via avg-of-magnitudes), `js/filter.js` (new trailingScalarMean helper), `index.html` (Reset (absolute) button, dB/dt table header), `css/index.css` (dB/dt column show/hide, scrollbar-gutter alignment for the fixed header + scrolling body)
+- **Nature of Contribution**: Code generation (features + bug fixes)
+- **Human Review Status**: Reviewed and verified (delta-B constant/moving, no overlay cancellation, reset, dB/dt value + column toggle, magnitude filter tracking the raw scatter, and header/data column alignment all verified via headless browser plus a numeric check against the real filter code; author confirmed the alignment fix)
+- **Git Hash**: 34a998d
+
+## [2026-07-07 15:15 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Make historical .log file import performant at full-day size (86,400 rows) for issue #15. Two changes: (1) replaced the O(N·W) trailing moving-average recompute with an O(N) sliding-window running-sum pass (`slidingWindowMeans`), eliminating the per-point re-summation and transient-object GC churn that dominated a day-size load; (2) virtualized the spreadsheet so only the visible row window (plus overscan) is in the DOM, with off-screen height reserved by spacer `
`s and a single shared date formatter — so import populates the table instantly instead of building 86,400 DOM rows. Together these cut the full import (filter on) from ~44s to ~3.2s. `loadLogFile` now populates the spreadsheet; the filter is applied across the whole file and the plot range spans the whole dataset (goals #1–#3).
+- **Sections/Files Affected**: `js/filter.js` (new `slidingWindowMeans` O(N) helper), `js/index.js` (`recomputeFiltered` single-pass rewrite; spreadsheet virtualization — `spacerRow`/`windowRows`/`renderSpreadsheet`/`resetSpreadsheet`, rewritten `rebuildSpreadsheet`/`addSpreadsheetRow`, shared `DATE_FMT`, scroll handler; `loadLogFile`/`renderActive`/`clearActiveView` repointed at the virtualized renderer), `css/index.css` (`.stripe`-class striping and `.spacer` rows for the virtualized body)
+- **Nature of Contribution**: Code generation (performance optimization)
+- **Human Review Status**: Reviewed and verified (86,400-row import measured at ~3.2s with 35 DOM rows; filter=86,400 points, range=24h; scroll top/mid/bottom show newest/midday/oldest with the body bounded to 325px; live WebSocket path prepends newest-first and updates the Current Reading — all verified via headless Chromium against a day-size log and a fake WS host)
+- **Git Hash**: eb039f3
+
+## [2026-07-08 20:34 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Implement light and dark theme support (issue #16). Wired the previously inert footer toggle into a full theming system: added a `theme` preference ("system"/"light"/"dark") to settings with migration, defaulting to the OS `prefers-color-scheme` until the user flips the toggle (then their explicit choice persists to localStorage and is restored on load). Tokenized the CSS into semantic color-pair variables with dark and light palettes, added an anti-flash inline `` script that sets the theme before first paint, and made the Plotly charts (main + sparklines) re-theme their backgrounds/grids/font on toggle without rebuilding traces or losing the axis range. Light theme uses a dark brushed-silver (gunmetal) chrome so the white logo, light chrome text, and the colored connection-status indicator (incl. the yellow "Failed" state) stay readable; the table header uses a matching solid gunmetal; config inputs get a border/tint so they stand out on the light panel. Also fixed the Trends sparklines not filling their container (removed the hardcoded layout height so they autosize like the main plot) and vertically centered the footer theme toggle.
+- **Sections/Files Affected**: `css/index.css` (semantic token pairs + dark/light palettes; tokenized chrome/surfaces/inputs/tabs; gunmetal light chrome + solid gunmetal table header; centered footer toggle), `index.html` (anti-flash inline theme script), `js/index.js` (settings.theme default + migration; resolveTheme/applyTheme; toggle + matchMedia wiring; plotTheme/themeLayout/retintPlots and layout injection), `js/data/sparklines.json` (removed fixed height so sparklines autosize)
+- **Nature of Contribution**: Code generation (feature)
+- **Human Review Status**: Reviewed and verified (OS-default resolution for dark/light, toggle flips + persists, explicit choice overrides OS on reload, charts/spreadsheet/chrome re-theme with 86,400-row data loaded with no trace/range loss, yellow "Failed" status readable on gunmetal chrome, sparklines fill container — all verified via headless Chromium with screenshots; author confirmed the visual result)
+- **Git Hash**: e7a35e8
+
+## [2026-07-08 21:27 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Fix the time-series panning so autofollow disengages when the user drags the Plotly range slider. Previously the plot's `plotly_relayout` handler only detected drag/zoom inside the plot body (`xaxis.range[0]`/`[1]`), so a range-slider drag (which emits `xaxis5.range`, the slider's bottom axis) left autofollow on and the view snapped back to the trailing window on the next reading. Broadened the handler to also treat a slider drag as a user pan, distinguishing it from the app's own `updateRange()` (which sets every axis, so its asynchronously-delivered event includes `xaxis.range`) to avoid self-disabling autofollow during live updates; also wrapped the one remaining unguarded `updateRange()` (time-window change) in `updateLock`.
+- **Sections/Files Affected**: `js/index.js` (`plotly_relayout` handler in `attachPlotHandlers`: added `userSlider` detection alongside the existing `userZoom`; `updateLock` guard on the time-window `updateRange()`)
+- **Nature of Contribution**: Code generation (bug fix)
+- **Human Review Status**: Reviewed and verified (headless Chromium against a live fake host: autofollow still tracks live data when on; the range slider's exact event shape disables autofollow with no snap-back; a real mouse-drag run also held the view; range-slider event key confirmed empirically as `xaxis5.range`; zoom and double-click paths unchanged)
+- **Git Hash**: 377f42a
+
+## [2026-07-09 17:16 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Implement automated tests for critical functionality (issue #26) and wire up CI. Expanded the author's `js/tests.js` starter into a 28-case Deno test suite covering the critical calculations: XYZ→HEZ conversion, coordinate rotation (`Vector.rotate` in degrees + radians, magnitude preservation), delta-B/scale, magnitude, JSON Lines parsing into a `Measurement` (fields, HEZ, UTC timestamp, constructor type-rejection), JSONL/CSV export formatting + round-trip, sparkline data-buffer aggregation (`reduceBucket`/`minMaxOfBucket`), and both the trailing and O(N) `slidingWindowMeans` moving-average paths (including an equivalence check). Added a `deno task test` command so the suite runs with one command, removed the now-redundant `js/filter_test.js` (superseded by `tests.js`), and added a GitHub Actions workflow that runs `deno lint` + the tests on every push/PR. Cleaned up the two remaining lint findings that would have failed CI: typed the `onReading` handler in `index.d.ts` (replacing `Function`) and dropped an unnecessary `async` on the `Deno.serve` handler in `main.ts`. (Separately, the author's own lint cleanup to `index.js`/`transit.js` was committed on its own beforehand.)
+- **Sections/Files Affected**: `js/tests.js` (new tests for rotation/export/buffers/parsing/sliding-window), `deno.json` (`test` task), `js/index.d.ts` (`onReading` typed signature), `ts/main.ts` (removed unused `async`), `.github/workflows/ci.yml` (new CI: lint + test), `js/filter_test.js` (removed — superseded)
+- **Nature of Contribution**: Code generation (tests + CI) and minor type/lint fixes
+- **Human Review Status**: Reviewed and verified (all 28 tests pass via `deno task test`; `deno lint js/ ts/` clean across 9 files; `deno check ts/main.ts` clean; CI steps mirror the local commands)
+- **Git Hash**: 35c97c2
+
+## [2026-07-09 20:22 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Make the Trends sparklines account for the active source's coordinate rotation (and delta-B), which they previously ignored — they rendered raw HEZ and never refreshed when the transform changed, so they disagreed with the main plot. Introduced a single `displayHEZ(m)` helper (rotation + delta-B baseline) as the shared source of truth for what gets plotted, made `buildSparklineTraces` take that display-mapper so the H/E/Z/magnitude rows mirror the main plot, and added a `refreshSparks()` call to the Save Rotation / Save dB / Reset dB handlers so the sparklines update on any transform change. Also DRY'd `buildSparklineTraces` (5 duplicated line+marker literal pairs → a `row()` helper) and reused `displayHEZ` across the live trace append, current-reading, and spreadsheet-row paths (collapsing four copies of the rotate-then-maybe-deltaB pattern).
+- **Sections/Files Affected**: `js/sparklines.js` (`buildSparklineTraces(sparklines, toDisplay)` + `row()` helper, empty-guard), `js/index.js` (new `displayHEZ`; `updateSparks` passes it; new `refreshSparks` wired into rotation/delta-B handlers; `displayHEZ` reused in `extendAllTraces`/`updateCurrentTable`/`spreadsheetRowHTML`)
+- **Nature of Contribution**: Code generation (bug fix + refactor)
+- **Human Review Status**: Reviewed and verified (`deno lint` clean; 28 tests pass; headless check: a 90° Z-rotation transforms the sparkline rows correctly — H→−E, E→H — and the sparkline H matches the main-plot H, with no page errors)
+- **Git Hash**: 8531f7e
+
+## [2026-07-09 22:24 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Enhance the Trends sparklines. Wired the previously-unused `minMaxOfBucket` into a translucent min/max envelope band behind each average line (so short excursions aren't averaged away by the bucket mean); the band is drawn in display space through `displayHEZ` so it respects rotation/delta-B, exact for the linear H/E/Z rows and an average-enclosing approximation for the nonlinear magnitude row. Reworked the rows to H/E/Z/Magnitude (dropped temperature; magnitude replaces it), added per-row text labels (accessibility, not color-only), switched the traces from WebGL `scattergl` to lighter SVG `scatter`, hid the y-axis tick numbers, and laid the four panels out as a 2×2 grid with tightened inter-panel gaps. Repositioned the Trends box to sit between Current Reading and the Spreadsheet at 15% of the right column (to set up a later layout change). `ingest` now stores a per-bucket `{avg, lo, hi}` aggregate.
+- **Sections/Files Affected**: `js/sparklines.js` (`buildSparklineTraces` min/max band + average + marker, H/E/Z/Mag rows, band encloses the average), `js/index.js` (`ingest` stores `{avg, lo, hi}`, imports `minMaxOfBucket`), `js/data/sparklines.json` (2×2 grid, SVG scatter, per-row labels, hidden y-ticks, tightened gaps), `js/index.d.ts` (`SparkAgg` typedef), `css/index.css` (Trends slice 15%, reordered below Current Reading), `index.html` (moved the Trends block above the Spreadsheet)
+- **Nature of Contribution**: Code generation (feature + layout)
+- **Human Review Status**: Reviewed and verified (`deno lint` clean across 9 files; 28 tests pass; headless demo confirmed the 2×2 band/labels render with no y-ticks and no page errors; author verified the layout manually)
+- **Git Hash**: 5b35531
+
+## [2026-07-09 23:44 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Restore the unified cross-subplot hover on the main plot (hovering shows all five channels — H, E, Z, Magnitude, Temperature — at one timestamp). It had broken because the main plot's five stacked rows were each on a separate matched x-axis, and `layout.hoversubplots` was set to the invalid value `"all"` (silently falling back to `"overlaying"`, which only shows the hovered row); Plotly's `"axis"` mode can only group rows that literally share an x-axis. Fixed by putting all traces on a single shared x-axis with five stacked y-axes (keeping per-row gridlines and the bottom rangeslider) and setting `hoversubplots: "axis"`. Because the rangeslider and the app's own `updateRange()` now both emit `xaxis.range`, reworked `updateRange()` to hold `updateLock` across Plotly's asynchronously-delivered `plotly_relayout` event (cleared in the relayout promise's `.finally`) so autofollow isn't self-disabled, and simplified the autofollow relayout handler (any user `xaxis.range` change disables follow) and its call sites.
+- **Sections/Files Affected**: `js/data/plots.json` (all traces on shared `x`; single bottom x-axis with rangeslider + ticks; removed the extra x-axes and the now-unneeded grid; `hoversubplots: "axis"`), `js/index.js` (`updateRange` single `xaxis.range` with async-safe `updateLock`; simplified `plotly_relayout`/`plotly_doubleclick` handlers; removed manual lock wrapping at the live/render/time-window call sites)
+- **Nature of Contribution**: Code generation (bug fix)
+- **Human Review Status**: Reviewed and verified (headless: unified hover shows all five values at the timestamp; gridlines present in every row; autofollow follows live data; a real rangeslider drag emits `xaxis.range` and disables autofollow with no snap-back; `deno lint` clean, 28 tests pass)
+- **Git Hash**: 60df33b
+
+## [2026-07-10 17:54 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Make the dashboard usable on tablets/iPad (issue #27, FR-UI-07) — the remaining task after the author verified Chrome/Firefox/Safari. Added the missing viewport meta tag (and charset) so tablets render at device width instead of scaling a ~980px desktop layout. Added responsive breakpoints: on landscape tablets the desktop side-by-side layout is kept (plots stay the primary view) and the spreadsheet's text is shrunk so its 6–7 columns (including dB/dt) stay readable in the 500px data column without stealing plot width; on portrait tablets the plots stack over a two-column data band (Current Reading + Trends on the left, Spreadsheet beside them spanning both rows), sized with flex so the whole dashboard fits the viewport without scrolling, with Current Reading laid out in two columns (via `display: contents`) so the readings aren't cramped, the two panel headers aligned, and the config sidebar pinned as a fixed overlay. Verified touch interactions (open sidebar, pick source type, add tab) work.
+- **Sections/Files Affected**: `index.html` (viewport + charset meta), `css/index.css` (responsive section: `≤1100px` spreadsheet font; `≤900px` portrait flex-column stack, 2-column data band with Spreadsheet spanning, 2-column Current Reading, header-alignment fix, fixed sidebar overlay)
+- **Nature of Contribution**: Code generation (responsive layout)
+- **Human Review Status**: Reviewed and verified (headless Chromium at iPad landscape 1024×768 and portrait 768×1024 with touch: no JS/console errors; portrait fits with no scroll and Z/Mag sparklines visible; spreadsheet no longer overflows/cramps even with dB/dt; tap opens sidebar / selects source type / adds tab; `deno lint` clean and 28 tests pass; author verified across major browsers and the tablet layout iterations)
+- **Git Hash**: b215225
+
+## [2026-07-14 15:15 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8 (1M context)
+- **Session Purpose**: Fix four iPad/tablet responsive-layout bugs found in device testing (issue #27) and add per-subplot separator borders to the main plot. (1) The Plotly "x unified" hover readout lingered on tap until the next tap inside the plot; now dismissed on a tap anywhere outside the plot. (2) In landscape the modebar obstructed the legend; the modebar is now vertical with a right-margin gutter. (3) The connection-status text wrapped when many/long source tabs filled the header; it now stays on one line while the tab strip scrolls. (4) In portrait the fixed config sidebar covered the footer; the footer now paints above the drawer and the drawer content clears it. Also added theme-aware rectangle borders around each of the five stacked subplots to separate them and de-clutter the y-axis labels.
+- **Sections/Files Affected**: `js/index.js` (document-level `pointerdown` → `Plotly.Fx.unhover`; theme-aware plot border color threaded through `plotTheme()`/`themeLayout()`/`retintPlots()`), `js/data/plots.json` (main-plot layout: `modebar.orientation: "v"`, `margin.r` gutter, five per-subplot border `shapes`), `css/index.css` (`#status` nowrap/flex-shrink; `footer` position/z-index; portrait `#config` padding-bottom)
+- **Nature of Contribution**: Code generation (bug fixes and a plot styling feature)
+- **Human Review Status**: Reviewed and verified (tested on iPad against a Mac-served dev build; author confirmed the four fixes; `plots.json` valid, 28 tests pass)
+- **Git Hash**: ac5277e
+
+## [2026-07-16 13:02 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8 (1M context)
+- **Session Purpose**: Make the config sidebar constrict the main plots rather than cover them (keeping the y-axis visible when the menu is open), and refine the plot/data-column layout on landscape tablets — shrink the data column on open, scale Current Reading and spreadsheet text to fit with smooth transitions, collapse export buttons to icon-only, and scope the overrides out of the portrait layout.
+- **Sections/Files Affected**: `css/index.css` (`.layout` 3-column grid drawer where the config spacer constricts the plots; `main`/`.right-column` grid columns; `@media (min-width: 901px) and (max-width: 1300px)` block — data column 350px, Current Reading + spreadsheet text scaling, icon-only export buttons, trimmed cell padding; `font-size` transitions on `.cell-value` and `#spreadsheet`), `index.html` (export button labels wrapped in `.btn-label` spans with `title` tooltips), `js/index.js` (`setSidebarOpen()` helper toggling `.config-open`; Plotly re-fit on the layout `transitionend`)
+- **Nature of Contribution**: Code generation and edit (CSS/HTML/JS)
+- **Human Review Status**: Reviewed and verified (headless WebKit + Chromium layout checks at portrait/landscape widths — drawer constricts plots with y-axis visible, no spreadsheet overflow at 1194px, portrait unaffected; 28/28 tests pass)
+- **Git Hash**: e653760
+
+## [2026-07-17 16:59 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8 (1M context)
+- **Session Purpose**: Re-add the deploy/ tooling to the repo without the out-of-scope MQTT bridge: the gmag-webui dashboard systemd unit template, a git-based auto-update mechanism (timer + oneshot service + script that fetches origin and reset --hard + restarts the dashboard on new commits), and an install.sh that auto-resolves the service user/home/repo path and renders the templates. Supersedes the earlier full deploy commit 352e86b, which was reverted (deb1dfb) because it bundled the MQTT bridge -- a host-infra concern out of scope for this repo.
+- **Sections/Files Affected**: deploy/ -- install.sh; gmag-webui.service.in, gmag-webui-update.service.in (templates); gmag-webui-update.timer; gmag-webui-update.sh; README.md
+- **Nature of Contribution**: Code generation (deployment tooling / infrastructure-as-code)
+- **Human Review Status**: Reviewed and verified (no mqtt/bridge references; install.sh bash -n clean and render-tested for a sample user; auto-update tested live on host)
+- **Git Hash**: 946b121
+
+## [2026-07-17 17:14 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8 (1M context)
+- **Session Purpose**: Fix deploy/install.sh and deploy/gmag-webui-update.sh being committed without the executable bit (mode 0644), which made `sudo .../deploy/install.sh` fail with "command not found". Set both to mode 0755.
+- **Sections/Files Affected**: deploy/install.sh, deploy/gmag-webui-update.sh (file mode 0644 -> 0755)
+- **Nature of Contribution**: Bug fix (file permissions)
+- **Human Review Status**: Reviewed and verified (git index now shows 100755 for both)
+- **Git Hash**: e22a7cc
+
+## [2026-07-20 15:22 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8 (1M context)
+- **Session Purpose**: Make the spreadsheet a sortable table (issue #12): click any column header (Date, H, E, Z, Mag, Temp, dB/dt) to sort ascending/descending, with a direction caret on the active column. Sorting operates in display space (rotation + delta-B) and preserves the virtualized rendering and live-streaming performance.
+- **Sections/Files Affected**: `js/index.js` (sort state + `sortValueAt`/`buildSortOrder`/`currentSortOrder` with a cached, invalidated permutation; `windowRows` takes the order with a null fast path for the default newest-first view; `renderSpreadsheet` and the rebuild/reset/add wrappers invalidate the cache; `thead` click-to-sort handler + `syncSortHeader`; fall back to the default sort when the dB/dt column is hidden while sorted by it), `index.html` (`data-sort`/`scope` attributes on the spreadsheet header cells), `css/index.css` (clickable header styling, hover, and `aria-sort` ▲/▼ carets on the active column)
+- **Nature of Contribution**: Code generation (feature)
+- **Human Review Status**: Reviewed and verified (headless WebKit with 6000 rows: default newest-first, ascending/descending per column monotonic and correct, stable ties, caret/aria-sort track the active column, no console errors; `deno lint` clean, 28/28 tests pass)
+- **Git Hash**: c013402
diff --git a/compose.yaml b/compose.yaml
index 98e203f..aa71326 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -12,7 +12,7 @@ services:
restart: unless-stopped
volumes:
- .:/app
- command: ["deno", "task", "dev"]
+ command: ["deno", "task", "start"]
backend:
container_name: mag-usb
build: .
diff --git a/css/index.css b/css/index.css
index 6fdfb6b..62ef24c 100755
--- a/css/index.css
+++ b/css/index.css
@@ -1,15 +1,49 @@
-/* Variables */
+/* Variables
+ Theme-agnostic tokens (status/series/button colors) live here unconditionally.
+ Themeable surface/text tokens default to the DARK palette on :root (a safe
+ no-JS fallback) and are overridden under :root[data-theme="light"]. js/index.js
+ sets data-theme on from the saved preference or prefers-color-scheme, so
+ one of the two palettes is always in effect. */
:root {
+ /* Constant across themes */
--col-success: #00c900;
--col-danger: #ff3b14;
--col-warning: #ffe100;
--col-info: #00aeff;
- --col-btn-inactive: #00bfff;
- --col-btn-hover: #69dcff;
- --col-btn-disabled: rgba(0, 125, 166, 0.9);
+ --btn-primary-bg: #00bfff;
+ --btn-primary-hover: #69dcff;
+ --btn-primary-active: #a0e4ff;
+ --btn-primary-disabled: #007da6e6;
--col-tab-active: #424242;
- --hbar-grad: linear-gradient(#000000, #606060, #000000);
--right-side-radius: 8px;
+
+ /* Themeable — DARK palette (default / fallback) */
+ --bg: #1e1e1e;
+ --fg: #f0f0f0;
+ --muted: #cfcfcf;
+ --link: #69daff;
+ /* Header/footer chrome */
+ --hbar-grad: linear-gradient(#000000, #606060, #000000);
+ --chrome-fg: #ffffff;
+ --chrome-fg-dim: #cfcfcf;
+ --chrome-soft: rgba(255, 255, 255, 0.08);
+ --chrome-hover: rgba(255, 255, 255, 0.16);
+ /* Content-box + table surfaces */
+ --surface-header-bg: #333333;
+ --surface-header-fg: #ffffff;
+ --surface-hover: #444444;
+ --border: rgba(255, 255, 255, 0.12);
+ --row-hover: #2f4a63;
+ --row-stripe: rgba(255, 255, 255, 0.05);
+ /* Form inputs */
+ --input-bg: #3a3a3a;
+ --input-fg: #f0f0f0;
+ --input-border: rgba(255, 255, 255, 0.14);
+ /* Secondary buttons */
+ --btn-secondary-bg: #515151;
+ --btn-secondary-hover: #5e5e5e;
+ --btn-secondary-active: #757575;
+ --btn-secondary-fg: #ffffff;
/* Config panel (instrument-panel surface) */
--panel-bg: #2b2b2b;
--panel-header-bg: #383838;
@@ -18,6 +52,50 @@
--panel-border: rgba(255, 255, 255, 0.08);
}
+/* Themeable — LIGHT palette */
+:root[data-theme="light"] {
+ --bg: #f0f2f5;
+ --fg: #1a1a1a;
+ --muted: #5a5f66;
+ --link: #69beff;
+ /* Dark brushed silver (gunmetal): light steel highlights over a graphite
+ body with a specular seam at the midline. Kept dark so the light chrome
+ text and the colored connection-status indicator (green/red/yellow) stay
+ readable. Shared by the header, footer, and content-box bars. */
+ --hbar-grad: linear-gradient(180deg,
+ #8b939c 0%,
+ #656d75 16%,
+ #454b52 45%,
+ #383e45 50%,
+ #4e555d 53%,
+ #6b727a 82%,
+ #4a5058 100%);
+ --chrome-fg: #f4f6f8;
+ --chrome-fg-dim: #c3c9d0;
+ --chrome-soft: rgba(255, 255, 255, 0.08);
+ --chrome-hover: rgba(255, 255, 255, 0.16);
+ /* Table/column headers use a solid gunmetal that echoes the chrome
+ gradient's tone (a light-theme gradient can't be used behind them, and a
+ light fill would wash out the bright inline series-color labels — Temp
+ #ff0, dB/dt #9af8ff, E green). --surface-header-fg stays white. */
+ --surface-header-bg: #474e56;
+ --surface-hover: #d5dbe2;
+ --border: rgba(0, 0, 0, 0.12);
+ --row-hover: #d6ebff;
+ --row-stripe: rgba(0, 0, 0, 0.05);
+ --input-bg: #eef1f5;
+ --input-fg: #1a1a1a;
+ --input-border: #b7c0ca;
+ --btn-secondary-bg: #d5dbe2;
+ --btn-secondary-hover: #c4ccd5;
+ --btn-secondary-fg: #1a1a1a;
+ --panel-bg: #ffffff;
+ --panel-header-bg: #e4e9ef;
+ --panel-text: #1a1a1a;
+ --panel-muted: #5a5f66;
+ --panel-border: rgba(0, 0, 0, 0.10);
+}
+
* {
box-sizing: border-box;
margin: 0;
@@ -30,6 +108,8 @@ body {
height: 100vh;
overflow: hidden;
font-family: Arial, Helvetica, sans-serif;
+ background-color: var(--bg);
+ color: var(--fg);
}
header {
@@ -41,7 +121,7 @@ header > div {
flex-direction: row;
background: var(--hbar-grad);
padding: 0px 12px;
- color: #fff;
+ color: var(--chrome-fg);
align-items: center;
height: 45px;
}
@@ -52,11 +132,18 @@ main {
/* Allow the grid row to constrain the plots rather than the plots'
intrinsic height stretching the row (and the whole layout) taller. */
min-height: 0;
+ /* Second layout column: the first (leading) column is the config drawer's
+ reserved space, which is 0 until the drawer opens. */
+ grid-column: 2;
+ /* Clip the plot to its column so that, during the open/close width
+ transition (Plotly re-fits only once it settles), the old-width chart is
+ trimmed rather than spilling over the right column. */
+ overflow: hidden;
}
-/* main > div {
- height: 20%;
-} */
+#wordmark {
+ font-style: italic;
+}
#plots {
height: 100%;
@@ -71,22 +158,23 @@ main {
}
a {
- color: #69daff;
+ color: var(--link);
}
input[type="button"], button {
- background: var(--col-btn-inactive);
+ background: var(--btn-primary-bg);
color: #ffffff;
padding: 4px;
border: 0;
border-radius: 4px;
- transition: background .2s ease;
+ transition: background .2s ease,
+ color .2s ease;
}
input[type="button"]:hover, button:hover {
- background: var(--col-btn-hover);
+ background: var(--btn-primary-hover);
}
input[type="button"]:disabled, button:disabled {
- background: var(--col-btn-disabled);
+ background: var(--btn-primary-disabled);
}
input[type="button"] {
@@ -107,11 +195,18 @@ footer {
text-align: center;
background: var(--hbar-grad);
vertical-align: bottom;
- color: #fff;
+ color: var(--chrome-fg);
padding-top: 7px;
padding-bottom: 7px;
display: grid;
grid-template-columns: 1fr 60px;
+ /* Vertically center both columns (copyright text + theme toggle) in the row
+ so the toggle stays centered regardless of the row's height. */
+ align-items: center;
+ /* Paint above the config drawer so the portrait-mode fixed sidebar
+ (position: fixed; bottom: 0) can't cover the footer (issue #27). */
+ position: relative;
+ z-index: 2;
}
.layout {
@@ -120,23 +215,32 @@ footer {
grid row to it (minmax floor of 0), so tall column content scrolls or
clips inside its cell instead of stretching the page. */
min-height: 0;
- /* Positioning context for the config sidebar, which overlays this band
- (between the header and footer) rather than the whole viewport. */
+ /* Positioning context for the config drawer, which is absolutely positioned
+ over the (empty) leading grid column it reserves. */
position: relative;
display: grid;
- grid-template-columns: 1fr 500px;
+ /* Three columns: [config drawer] [plots] [right column]. The drawer column
+ is 0 until the drawer opens, at which point it grows and pushes the plots
+ right — constricting them rather than covering them, so the y-axis stays
+ visible. Animate the track so the plots slide in step with the drawer. */
+ grid-template-columns: 0 1fr 500px;
grid-template-rows: minmax(0, 1fr);
+ transition: grid-template-columns 0.3s ease;
+}
+.layout.config-open {
+ grid-template-columns: 320px 1fr 500px;
}
/*TODO: At some point we want to make this resizable.*/
.right-column {
display: grid;
- /* Current Reading sizes to its content (no trailing whitespace), the
- spreadsheet takes the remaining space and scrolls, and Trends keeps a
- proportional slice. minmax(0, …) lets the middle row shrink so its
- table scrolls instead of pushing Trends off-screen. */
- grid-template-rows: auto minmax(0, 1fr) 25%;
+ /* Current Reading sizes to its content, Trends keeps a proportional slice
+ below it, and the spreadsheet takes the remaining space and scrolls.
+ minmax(0, …) lets the spreadsheet row shrink so its table scrolls instead
+ of pushing content off-screen. */
+ grid-template-rows: auto 19% minmax(0, 1fr);
min-height: 0;
+ grid-column: 3; /* third layout column (after the drawer + plots) */
}
.content-box {
@@ -166,9 +270,19 @@ footer {
color: var(--panel-text);
transition: transform 0.3s ease;
}
+.layout.config-open #config {
+ transform: translateX(0);
+}
#sideToggle {
margin-right: 12px;
+ font-size: 14pt;
+}
+
+.flex-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 5px;
}
#wordmark {
@@ -177,6 +291,10 @@ footer {
flex-shrink: 0;
}
+#trends {
+ margin-bottom: 5px;
+}
+
#data, #trends {
padding: 0 5px 5px;
}
@@ -193,6 +311,8 @@ footer {
margin-top: 5px;
font-size: 10pt;
border-radius: var(--right-side-radius);
+ /* Scale smoothly with the drawer transition when the column narrows. */
+ transition: font-size 0.3s ease;
}
table {
@@ -215,6 +335,12 @@ table {
.fixed-head {
display: block;
flex: 0 0 auto;
+ /* Reserve the same scrollbar gutter the body reserves, so the header
+ columns stay aligned with the data whether or not the body scrolls.
+ overflow-y: hidden keeps the header itself unscrollable / gutter-only. */
+ overflow-y: hidden;
+ scrollbar-gutter: stable;
+ background-color: var(--surface-header-bg);
}
.scrolling {
@@ -222,6 +348,9 @@ table {
flex: 1;
min-height: 0;
overflow-y: auto;
+ /* Always reserve the scrollbar gutter (even before the body overflows) so
+ the columns don't shift when the scrollbar appears. */
+ scrollbar-gutter: stable;
}
.fixed-head tr,
@@ -238,28 +367,68 @@ table {
white-space: nowrap;
}
+/* dB/dt column (issue #29): shown only when "Show dB/dt" is enabled. */
+#spreadsheet:not(.show-dbdt) .col-dbdt {
+ display: none;
+}
+
thead th {
- background-color: #333;
- color: #fff;
+ background-color: var(--surface-header-bg);
+ color: var(--surface-header-fg);
padding: 6px;
}
+/* Click-to-sort headers. Only the active column shows a direction caret (driven
+ by aria-sort, set in js/index.js), so narrow columns aren't crowded. */
+thead th[data-sort] {
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+thead th[data-sort]:hover {
+ background-color: var(--surface-hover);
+}
+thead th[aria-sort="ascending"]::after {
+ content: " \25B2"; /* ▲ */
+ font-size: 0.7em;
+ vertical-align: middle;
+}
+thead th[aria-sort="descending"]::after {
+ content: " \25BC"; /* ▼ */
+ font-size: 0.7em;
+ vertical-align: middle;
+}
+
td {
text-align: center;
padding: 2px;
}
-tbody tr:hover {
- background: #bef;
+/* tbody tr:hover {
+ background: var(--row-hover);
+} */
+
+/* Alternate row color slightly. The spreadsheet body is virtualized (only the
+ visible window is in the DOM), so striping is keyed to a per-row .stripe class
+ set from the stable display index rather than :nth-of-type. */
+.scrolling tr.stripe {
+ background-color: var(--row-stripe);
}
-/* Alternate row color slightly */
-tr:nth-of-type(2n) {
- background-color: rgba(0, 0, 0, 0.05);
+/* Virtualization spacers reserve off-screen scroll height; never highlight. */
+.scrolling tr.spacer,
+.scrolling tr.spacer:hover {
+ background: none;
}
#status {
text-align: right;
+ /* Keep the connection status on a single line; when many/long source tabs
+ fill the header, the tab strip scrolls (#tabs overflow-x) instead of
+ squeezing this text into an ugly wrap (issue #27). */
+ white-space: nowrap;
+ flex-shrink: 0;
+ margin-left: 12px;
}
#status.success {
@@ -290,8 +459,8 @@ tr:nth-of-type(2n) {
display: flex;
flex-direction: row;
background-image: var(--hbar-grad);
- background-color: #333;
- color: #fff;
+ background-color: var(--surface-header-bg);
+ color: var(--chrome-fg);
padding: 6px;
border-radius: var(--right-side-radius);
}
@@ -317,7 +486,7 @@ tr:nth-of-type(2n) {
align-items: center;
gap: 8px;
background-color: var(--panel-header-bg);
- color: #fff;
+ color: var(--panel-text);
border: 0;
border-radius: 0;
padding: 11px 12px;
@@ -329,7 +498,7 @@ tr:nth-of-type(2n) {
transition: background 0.15s ease;
}
.panel-header:hover {
- background-color: #444;
+ background-color: var(--surface-hover);
}
.panel-header .title {
flex-grow: 1;
@@ -373,14 +542,15 @@ tr:nth-of-type(2n) {
.field input[type="text"],
.field input[type="password"],
.field input[type="url"],
+.field input[type="number"],
.field select {
width: 100%;
- border: 1px solid transparent;
+ border: 1px solid var(--input-border);
border-radius: 6px;
padding: 7px 9px;
font-size: 13px;
- background-color: #fff;
- color: #1a1a1a;
+ background-color: var(--input-bg);
+ color: var(--input-fg);
}
.field input.mono {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
@@ -388,7 +558,7 @@ tr:nth-of-type(2n) {
}
.field input:focus-visible,
.field select:focus-visible {
- outline: 2px solid var(--col-btn-inactive);
+ outline: 2px solid var(--btn-primary-bg);
outline-offset: 1px;
}
.field input[type="file"] {
@@ -404,10 +574,21 @@ tr:nth-of-type(2n) {
}
/* Fix spacing issues for MQTT broker panel */
-.type-fields[data-type="mqtt"] > .field:not(:first-of-type) {
+.type-fields[data-type="mqtt"] > .field:not(:first-of-type)/*,
+.method-fields[data-type="constant"] > .field:not(:first-of-type)*/ {
padding-top: 10px;
}
+.method-fields[data-type="constant"] {
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+}
+
+#delta {
+ rotate: -90deg;
+}
+
/* Subheads inside a panel body */
.subhead {
display: flex;
@@ -419,7 +600,7 @@ tr:nth-of-type(2n) {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
- color: #cfcfcf;
+ color: var(--panel-muted);
}
.note {
@@ -433,7 +614,8 @@ tr:nth-of-type(2n) {
/* Buttons */
.btn-primary,
-.btn-secondary {
+.btn-secondary,
+.btn-error {
display: flex;
flex-direction: row;
align-items: center;
@@ -446,19 +628,41 @@ tr:nth-of-type(2n) {
font-weight: 700;
cursor: pointer;
}
+.btn-primary.sm {
+ border-radius: 4px;
+ padding: 4px;
+ font-size: 9px;
+ font-weight: 500;
+}
.btn-primary {
- background-color: var(--col-btn-inactive);
+ background-color: var(--btn-primary-bg);
color: #fff;
}
.btn-primary:hover {
- background-color: var(--col-btn-hover);
+ background-color: var(--btn-primary-hover);
+}
+.btn-primary:active {
+ background-color: var(--btn-primary-active);
}
.btn-secondary {
- background-color: #515151;
- color: #fff;
+ background-color: var(--btn-secondary-bg);
+ color: var(--btn-secondary-fg);
}
.btn-secondary:hover {
- background-color: #5e5e5e;
+ background-color: var(--btn-secondary-hover);
+}
+.btn-secondary:active {
+ background-color: var(--btn-secondary-active);
+}
+.btn-error {
+ background-color: #e65353;
+ color: #fff;
+}
+.btn-error:hover {
+ background-color: #ee2d2d;
+}
+.btn-error:active {
+ background-color: #f00;
}
/* Segmented control (source type, moving-average toggle) */
@@ -467,12 +671,19 @@ tr:nth-of-type(2n) {
flex-direction: row;
}
.select-group > button {
+ background-color: var(--btn-secondary-bg);
flex: 1 1 0;
border: 0;
padding: 7px 4px;
font-size: 12px;
cursor: pointer;
}
+.select-group > button:not(:disabled):hover {
+ background-color: var(--btn-secondary-hover);
+}
+.select-group > button:not(:disabled):active {
+ background-color: var(--btn-secondary-active);
+}
.select-group > button:not(:first-of-type):not(:last-of-type) {
border-radius: 0;
}
@@ -485,10 +696,16 @@ tr:nth-of-type(2n) {
border-bottom-left-radius: 0;
}
.select-group > button:disabled {
- background-color: var(--col-success);
+ background-color: var(--btn-primary-bg);
color: #fff;
cursor: default;
}
+[data-theme="light"] .select-group > button:not(:disabled) {
+ color: #000;
+}
+[data-theme="light"] .select-group>button:not(:disabled):active {
+ color: #fff;
+}
/* Rotation sliders: label | track | value */
.slider-group {
@@ -502,11 +719,12 @@ tr:nth-of-type(2n) {
width: 100%;
}
.slider-group > span {
- text-align: right;
+ text-align: center;
font-variant-numeric: tabular-nums;
}
-.type-fields[hidden] {
+.type-fields[hidden],
+.method-fields[hidden] {
display: none;
}
@@ -538,18 +756,18 @@ tr:nth-of-type(2n) {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 6px;
- background-color: rgba(255, 255, 255, 0.08);
- color: #cfcfcf;
+ background-color: var(--chrome-soft);
+ color: var(--chrome-fg-dim);
font-size: 12px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.tab:hover {
- background-color: rgba(255, 255, 255, 0.16);
+ background-color: var(--chrome-hover);
}
.tab.active {
background-color: var(--panel-bg);
- color: #fff;
+ color: var(--panel-text);
}
.tab-name {
@@ -574,13 +792,13 @@ tr:nth-of-type(2n) {
.tab-close {
flex: 0 0 auto;
font-size: 11px;
- color: #aaa;
+ color: var(--chrome-fg-dim);
border-radius: 3px;
padding: 1px 3px;
}
.tab-close:hover {
- color: #fff;
- background-color: rgba(255, 255, 255, 0.2);
+ color: var(--chrome-fg);
+ background-color: var(--chrome-hover);
}
#addTab {
@@ -590,12 +808,12 @@ tr:nth-of-type(2n) {
height: 28px;
border: 0;
border-radius: 6px;
- background-color: rgba(255, 255, 255, 0.1);
- color: #fff;
+ background-color: var(--chrome-soft);
+ color: var(--chrome-fg);
cursor: pointer;
}
#addTab:hover {
- background-color: rgba(255, 255, 255, 0.2);
+ background-color: var(--chrome-hover);
}
#addTab:disabled {
opacity: 0.4;
@@ -612,12 +830,13 @@ tr:nth-of-type(2n) {
display: grid;
gap: 5px;
}
-.row1 {
- grid-template-columns: repeat(3, 1fr);
-}
.row2 {
grid-template-columns: repeat(2, 1fr);
}
+.row1, .row2.dB {
+ grid-template-columns: repeat(3, 1fr);
+}
+
.cell {
padding: 5px;
text-align: center;
@@ -631,7 +850,8 @@ tr:nth-of-type(2n) {
.row1 .cell:last-of-type { background-color: #023e8a; }
.row2 .cell:first-of-type { background-color: #da1884; }
-.row2 .cell:last-of-type { background-color: #ffb200; }
+.row2 .cell:nth-of-type(2) { background-color: #ffb200; }
+.row2 .cell:nth-of-type(3) { background-color: #11c6da; }
.cell-head {
text-align: left;
@@ -640,9 +860,12 @@ tr:nth-of-type(2n) {
}
.cell-value {
font-size: 18pt;
+ /* Scale smoothly in step with the drawer's grid-template-columns transition
+ when the data column narrows/widens (see the max-width: 1300px block). */
+ transition: font-size 0.3s ease;
}
-/* Src: https: //www.geeksforgeeks.org/css/how-to-create-toggle-switch-by-using-html-and-css/ */
+/* Src: https://www.geeksforgeeks.org/css/how-to-create-toggle-switch-by-using-html-and-css/ */
.toggle-switch {
position: relative;
display: inline-block;
@@ -650,6 +873,11 @@ tr:nth-of-type(2n) {
height: 15px;
}
+.toggle-switch.lg {
+ width: 60px;
+ height: 30px;
+}
+
.toggle-switch input {
opacity: 0;
width: 0;
@@ -680,6 +908,11 @@ tr:nth-of-type(2n) {
border-radius: 2px;
}
+.toggle-switch.lg.square > .slider::before {
+ height: 26px;
+ width: 26px;
+}
+
.square input:checked + .slider {
background-color: #4caf50;
}
@@ -688,11 +921,157 @@ tr:nth-of-type(2n) {
transform: translateX(14px);
}
+.toggle-switch.lg.square input:checked + .slider::before {
+ transform: translateX(30px);
+}
+
#ldWrapper {
display: flex;
+ align-items: center;
margin-right: 7px;
}
#ldWrapper i {
padding-right: 7px;
+}
+
+/* ----------------------------------------------------------------------------
+ Responsive: tablets / iPad (issue #27)
+ The dashboard is a fixed-viewport, side-by-side layout on desktop, and the
+ plots stay the star of the show. Rather than steal width from them, on tablet
+ widths we shrink the spreadsheet's text so its 6–7 columns stay readable in
+ the 500px data column. Portrait tablets can't fit both side by side, so we
+ stack the plots over the data band.
+ ---------------------------------------------------------------------------- */
+
+/* Landscape tablets and small windows (covers iPad landscape ~1024–1194):
+ opening the drawer here would crush the plots, since the base constrict keeps
+ the 500px data column (plot = width − 320 − 500). Borrow the space from the
+ data column instead so the plots stay usable with the y-axis visible; the
+ column restores to 500px when the drawer closes. Kept in px (not fr) so it
+ interpolates smoothly with the closed state during the transition. The
+ now-narrow column also needs the smaller spreadsheet text.
+ Floored at min-width: 901px so these config-open overrides don't leak into
+ the portrait layout (≤ 900px), which stacks the panels and has its own
+ sizing — otherwise the higher-specificity .config-open rules would shrink the
+ portrait cells/table when the (overlay) drawer opens. */
+@media (min-width: 901px) and (max-width: 1300px) {
+ .layout.config-open {
+ grid-template-columns: 320px 1fr 350px;
+ }
+ /* With the data column narrowed to 350px, scale its text down so the
+ Current Reading values and the spreadsheet columns still fit; restored to
+ full size when the drawer closes. */
+ .layout.config-open .cell-value {
+ font-size: 12pt;
+ }
+ /* Collapse the export buttons to icons only (labels kept as title tooltips)
+ so they stop crowding the "Spreadsheet" header. */
+ .layout.config-open #data .btn-label {
+ display: none;
+ }
+ .layout.config-open #data .btn-primary.sm {
+ padding: 4px 6px;
+ }
+ /* Shrink the spreadsheet type and trim cell padding so the timestamp and
+ the ~9-char nT values fit their columns without spilling. */
+ .layout.config-open #spreadsheet {
+ font-size: 7.5pt;
+ }
+ .layout.config-open #spreadsheet td {
+ padding: 1px 0;
+ }
+ .layout.config-open #spreadsheet thead th {
+ padding: 3px 0;
+ }
+}
+
+/* Tablet widths: smaller spreadsheet text so the value columns (up to 7 with
+ dB/dt) don't run together in the fixed-width 500px data column. */
+@media (max-width: 1100px) {
+ #spreadsheet {
+ font-size: 8.5pt;
+ }
+}
+
+/* Portrait tablets: stack the plots over the data band. Block flow (not grid)
+ lets main and the data band take their natural heights and the whole thing
+ scroll within .layout. Below the plots, the band is a two-column grid:
+ Current Reading and Trends stacked in the left column, with the Spreadsheet
+ beside them spanning both rows (it's the tall, scrollable one). */
+@media (max-width: 900px) {
+ .layout {
+ /* Flex column so main + the data band divide exactly the space between
+ the header and footer — the whole dashboard fits the portrait
+ viewport with no page scroll (vh heights would overflow, since they
+ don't subtract the header/footer). */
+ display: flex;
+ flex-direction: column;
+ }
+ main {
+ flex: 3 1 0;
+ min-height: 0;
+ }
+ .right-column {
+ /* Flex-sized (bounded by .layout), so the spreadsheet (which spans both
+ rows) can't inflate the shared row and push Trends down — it scrolls
+ internally instead. The spreadsheet gets the larger column share since
+ its columns (up to 7 with dB/dt) need the room. */
+ flex: 2 1 0;
+ min-height: 0;
+ grid-template-columns: 0.75fr 1.25fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+ #current-wrapper {
+ grid-column: 1;
+ grid-row: 1;
+ }
+ /* In the narrow left column, lay the readings out in two columns rather than
+ rows of three, so the numbers aren't cramped (and Current Reading takes a
+ fairer share of the column vs. Trends). `display: contents` flattens the
+ row wrappers so all cells flow into one 2-column grid while keeping the
+ per-cell color selectors intact. */
+ #current {
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: none;
+ }
+ .row1,
+ .row2 {
+ display: contents;
+ }
+ /* Smaller reading values let the left column stay narrow, freeing width for
+ the spreadsheet. */
+ .cell-value {
+ font-size: 15pt;
+ }
+ #trends {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ #data {
+ /* Spreadsheet sits beside Current Reading + Trends, spanning both rows;
+ its table keeps its own internal scroll. Match Current Reading's top
+ padding (content-box default is overridden to 0 for #data) so the two
+ panel headers line up. */
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ padding-top: 5px;
+ /* Make the grid gap between all elements equal */
+ padding-left: 0;
+ }
+ /* Pin the config sidebar to the viewport, not the now-scrollable band, so
+ it stays in view while the band scrolls underneath it. */
+ #config {
+ position: fixed;
+ top: 45px;
+ bottom: 0;
+ /* The footer paints above the drawer (z-index); pad the scroll area so
+ the last control can scroll clear of it instead of hiding behind. */
+ padding-bottom: 50px;
+ }
+ /* The wordmark takes up too much space in portrait mode. Give more space
+ to the tabber. */
+ #wordmark {
+ display: none;
+ }
}
\ No newline at end of file
diff --git a/deno.json b/deno.json
index f82535b..432d287 100644
--- a/deno.json
+++ b/deno.json
@@ -1,10 +1,14 @@
{
"tasks": {
- "dev": "deno run --allow-net --allow-read --allow-env --watch=ts/main.ts,web ts/main.ts"
+ "dev": "deno run --allow-net --allow-read --allow-env --watch=ts/main.ts,web ts/main.ts",
+ "start": "deno run --allow-net --allow-read --allow-env ts/main.ts",
+ "test": "deno test js/tests.js"
},
"imports": {
"@std/assert": "jsr:@std/assert@1",
+ "@std/dotenv": "jsr:@std/dotenv@^0.225.7",
"@types/plotly.js": "npm:@types/plotly.js@^3.0.10",
+ "mqtt": "npm:mqtt@^5.15.1",
"plotly.js": "npm:plotly.js@^3.3.1"
}
}
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..4e1d9fe
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,58 @@
+# Deployment artifacts
+
+Systemd units and helper scripts for running the gmag_webui dashboard on a host.
+The units are **templates** (`*.service.in`) with placeholders that `install.sh`
+fills in by auto-resolving the service user, their home, and the repo path — so
+nothing here is pinned to a specific username or host.
+
+> Credentials are **not** in this repo. The dashboard reads its config from an
+> `EnvironmentFile` created on the host (`/.env`, `chmod 600`). Create it
+> by hand; never commit it.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `install.sh` | Resolves user/home/repo, renders the `.in` templates, installs everything. |
+| `gmag-webui.service.in` | The dashboard (native Deno server on `:8000`). |
+| `gmag-webui-update.service.in` | Oneshot that runs the update script (passes `GMAG_REPO`). |
+| `gmag-webui-update.timer` | Fires the update check every 5 minutes. |
+| `gmag-webui-update.sh` | Fetches origin; on a new commit for the tracked branch, `git reset --hard` + restart the dashboard. |
+
+### Template placeholders
+
+`install.sh` substitutes these in the `.in` files:
+
+| Placeholder | Resolved to |
+|-------------|-------------|
+| `@USER@` | service user (see resolution order below) |
+| `@HOME@` | that user's home directory |
+| `@REPO@` | this checkout's location |
+
+## Install
+
+```bash
+sudo deploy/install.sh # auto-resolve the service user
+sudo deploy/install.sh --user wsprdaemon # or force a specific user
+sudo deploy/install.sh --enable # also enable + start the units
+```
+
+User resolution order: `--user` → `$SUDO_USER` (the human who ran sudo) → the
+owner of the repo checkout. The installer never starts services unless `--enable`
+is given (so you can create the `.env` file first).
+
+## Auto-update behavior
+
+- The timer runs `gmag-webui-update.sh` every ~5 minutes. It compares the clone's
+ `HEAD` against `origin/` and, if they differ, forces the clone
+ to the remote (`git reset --hard`) and restarts `gmag-webui`.
+- **Pushing to the tracked branch is a live deploy** — the host picks it up within
+ a timer interval and restarts the dashboard (a brief blip).
+- `git reset --hard` discards local edits in the clone (gitignored files like
+ `.env` are kept). Switch the script to `git merge --ff-only` if you prefer
+ updates to be skipped on divergence rather than overwritten.
+- The update runs as root but performs git operations as the repo owner (resolved
+ from the repo directory) via `runuser`.
+
+Inspect: `systemctl status gmag-webui-update.timer`,
+`journalctl -u gmag-webui-update.service`.
diff --git a/deploy/gmag-webui-update.service.in b/deploy/gmag-webui-update.service.in
new file mode 100644
index 0000000..3f41b26
--- /dev/null
+++ b/deploy/gmag-webui-update.service.in
@@ -0,0 +1,11 @@
+[Unit]
+Description=Check origin and auto-update gmag_webui, restart dashboard on change
+Documentation=https://github.com/HamSCI/gmag_webui
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+Environment=GMAG_REPO=@REPO@
+Environment=GMAG_SERVICE=gmag-webui.service
+ExecStart=/usr/local/bin/gmag-webui-update.sh
diff --git a/deploy/gmag-webui-update.sh b/deploy/gmag-webui-update.sh
new file mode 100755
index 0000000..4215844
--- /dev/null
+++ b/deploy/gmag-webui-update.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+#
+# Auto-update the gmag_webui deploy clone from origin and restart the dashboard
+# when the tracked branch has advanced (or diverged). Safe no-op when in sync.
+#
+# Runs as root (to restart the service) but performs every git operation as the
+# repo owner via runuser, so file ownership stays correct and git does not
+# refuse with "detected dubious ownership". The owner is resolved from the repo
+# directory itself, so nothing here is pinned to a specific username.
+#
+# NOTE: this uses `git reset --hard`, so the clone is forced to match the remote
+# tracked branch on every run -- local edits in the clone are discarded (gitignored
+# files such as .env are preserved). Pushing to the tracked branch therefore acts
+# as a live deploy. Change `reset --hard` to `merge --ff-only` below if you prefer
+# updates to be skipped (rather than overwritten) when the clone diverges.
+#
+# Installed to /usr/local/bin/gmag-webui-update.sh, driven by
+# gmag-webui-update.timer. GMAG_REPO is provided by gmag-webui-update.service.
+set -euo pipefail
+
+REPO="${GMAG_REPO:?GMAG_REPO must be set to the gmag_webui clone path}"
+SERVICE="${GMAG_SERVICE:-gmag-webui.service}"
+OWNER="${GMAG_OWNER:-$(stat -c %U "$REPO")}"
+OWNER_HOME="$(getent passwd "$OWNER" | cut -d: -f6)"
+
+git_as_owner() {
+ runuser -u "$OWNER" -- env HOME="$OWNER_HOME" git -C "$REPO" "$@"
+}
+
+branch="$(git_as_owner rev-parse --abbrev-ref HEAD)"
+git_as_owner fetch --quiet origin "$branch"
+
+local_rev="$(git_as_owner rev-parse HEAD)"
+remote_rev="$(git_as_owner rev-parse "origin/${branch}")"
+
+if [ "$local_rev" = "$remote_rev" ]; then
+ echo "gmag_webui up to date on ${branch} (${local_rev:0:7})"
+ exit 0
+fi
+
+echo "gmag_webui ${branch}: ${local_rev:0:7} -> ${remote_rev:0:7}; updating"
+git_as_owner reset --hard "origin/${branch}"
+systemctl restart "$SERVICE"
+echo "gmag_webui updated to ${remote_rev:0:7}; restarted ${SERVICE}"
diff --git a/deploy/gmag-webui-update.timer b/deploy/gmag-webui-update.timer
new file mode 100644
index 0000000..7beb604
--- /dev/null
+++ b/deploy/gmag-webui-update.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Periodically check origin for gmag_webui updates
+
+[Timer]
+# First check shortly after boot, then every 5 minutes after each run.
+OnBootSec=2min
+OnUnitActiveSec=5min
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/deploy/gmag-webui.service.in b/deploy/gmag-webui.service.in
new file mode 100644
index 0000000..cf20dd0
--- /dev/null
+++ b/deploy/gmag-webui.service.in
@@ -0,0 +1,19 @@
+[Unit]
+Description=HamSCI Magnetometer Web Dashboard
+Documentation=https://github.com/HamSCI/gmag_webui
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=@USER@
+WorkingDirectory=@REPO@
+EnvironmentFile=@REPO@/.env
+Environment=HOME=@HOME@
+ExecStart=/usr/local/bin/deno run --allow-net --allow-read --allow-env ts/main.ts
+Restart=always
+RestartSec=3
+NoNewPrivileges=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/install.sh b/deploy/install.sh
new file mode 100755
index 0000000..f05d6e1
--- /dev/null
+++ b/deploy/install.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+#
+# Install/refresh the gmag_webui systemd units, auto-resolving the service user,
+# their home directory, and the repo location. Nothing is pinned to a specific
+# username -- run it on any host and it adapts.
+#
+# Usage (run with sudo, from anywhere):
+# sudo deploy/install.sh # resolve user automatically
+# sudo deploy/install.sh --user wsprdaemon # force a specific service user
+# sudo deploy/install.sh --enable # also enable+start the units
+#
+# User resolution order: --user > $SUDO_USER (the human who ran sudo) >
+# the owner of the repo checkout.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$(realpath "$0")")" && pwd)" # .../deploy
+REPO="$(dirname "$SCRIPT_DIR")" # repo root
+
+USER_ARG="" ; DO_ENABLE=0
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --user) USER_ARG="${2:?}"; shift 2 ;;
+ --enable) DO_ENABLE=1; shift ;;
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
+ esac
+done
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "error: run with sudo (needs to write /etc/systemd/system)" >&2
+ exit 1
+fi
+
+# --- resolve the service user ---
+if [ -n "$USER_ARG" ]; then
+ SVC_USER="$USER_ARG"
+elif [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
+ SVC_USER="$SUDO_USER"
+else
+ SVC_USER="$(stat -c %U "$REPO")"
+fi
+
+if ! getent passwd "$SVC_USER" >/dev/null; then
+ echo "error: user '$SVC_USER' does not exist" >&2
+ exit 1
+fi
+SVC_HOME="$(getent passwd "$SVC_USER" | cut -d: -f6)"
+
+cat < stdout
+ sed -e "s|@USER@|$SVC_USER|g" \
+ -e "s|@HOME@|$SVC_HOME|g" \
+ -e "s|@REPO@|$REPO|g" \
+ "$1"
+}
+
+# --- render + install unit files ---
+for unit in gmag-webui.service gmag-webui-update.service; do
+ render "$SCRIPT_DIR/$unit.in" > "/etc/systemd/system/$unit"
+ chmod 0644 "/etc/systemd/system/$unit"
+ echo "installed /etc/systemd/system/$unit"
+done
+install -m 0644 "$SCRIPT_DIR/gmag-webui-update.timer" /etc/systemd/system/gmag-webui-update.timer
+install -m 0755 "$SCRIPT_DIR/gmag-webui-update.sh" /usr/local/bin/gmag-webui-update.sh
+echo "installed /usr/local/bin/gmag-webui-update.sh"
+
+systemctl daemon-reload
+echo "daemon-reloaded."
+
+if [ "$DO_ENABLE" -eq 1 ]; then
+ systemctl enable --now gmag-webui.service gmag-webui-update.timer
+ echo "enabled + started gmag-webui and gmag-webui-update.timer"
+else
+ cat < HOST, PORT
+ 2. Enable + start:
+ sudo systemctl enable --now gmag-webui.service gmag-webui-update.timer
+EOF
+fi
diff --git a/docs/Configuration.md b/docs/Configuration.md
new file mode 100644
index 0000000..7965025
--- /dev/null
+++ b/docs/Configuration.md
@@ -0,0 +1,57 @@
+# Magnetometer Dashboard Configuration Settings
+
+This page is intended to document all configuration options on the magnetometer
+dashboard. Settings are accessible from the hamburger menu icon in the top-left
+corner of the page.
+
+Settings are persisted through local storage.
+
+## Connection
+
+* **Source Name**: The title displayed in the source's tab. Will automatically
+ update the tab's contents.
+* **Source Type**: Can be either "WebSocket", "MQTT", or "File". Depending on
+ the type, different settings are exposed.
+ * WebSocket
+ * **WebSocket URL**: The host's URL in the form ws://IP:PORT or
+ wss://IP:PORT.
+ * MQTT
+ * **Broker URL**: The MQTT broker URL, similar in form to a WebSocket URL.
+ * **Topic**: The MQTT feed to subscribe to.
+ * **Username** and **Password**: Optional credentials for secure brokers.
+ * File
+ * **Log File**: The file to upload and display on the dashboard.
+
+A file source's contents will not persist between refreshes to save memory. It
+must be reuploaded to the dashboard each refresh.
+
+## Processing
+
+* **Rotation**: A set of 3 sliders that can rotate the output HEZ vector in
+ 90-degree increments in each of the X, Y, and Z axes. By default, these are
+ all set to 0.
+
+* Delta-B
+ * **Calculation Method**: Can be either "Constant dB" or "Moving Average".
+ Different settings are exposed depending on the selection.
+ * **Constant dB** exposes 3 fields for each of the H, E, and Z components.
+ These fields can then be filled in with manual values.
+ * **Moving Average** will take a snapshot of the current moving average
+ filter window and use the snapshot as the baseline vector.
+
+The order of operations these are applied in is rotations first using right
+hand rule, then subtract delta-B from the result.
+
+Processing settings are saved per source.
+
+## Display
+
+* **Time Window**: The time range to display on the main plots. Options are
+ 1 minute, 5 minutes, 15 minutes, and 1 hour. Default is 1 hour.
+* **Show dB/dt**: Shows an extra field in the Current Reading and Spreadsheet
+ indicating the rate of change. By default, this is disabled.
+* **Moving Average**: Displays a denoised average line over the raw data.
+ The time window can be set as 10 seconds, 30 seconds, or 60 seconds. A high
+ time window increases the intensity of the denoising.
+
+Display settings are saved globally per browser.
\ No newline at end of file
diff --git a/docs/Deploy_setup.md b/docs/Deploy_setup.md
new file mode 100644
index 0000000..2d46f24
--- /dev/null
+++ b/docs/Deploy_setup.md
@@ -0,0 +1,44 @@
+# HamSCI Magnetometer Deployment Guide
+
+This guide demonstrates how to set up the gmag_webui project on a deployable
+magnetometer unit. This does not cover mag-usb installation.
+
+## Architecture
+
+The magnetometer suite is deployed on a BeeLink computer running Ubuntu Noble.
+Mag-usb must be installed on the BeeLinks for the magnetometer portion.
+Optionally, WSPRDaemon can be installed if your setup includes an RX-888.
+(Mag-usb and gmag_webui do not conflict with WSPRDaemon)
+
+The magnetometer is wired to the BeeLink computer via USB-C and RJ45.
+
+## Guide
+
+1. Ensure the following packages are installed:
+ ```bash
+ sudo apt-get install -y curl git unzip
+ ```
+2. Install the Deno runtime via curl. When it prompts you to add deno to $PATH,
+ type Y and press enter twice. Restart your computer after Deno installs.
+ ```bash
+ curl -fsSL https://deno.land/install.sh | sh
+ sudo reboot
+ ```
+3. Clone this repository to your computer:
+ ```bash
+ git clone https://github.com/HamSCI/gmag_webui
+ ```
+4. cd into the local clone's vendor folder and unzip the archive containing
+ Font Awesome:
+ ```bash
+ cd gmag_webui/vendor
+ unzip fontawesome-free-7.1.0-web.zip
+ ```
+5. cd back to the project's root. Run the deploy installation script to set up
+ start on boot and automatic updates:
+ ```bash
+ cd ..
+ sudo ./deploy/install.sh
+ ```
+ The dashboard should be accessible on localhost:8000. You can change the
+ port by setting it in a .env file in the project's root directory.
\ No newline at end of file
diff --git a/docs/Project_Setup.md b/docs/Project_Setup.md
index a59f5fa..8820862 100644
--- a/docs/Project_Setup.md
+++ b/docs/Project_Setup.md
@@ -22,7 +22,7 @@ will be assuming the hardware used is a traditional setup, meaning:
For the host (and hardware):
* The host's operating system can be any Linux distribution or macOS.
- * Windows is unsupported. Raspberry Pis are unsupported.
+ * Windows is unsupported.
* For this guide, we will assume the host is running Linux.
* A separate, but comprehensive guide for running on macOS can be found
[here](/docs/macOS_Setup.md).
@@ -32,7 +32,7 @@ For the host (and hardware):
* Pololu Isolated USB-to-I²C Adapter (products 5396 or 5397)
* The recommended adapter is the Pi Eliminator, which can be purchased from
[TAPR](https://tapr.org/product/pi-eliminator/).
-* I²C cable
+* RJ45 cable
* A small length cable is fine for testing connections, but generally, a
longer cable is more suitable in practical application (~100 meters)
* Male-to-Male USB-C cable
@@ -41,6 +41,7 @@ For the client:
* The client can run any operating system.
* Docker Desktop is recommended for running the dashboard.
+ * Deno can also be installed directly.
## Host Setup
@@ -98,10 +99,11 @@ Connect the Pololu adapter along with the RM3100 sensor board to the host. The
green and blue LEDs should light up and hold steady. If the LEDs are blinking,
it is likely that your computer cannot find the adapter.
-Run mag-usb:
+Naviagate back to the home directory and run mag-usb:
```bash
-./mag-usb/build/mag-usb
+cd ~
+./mag-usb/build/mag-usb &
```
## Client Setup
@@ -112,6 +114,18 @@ Clone this repository with `git`:
git clone https://github.com/HamSCI/gmag_webui
```
+Unzip the archive containing Font Awesome. Some parts of the dashboard will not
+run correctly without it.
+
+If your client happens to be Linux or the same machine as the host, you will
+need the "unzip" package:
+
+```bash
+sudo apt-get install -y unzip
+cd gmag_webui/vendor
+unzip fontawesome-free-7.1.0-web.zip
+```
+
Navigate to the project's root directory. Run the project by creating a Docker
container:
diff --git a/docs/macOS_Setup.md b/docs/macOS_Setup.md
index e89ce35..386e1c2 100644
--- a/docs/macOS_Setup.md
+++ b/docs/macOS_Setup.md
@@ -17,7 +17,7 @@ magnetometer and serve the web dashboard on a single computer.
`brew install socat`)
* Pololu Isolated USB-to-I²C Adapter (products 5396 or 5397)
* RM3100-based magnetometer board
-* I²C cable
+* RJ45 cable
* Male-to-Male USB-C cable
## Hardware Setup
@@ -40,7 +40,7 @@ magnetometer and serve the web dashboard on a single computer.
```bash
socat -d -d TCP-LISTEN:1234,reuseaddr,fork FILE:,raw,echo=0 &
```
-2. Open Docker Desktop to ensure the Docker daemon is running.
+2. Open Docker Desktop to ensure the Docker engine is running.
3. In a separate Terminal, navigate to this project's root directory and run
`docker compose up -d` to start a shared container. The dashboard is
accessible on `localhost:8000`.
@@ -52,7 +52,7 @@ magnetometer and serve the web dashboard on a single computer.
```
6. Run this command to start mag-usb as a background process:
```bash
- ./mag-usb/build/mag-usb &
+ ./mag-usb &
```
7. If mag-usb is running properly, you can `exit` the shell. mag-usb will
continue to run in the background until the container is stopped.
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..0e1a127
Binary files /dev/null and b/favicon.ico differ
diff --git a/images/android-chrome-192x192.png b/images/android-chrome-192x192.png
new file mode 100644
index 0000000..5674fa9
Binary files /dev/null and b/images/android-chrome-192x192.png differ
diff --git a/images/android-chrome-512x512.png b/images/android-chrome-512x512.png
new file mode 100644
index 0000000..6b15845
Binary files /dev/null and b/images/android-chrome-512x512.png differ
diff --git a/images/apple-touch-icon.png b/images/apple-touch-icon.png
new file mode 100644
index 0000000..969db51
Binary files /dev/null and b/images/apple-touch-icon.png differ
diff --git a/images/favicon-16x16.png b/images/favicon-16x16.png
new file mode 100644
index 0000000..93a0a4a
Binary files /dev/null and b/images/favicon-16x16.png differ
diff --git a/images/favicon-32x32.png b/images/favicon-32x32.png
new file mode 100644
index 0000000..4783e26
Binary files /dev/null and b/images/favicon-32x32.png differ
diff --git a/index.html b/index.html
index c96874d..2c9793a 100755
--- a/index.html
+++ b/index.html
@@ -1,13 +1,34 @@
+
+
Magnetometer Dash
+
+
+
+
+
+
@@ -15,8 +36,8 @@