Files
muxplex/docs/plans/2026-04-08-server-side-settings-design.md
Brian Krabach 3bb3944b52 docs: add design for consolidating settings server-side
Move all display/UX settings from browser localStorage to server-side
settings.json. Flat keys approach (Approach A) - adds 10 new keys to
DEFAULT_SETTINGS, requiring zero changes to existing load/save/patch
functions or API endpoints.

Key decisions:
- No federation settings sync (each server owns its own settings)
- Drop notificationPermission (browser API is source of truth)
- sidebarOpen defaults to None for auto-detect on first load
- No migration needed - users reset preferences
- Fix auto_open vs auto_open_created naming alignment

Verified compatible with muxplex v0.2.0 (1b5207b).
2026-04-08 09:24:12 -07:00

176 lines
9.8 KiB
Markdown

# Consolidate Settings Server-Side Design
## Goal
Move all user-configurable settings from browser localStorage to server-side `~/.config/muxplex/settings.json`, eliminating the split storage model. After this change, the only remaining localStorage usage is `tmux-web-device-id` (per-browser device identity for the heartbeat system, not a setting).
## Background
Muxplex currently splits settings across two storage layers:
- **Server-side** (`~/.config/muxplex/settings.json`): Infrastructure and session behavior settings (host, port, auth, sort_order, federation config, etc.) -- 17 keys total.
- **Client-side** (browser localStorage): Display/UX preferences stored across 3 keys:
- `muxplex.display` -- fontSize, gridColumns, bellSound, hoverPreviewDelay, viewMode, showDeviceBadges, showHoverPreview, activityIndicator, gridViewMode, notificationPermission
- `muxplex.sidebarOpen` -- sidebar toggle state
- `tmux-web-device-id` -- per-browser device identity (not a setting)
This split complicates the mental model and means display preferences don't roam with the server -- if you reinstall your browser or clear localStorage, your preferences are lost even though the server is untouched.
## Approach
**Flat keys (Approach A)** -- add 10 new keys directly as flat top-level entries in `DEFAULT_SETTINGS` in `settings.py`.
This requires zero changes to `load_settings()`, `save_settings()`, or `patch_settings()` -- they all iterate `DEFAULT_SETTINGS` keys, so new keys work automatically through the existing `GET/PATCH /api/settings` API.
### Alternatives Considered
- **Nested `display` sub-object**: Would require deep-merge logic in `patch_settings()` and frontend changes to `patchServerSetting()`. More code for the same result. Rejected.
- **Separate `display.json` file**: New file, new API endpoints, new load/save functions. Over-engineered for a single-user tmux UI. Rejected.
## Architecture
### Multi-Device / Federation Settings Model
Each muxplex server owns its own `settings.json`. Federation is for session aggregation, not config replication. No settings sync across servers.
When browsing through Server A (the aggregator), all sessions -- local and federated -- render using Server A's display settings. Server B's settings are irrelevant; it only provides raw session data and WebSocket proxying. Server B's settings only matter when someone browses directly to Server B, which is a separate context where different settings may be desirable (e.g., different font size for different monitors).
Syncing would introduce disproportionate complexity (conflict resolution, partial connectivity, auth scope expansion, bootstrap problem) for minimal UX benefit in a single-user tool.
### Data Flow
```
Browser ──GET /api/settings──> Server A (settings.json)
│ │
│ _serverSettings cache │ fontSize: 16, gridColumns: 3, ...
│ (in-memory, immediate) │ + all infra/session settings
│ │
│──PATCH /api/settings──> │ patch_settings() merges into file
│ (fire-and-forget) │
│ │
│ ├── Local tmux sessions
│ ├── Server B sessions (federation proxy)
│ └── Server C sessions (federation proxy)
│ All sessions render using Server A's display settings.
│ Server B/C settings are irrelevant from this browser.
```
## Components
### Server-Side Changes (`muxplex/settings.py`)
Add 10 new keys to `DEFAULT_SETTINGS`:
```python
# Display preferences (consumed by frontend)
"fontSize": 14,
"hoverPreviewDelay": 1500,
"gridColumns": "auto",
"bellSound": False,
"viewMode": "auto",
"showDeviceBadges": True,
"showHoverPreview": True,
"activityIndicator": "both",
"gridViewMode": "flat",
# UI state
"sidebarOpen": None, # None = auto-detect from screen width
```
`sidebarOpen` defaults to `None` (not `True`/`False`) so the frontend can distinguish "never set by user" from "explicitly set" and fall back to screen-width detection (open at >= 960px, closed below) on first load.
**No changes needed to:**
- `load_settings()`, `save_settings()`, `patch_settings()` -- they iterate `DEFAULT_SETTINGS` keys automatically.
- `muxplex/main.py` -- `GET/PATCH /api/settings` endpoints already handle any keys in `DEFAULT_SETTINGS`. None of the new keys need redaction (no sensitive data).
**Intentionally excluded:** `notificationPermission` is NOT added. The browser `Notification.permission` API is the source of truth; the cached localStorage value was never meaningfully consumed.
### Frontend Changes (`muxplex/frontend/app.js`)
#### Startup Sequence
Move `await loadServerSettings()` to the top of `DOMContentLoaded`, before first render. Currently it's called lazily when opening the settings panel. Display settings like `fontSize` and `gridColumns` affect rendering, so they must be available before anything renders (~50ms on localhost; page is blank until `restoreState` anyway).
#### Replace the Storage Layer
- **Delete** `DISPLAY_SETTINGS_KEY` constant (`'muxplex.display'`) and `SIDEBAR_KEY` constant (`'muxplex.sidebarOpen'`).
- **Delete** `loadDisplaySettings()` function (reads from localStorage).
- **Delete** `saveDisplaySettings()` function (writes to localStorage).
- **Add** `getDisplaySettings()` function that reads from `_serverSettings` cache, falls back to `DISPLAY_DEFAULTS` for anything missing or before the initial fetch.
- **Remove** `notificationPermission` from `DISPLAY_DEFAULTS` (keep the runtime `_notificationPermission` variable for the browser API state).
#### Update All Callsites
- All ~16 `loadDisplaySettings()` calls become `getDisplaySettings()` (pure rename).
- All ~6 `saveDisplaySettings(ds)` calls become `patchServerSetting(key, value)` calls with an in-memory `_serverSettings` cache update so the UI stays responsive without waiting for the server round-trip.
#### Sidebar Functions
Rewrite `initSidebar()`, `toggleSidebar()`, and `bindSidebarClickAway()`:
- Read `_serverSettings.sidebarOpen` instead of localStorage.
- Write via `patchServerSetting('sidebarOpen', isOpen)` (fire-and-forget, non-blocking).
- Keep the `None` -> screen-width auto-detect logic for first-time users.
#### Bonus Fix: `auto_open` -> `auto_open_created`
The `openSettings()` function reads the server setting as `ss.auto_open` but the canonical server-side key is `auto_open_created`. Fix this alignment as part of this work since we're already touching that code path.
#### Only Remaining localStorage Usage
`tmux-web-device-id` in `initDeviceId()` (3 occurrences) -- untouched. This is per-browser device identity for the heartbeat/bell system, not a user setting.
## Error Handling
### Race on Startup
`loadServerSettings()` is `await`ed before first render, so display settings are always available. If the fetch fails (server down, network issue), `getDisplaySettings()` falls back to `DISPLAY_DEFAULTS` -- the app still loads with sensible defaults, just not personalized.
### Fire-and-Forget Writes
Setting changes call `patchServerSetting()` which updates the in-memory `_serverSettings` cache immediately (so the UI is responsive), then fires the `PATCH /api/settings` request. If the PATCH fails, a "Failed to save setting" toast appears. The in-memory cache stays updated for the current session, but the change won't persist across page refreshes. This matches the current behavior for existing server settings.
### `sidebarOpen: None` Auto-Detect
On first load (or after a settings reset), `sidebarOpen` is `None`. The frontend detects this and uses screen-width heuristic (open at >= 960px, closed below). Once the user explicitly toggles the sidebar, it writes `true`/`false` and the auto-detect is bypassed from then on.
### No Migration
No migration of existing localStorage values. Users get defaults and set preferences again. Old localStorage keys (`muxplex.display`, `muxplex.sidebarOpen`) become orphans -- harmless, ignored by the new code.
## Testing Strategy
### Server-Side Tests (`muxplex/tests/test_settings.py`)
- One test verifying the 10 new keys exist in `DEFAULT_SETTINGS` with correct default values.
- One test verifying display keys round-trip through `patch_settings()` (write then read back).
- Existing test infrastructure for `load_settings`/`save_settings` already covers the mechanics.
### Frontend Tests (`muxplex/frontend/tests/test_app.mjs`)
- Update any existing tests that reference `loadDisplaySettings` or `saveDisplaySettings` to use `getDisplaySettings`.
- Mock `_serverSettings` instead of `localStorage` for display settings tests.
### Verification Checklist (manual, post-implementation)
1. `grep -r 'localStorage' app.js` returns only `tmux-web-device-id` references.
2. `grep -r 'DISPLAY_SETTINGS_KEY\|SIDEBAR_KEY\|muxplex\.display\|muxplex\.sidebarOpen' app.js` returns zero matches.
3. `grep -r 'loadDisplaySettings\|saveDisplaySettings' app.js` returns zero matches.
4. `pytest muxplex/tests/test_settings.py` passes.
5. Manual: change font size -> refresh -> persists.
6. Manual: toggle sidebar -> refresh -> persists.
7. `cat ~/.config/muxplex/settings.json` shows the new display keys.
## Open Questions
None -- all design decisions have been validated.
## Future Work
- **Federation settings sync**: Investigate syncing user preferences (fontSize, sort_order, etc.) across federated servers. Deferred -- each server owns its own settings for now. Standard sysadmin solutions (copy the file, ansible, dotfiles repo) work fine in the meantime.
## Compatibility
Verified against muxplex v0.2.0 (commit `1b5207b`). The v0.2.0 release added a `DELETE /api/federation/{remote_id}/sessions/{session_name}` endpoint and updated `killSession()` -- neither affects settings storage. `DEFAULT_SETTINGS` in `settings.py` is stable at 17 keys; none of the new display keys conflict with existing keys.