Ship Track G UX, multi-shell terminals, Swarm default-on, install.peardock.boats
Release rolling / release (push) Successful in 8m22s

Probe bash/sh/ash and related shells until a container terminal works, enable Swarm APIs by default, add keyboard go-chords and appearance prefs, and point install docs at https://install.peardock.boats.
This commit is contained in:
Raven Scott
2026-07-11 18:06:46 -04:00
parent 5d1815070d
commit 42a69c6dc7
20 changed files with 1305 additions and 110 deletions
+3 -3
View File
@@ -24,17 +24,17 @@ No central control plane. The server announces a keypair on the DHT; clients con
Interactive installer — detects OS/arch, asks **server** vs **client**, installs a Linux **systemd** service for the server or a desktop client for your platform: Interactive installer — detects OS/arch, asks **server** vs **client**, installs a Linux **systemd** service for the server or a desktop client for your platform:
```bash ```bash
curl -fsSL https://git.ssh.surf/snxraven/peardock/raw/branch/main/scripts/install.sh | bash curl -fsSL https://install.peardock.boats | bash
``` ```
Non-interactive examples: Non-interactive examples:
```bash ```bash
# Server (Linux systemd → peardock.service) # Server (Linux systemd → peardock.service)
curl -fsSL https://git.ssh.surf/snxraven/peardock/raw/branch/main/scripts/install.sh | bash -s -- --server --yes curl -fsSL https://install.peardock.boats | bash -s -- --server --yes
# Desktop client only # Desktop client only
curl -fsSL https://git.ssh.surf/snxraven/peardock/raw/branch/main/scripts/install.sh | bash -s -- --client --yes curl -fsSL https://install.peardock.boats | bash -s -- --client --yes
``` ```
Downloads artifacts from the **`rolling`** release (`peardock-server-*` / `peardock-client-*`). Downloads artifacts from the **`rolling`** release (`peardock-server-*` / `peardock-client-*`).
+61 -21
View File
@@ -12,6 +12,7 @@
| **D — Holesail tunnels** | ✅ Complete | | **D — Holesail tunnels** | ✅ Complete |
| **E — Docker ops / Portainer-parity (core)** | ✅ Complete | | **E — Docker ops / Portainer-parity (core)** | ✅ Complete |
| **F — Optional Docker ops enhancements** | ✅ Complete | | **F — Optional Docker ops enhancements** | ✅ Complete |
| **G — UI/UX design system & power-user chrome** | 🔄 In progress |
```bash ```bash
npm test npm test
@@ -34,15 +35,16 @@ npm run dev
| GitOps | `server/utils/gitops.js` | | GitOps | `server/utils/gitops.js` |
| Recreate / system prune | `recreateContainer`, `systemPrune` | | Recreate / system prune | `recreateContainer`, `systemPrune` |
| Track F extras UI | `ui/track-f-extras.js` | | Track F extras UI | `ui/track-f-extras.js` |
| Track G UX chrome | `ui/track-g-ux.js`, `ui/modern.css`, Settings → Appearance |
| Volume browser | `browseVolume` RPC | | Volume browser | `browseVolume` RPC |
| Schedules | `server/services/schedules.js` | | Schedules | `server/services/schedules.js` |
| Fleet environments | peer env tags + filter | | Fleet environments | peer env tags + filter |
--- ---
# Tracks AE — ✅ Complete # Tracks AF — ✅ Complete
See git history. Core Engine coverage, product UI, Holesail, recreate, system prune, Hub search, secrets/configs lists, settings subtabs. See git history. Core Engine coverage, product UI, Holesail, recreate, system prune, Hub search, secrets/configs lists, settings subtabs, volume browse, schedules, fleet envs.
--- ---
@@ -50,47 +52,85 @@ See git history. Core Engine coverage, product UI, Holesail, recreate, system pr
| Item | Delivery | | Item | Delivery |
|------|----------| |------|----------|
| **Volume file browser** | RPC `browseVolume` (mountpoint listing) + Volumes 📂 action + modal | | **Volume file browser** | RPC `browseVolume` + Volumes 📂 action + modal |
| **Stack env file** + re-deploy | Env file textarea on deploy stack → `envFileContent` | | **Stack env file** + re-deploy | Env file textarea on deploy stack → `envFileContent` |
| **Service scale UI** | Scale button on Swarm services table → `scaleService` | | **Service scale UI** | Scale button on Swarm services table → `scaleService` |
| **Create secret / config** | Forms on Swarm Secrets/Configs tabs | | **Create secret / config** | Forms on Swarm Secrets/Configs tabs |
| **Disconnect network** | Unlink on container networking cards → `disconnectNetwork` | | **Disconnect network** | Unlink on container networking cards → `disconnectNetwork` |
| **Registry browser** | Access view Hub search + pull (vault for private creds) | | **Registry browser** | Access view Hub search + pull (vault for private creds) |
| **Scheduled maintenance** | Server schedules (prune/ping) + Host panel UI (`peardock-schedules.json`) | | **Scheduled maintenance** | Server schedules (prune/ping) + Host panel UI |
| **Container resource editor** | Resources… menu → live `updateContainer` (CPU/memory/restart) | | **Container resource editor** | Resources… menu → live `updateContainer` |
| **Log download / export** | Already wired (`logs-download-btn` + `getContainerLogs`) | | **Log download / export** | `logs-download-btn` + `getContainerLogs` |
| **Multi-host environments** | Fleet env tags (prod/staging/dev/lab) + filter | | **Multi-host environments** | Fleet env tags (prod/staging/dev/lab) + filter |
### Track F checklist ---
- [x] `browseVolume` + UI # Track G — UI/UX design system & power-user chrome — 🔄
- [x] Stack env file on deploy
- [x] Service scale control Elevate the desktop console: denser/spacier layouts, accessible motion, keyboard-first navigation, and subtle chrome that makes long operating sessions less fatiguing.
- [x] Create secret / config forms
- [x] Disconnect network from container details ### Delivered (this pass)
- [x] Registry Hub search in Access
- [x] Server schedules + Host UI | Item | Delivery |
- [x] Resource live editor |------|----------|
- [x] Log export (verified existing) | **Keyboard shortcuts modal** | `?` opens a designed cheatsheet (`openShortcutsModal`); Settings → About link |
- [x] Fleet environment tags | **Quick filter focus** | `/` focuses the active views search/filter |
| **Go-chords** | `g` then letter jumps views (`g c` containers, `g i` images, …); titlebar `g…` hint |
| **Command palette extras** | Shortcuts, focus filter, toggle sidebar, cycle density, refresh view |
| **Density: spacious** | Settings + `body[data-density=spacious\|compact\|comfortable]` |
| **Accent themes** | Teal / cyan / violet (`body[data-accent]`) |
| **Reduce motion** | Preference + OS `prefers-reduced-motion` |
| **List refresh stamp** | “Updated HH:MM:SS” on headers after poll / navigation |
| **Sticky table headers** | Resource tables keep `thead` visible while scrolling |
| **Scroll-to-top** | Floating control after scrolling long content |
| **View enter motion** | Subtle fade/slide (disabled when reduce-motion) |
| **Swarm auto-refresh** | Included in list poll when Swarm view is active |
| **ENABLE_SWARM default on** | Opt out with `ENABLE_SWARM=0` (same pattern as Holesail) |
### Track G checklist
- [x] Shortcuts modal + About entry
- [x] `/` filter focus + `g` chords
- [x] Palette power actions
- [x] Density spacious + accent + reduce motion
- [x] Refresh stamp + sticky headers + scroll-top
- [ ] **Dashboard density cards** — sparkline polish, empty-state CTAs per role
- [ ] **Column visibility** prefs per table (containers/images)
- [ ] **Saved filters** (status + search presets)
- [ ] **Bulk action bar** sticky + keyboard multi-select (`x` / shift)
- [ ] **Notification grouping** by peer + severity collapse
- [ ] **Onboarding checklist** progress persisted across peers
- [ ] **High-contrast mode** for projector / outdoor ops
- [ ] **Font scale** preference (sm / md / lg)
- [ ] **Mobile breakpoints** for ops on tablet (read-mostly)
### Proposed Track H (later)
| Theme | Ideas |
|-------|--------|
| **Observability** | Per-container sparkline history UI, export CSV, host heat map |
| **Fleet** | Cross-peer bulk restart, tag-based deploy targets |
| **Extensibility** | Plugin UI surface when `ENABLE_PLUGINS=1` |
| **Accessibility audit** | axe / keyboard trap pass, ARIA live regions review |
--- ---
## Cross-cutting NFR ✅ ## Cross-cutting NFR ✅
Destructive confirms · job tray · roles · smart defaults · Holesail · GitOps · prune/recreate · notification badge policy · schedules Destructive confirms · job tray · roles · smart defaults · Holesail · GitOps · prune/recreate · notification badge policy · schedules · keyboard-first chrome (Track G)
--- ---
## Definition of done ## Definition of done
1. ✅ Full container/image/network/volume/stack RPC + UI 1. ✅ Full container/image/network/volume/stack RPC + UI
2. ✅ Swarm services/nodes/tasks/secrets/configs (+ create/scale) 2. ✅ Swarm services/nodes/tasks/secrets/configs (+ create/scale); Swarm **on by default**
3. ✅ Holesail, GitOps, recreate, system prune, Hub search 3. ✅ Holesail, GitOps, recreate, system prune, Hub search
4. ✅ Volume browse, resource edit, disconnect network, schedules, fleet envs 4. ✅ Volume browse, resource edit, disconnect network, schedules, fleet envs
5. ✅ Settings subtabs · Access vault · multi-peer fleet 5. ✅ Settings subtabs · Access vault · multi-peer fleet
6. `npm test` green 6. 🔄 Track G UX chrome (first ship); remaining items open
7.`npm test` green
--- ---
**Roadmap complete.** Tracks AF delivered. **Next:** finish remaining Track G checklist (column prefs, saved filters, bulk keyboard, a11y), then open Track H as needed.
+16 -1
View File
@@ -911,6 +911,14 @@ function navigateToView(viewName, opts = {}) {
// ignore // ignore
} }
} }
// Track G: view enter animation + “Updated …” stamp
try {
window.peardockUx?.animateViewEnter?.(viewName);
window.peardockUx?.markListRefreshed?.(viewName);
} catch {
// ignore
}
} }
/** Hash / back-forward navigation */ /** Hash / back-forward navigation */
@@ -4557,7 +4565,8 @@ document.addEventListener('DOMContentLoaded', () => {
containerId, containerId,
}; };
// Fit when tab is visible, then start PTY with size // Fit when tab is visible, then start PTY with size.
// Server probes bash → sh → ash → … until a working shell is found.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows }; const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows };
if (manager.active?.connected) { if (manager.active?.connected) {
@@ -4568,6 +4577,12 @@ document.addEventListener('DOMContentLoaded', () => {
rows: dims.rows, rows: dims.rows,
tty: true, tty: true,
}) })
.then((res) => {
const shell = res?.shell || (Array.isArray(res?.cmd) ? res.cmd.join(' ') : '');
if (shell) {
xterm.writeln(`\x1b[90m[peardock] shell: ${shell}\x1b[0m\r`);
}
})
.catch((err) => { .catch((err) => {
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m`); xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m`);
}); });
+1 -1
View File
@@ -425,7 +425,7 @@ export const api = {
return connOrActive(connection).request(Methods.closeTunnel, { id }) return connOrActive(connection).request(Methods.closeTunnel, { id })
}, },
// —— Swarm (requires ENABLE_SWARM) —— // —— Swarm (on by default; ENABLE_SWARM=0 to disable) ——
swarmInspect(connection) { swarmInspect(connection) {
return connOrActive(connection).request(Methods.swarmInspect, {}) return connOrActive(connection).request(Methods.swarmInspect, {})
}, },
+1 -1
View File
@@ -31,7 +31,7 @@ const CODE_MAP = {
}, },
FEATURE_DISABLED: { FEATURE_DISABLED: {
title: 'Feature disabled', title: 'Feature disabled',
recovery: 'Enable the feature flag on the server (e.g. ENABLE_SWARM=1) and reconnect.', recovery: 'Enable the feature flag on the server (e.g. remove ENABLE_SWARM=0) and reconnect.',
severity: 'warning', severity: 'warning',
}, },
INVALID_ARGS: { INVALID_ARGS: {
+3 -1
View File
@@ -30,7 +30,9 @@ EnvironmentFile=-/opt/peardock/.env
# Example production knobs (prefer .env): # Example production knobs (prefer .env):
# Environment=PEARDOCK_DEFAULT_ROLE=operator # Environment=PEARDOCK_DEFAULT_ROLE=operator
# Environment=PEARDOCK_PEER_ALLOWLIST=1 # Environment=PEARDOCK_PEER_ALLOWLIST=1
# Environment=ENABLE_HOLESAIL=1 # Swarm + Holesail are on by default; opt out with ENABLE_*=0
# Environment=ENABLE_SWARM=0
# Environment=ENABLE_HOLESAIL=0
ReadWritePaths=/opt/peardock ReadWritePaths=/opt/peardock
+1 -1
View File
@@ -59,7 +59,7 @@ Vault file: `peardock-vault.json` (mode 600). Override path with `PEARDOCK_VAULT
| Env | Effect | | Env | Effect |
|-----|--------| |-----|--------|
| `ENABLE_SWARM=1` | Swarm / services / secrets / configs RPC | | `ENABLE_SWARM=0` | Disable Swarm / services / secrets / configs RPC (on by default) |
| `ENABLE_PLUGINS=1` | Plugin install/enable/remove | | `ENABLE_PLUGINS=1` | Plugin install/enable/remove |
| `ENABLE_HOLESAIL=0` | Disable Holesail P2P port tunnels (on by default) — see [HOLESAIL.md](./HOLESAIL.md) | | `ENABLE_HOLESAIL=0` | Disable Holesail P2P port tunnels (on by default) — see [HOLESAIL.md](./HOLESAIL.md) |
| `PEARDOCK_MAX_TUNNELS` | Max concurrent Holesail tunnels (default 20) | | `PEARDOCK_MAX_TUNNELS` | Max concurrent Holesail tunnels (default 20) |
+4 -3
View File
@@ -30,7 +30,8 @@
- **Anyone with the server public key** can *attempt* a DHT connection. - **Anyone with the server public key** can *attempt* a DHT connection.
- **Default role is admin** unless `PEARDOCK_DEFAULT_ROLE` / `PEARDOCK_ADMIN_KEYS` / peer policy tighten it. - **Default role is admin** unless `PEARDOCK_DEFAULT_ROLE` / `PEARDOCK_ADMIN_KEYS` / peer policy tighten it.
- **Swarm / plugins** are off unless `ENABLE_SWARM` / `ENABLE_PLUGINS`. - **Plugins** are off unless `ENABLE_PLUGINS=1`.
- **Swarm** APIs are on by default (`ENABLE_SWARM=0` to disable).
- **Holesail tunnels** are on by default (`ENABLE_HOLESAIL=0` to disable) — each `hs://` URL is a capability to the target port. - **Holesail tunnels** are on by default (`ENABLE_HOLESAIL=0` to disable) — each `hs://` URL is a capability to the target port.
- **Host FS browse** is **default-deny** unless `PEARDOCK_BROWSE_ROOTS` or `PEARDOCK_BROWSE_OPEN=1`. - **Host FS browse** is **default-deny** unless `PEARDOCK_BROWSE_ROOTS` or `PEARDOCK_BROWSE_OPEN=1`.
@@ -57,7 +58,7 @@
| Rate limit | Per-peer limiter on RPC | | Rate limit | Per-peer limiter on RPC |
| Registry secrets | AES-256-GCM vault (`registry-vault.js`) | | Registry secrets | AES-256-GCM vault (`registry-vault.js`) |
| Browse FS | Root allowlist / default deny | | Browse FS | Root allowlist / default deny |
| Feature gates | `ENABLE_SWARM`, `ENABLE_PLUGINS`, `ENABLE_HOLESAIL` (default on; set `0` to disable), `PEARDOCK_UNRESTRICTED_CLI` | | Feature gates | `ENABLE_SWARM` / `ENABLE_HOLESAIL` (default on; set `0` to disable), `ENABLE_PLUGINS`, `PEARDOCK_UNRESTRICTED_CLI` |
| Tunnel targets | Loopback / allowlisted hosts only (`PEARDOCK_TUNNEL_HOSTS`) | | Tunnel targets | Loopback / allowlisted hosts only (`PEARDOCK_TUNNEL_HOSTS`) |
--- ---
@@ -78,7 +79,7 @@
- [ ] Set `PEARDOCK_ADMIN_KEYS=<your client public key hex>` - [ ] Set `PEARDOCK_ADMIN_KEYS=<your client public key hex>`
- [ ] Enable `PEARDOCK_PEER_ALLOWLIST=1` after registering operators - [ ] Enable `PEARDOCK_PEER_ALLOWLIST=1` after registering operators
- [ ] Set `PEARDOCK_BROWSE_ROOTS` only if host path pickers are needed - [ ] Set `PEARDOCK_BROWSE_ROOTS` only if host path pickers are needed
- [ ] Leave `ENABLE_SWARM` / `ENABLE_PLUGINS` off unless required - [ ] Set `ENABLE_SWARM=0` if Swarm APIs are not needed; leave `ENABLE_PLUGINS` off unless required
- [ ] File mode `600` on vault, peer policy, audit, `.env` - [ ] File mode `600` on vault, peer policy, audit, `.env`
- [ ] Run server as non-root in `docker` group (see `deploy/peardock.service`) - [ ] Run server as non-root in `docker` group (see `deploy/peardock.service`)
- [ ] Review `peardock-audit.log` periodically - [ ] Review `peardock-audit.log` periodically
+37 -6
View File
@@ -1445,13 +1445,13 @@
</div> </div>
</div> </div>
<!-- Swarm view (ENABLE_SWARM=1 on server) --> <!-- Swarm view (on by default; ENABLE_SWARM=0 to disable) -->
<div id="swarm-view" class="view hidden"> <div id="swarm-view" class="view hidden">
<div class="container-fluid"> <div class="container-fluid">
<div class="page-header"> <div class="page-header">
<div> <div>
<h2><i class="fas fa-project-diagram"></i>Swarm</h2> <h2><i class="fas fa-project-diagram"></i>Swarm</h2>
<p class="page-subtitle">Services, nodes, and tasks (requires ENABLE_SWARM=1)</p> <p class="page-subtitle">Services, nodes, and tasks (on by default; set ENABLE_SWARM=0 to disable)</p>
</div> </div>
<button class="btn btn-outline-primary" type="button" id="swarm-refresh-btn"><i class="fas fa-sync me-1"></i>Refresh</button> <button class="btn btn-outline-primary" type="button" id="swarm-refresh-btn"><i class="fas fa-sync me-1"></i>Refresh</button>
</div> </div>
@@ -1920,8 +1920,9 @@ services:
<select id="settings-density" class="form-select bg-dark text-white"> <select id="settings-density" class="form-select bg-dark text-white">
<option value="comfortable">Comfortable</option> <option value="comfortable">Comfortable</option>
<option value="compact">Compact</option> <option value="compact">Compact</option>
<option value="spacious">Spacious</option>
</select> </select>
<div class="form-text">Tighter rows and cards when compact.</div> <div class="form-text">Row and card spacing for dense fleets or touch targets.</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="settings-sidebar">Sidebar</label> <label class="form-label" for="settings-sidebar">Sidebar</label>
@@ -1931,6 +1932,31 @@ services:
</select> </select>
<div class="form-text">Applied on next launch and when you save.</div> <div class="form-text">Applied on next launch and when you save.</div>
</div> </div>
<div class="col-md-4">
<label class="form-label" for="settings-accent">Accent color</label>
<select id="settings-accent" class="form-select bg-dark text-white">
<option value="teal">Teal (default)</option>
<option value="cyan">Cyan</option>
<option value="violet">Violet</option>
</select>
<div class="form-text">Highlights, focus rings, and active nav.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-reduce-motion">Motion</label>
<select id="settings-reduce-motion" class="form-select bg-dark text-white">
<option value="0">Allow subtle animation</option>
<option value="1">Reduce motion</option>
</select>
<div class="form-text">Also respects OS “prefers-reduced-motion”.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-refresh-stamp">List refresh stamp</label>
<select id="settings-refresh-stamp" class="form-select bg-dark text-white">
<option value="1">Show “Updated …” time</option>
<option value="0">Hide</option>
</select>
<div class="form-text">Appears on list headers after refresh / auto-poll.</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2128,13 +2154,18 @@ services:
<div class="settings-section"> <div class="settings-section">
<h3>About peardock</h3> <h3>About peardock</h3>
<p class="small text-muted mb-1">Decentralized Docker control plane over HyperDHT + protomux-rpc.</p> <p class="small text-muted mb-1">Decentralized Docker control plane over HyperDHT + protomux-rpc.</p>
<p class="small text-muted mb-1">Version: <code id="settings-app-version">2.0.0</code> · License: Apache-2.0</p> <p class="small text-muted mb-1">Version: <code id="settings-app-version">2.0.1</code> · License: Apache-2.0</p>
<p class="small text-muted mb-3">Holesail integration (optional data-plane tunnels) is AGPL-3.0.</p> <p class="small text-muted mb-3">Holesail integration (optional data-plane tunnels) is AGPL-3.0.</p>
<h4 class="h6 text-white">Keyboard</h4> <h4 class="h6 text-white">Keyboard</h4>
<ul class="small text-muted mb-3"> <ul class="small text-muted mb-2">
<li><kbd>Ctrl</kbd>+<kbd>K</kbd> — command palette</li> <li><kbd>Ctrl</kbd>/<kbd></kbd>+<kbd>K</kbd> — command palette</li>
<li><kbd>?</kbd> — shortcuts help</li> <li><kbd>?</kbd> — shortcuts help</li>
<li><kbd>/</kbd> — focus list filter</li>
<li><kbd>g</kbd> then a letter — jump to a view</li>
</ul> </ul>
<button type="button" class="btn btn-outline-secondary btn-sm mb-3" id="settings-open-shortcuts">
<i class="fas fa-keyboard me-1"></i>Open shortcuts cheatsheet
</button>
<h4 class="h6 text-white">Server env (operator)</h4> <h4 class="h6 text-white">Server env (operator)</h4>
<p class="small text-muted mb-0"> <p class="small text-muted mb-0">
Documented in <code>docs/OPERATOR.md</code>: Documented in <code>docs/OPERATOR.md</code>:
+8 -1
View File
@@ -171,7 +171,8 @@ function startTerminal(containerId, containerName) {
name: containerName, name: containerName,
} }
// Fit after layout, then start remote PTY with known size // Fit after layout, then start remote PTY with known size.
// Server probes bash → sh → ash → … until a working shell is found.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows } const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows }
manager manager
@@ -181,6 +182,12 @@ function startTerminal(containerId, containerName) {
rows: dims.rows, rows: dims.rows,
tty: true, tty: true,
}) })
.then((res) => {
const shell = res?.shell || (Array.isArray(res?.cmd) ? res.cmd.join(' ') : '')
if (shell) {
xterm.writeln(`\x1b[90m[peardock] shell: ${shell}\x1b[0m\r`)
}
})
.catch((err) => { .catch((err) => {
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m\r\n`) xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m\r\n`)
}) })
+7 -4
View File
@@ -2,11 +2,11 @@
# peardock interactive installer # peardock interactive installer
# #
# One-liner: # One-liner:
# curl -fsSL https://git.ssh.surf/snxraven/peardock/raw/branch/main/scripts/install.sh | bash # curl -fsSL https://install.peardock.boats | bash
# #
# Non-interactive: # Non-interactive:
# curl -fsSL ... | bash -s -- --server # curl -fsSL https://install.peardock.boats | bash -s -- --server
# curl -fsSL ... | bash -s -- --client # curl -fsSL https://install.peardock.boats | bash -s -- --client
# PEARDOCK_ROLE=server bash scripts/install.sh --yes # PEARDOCK_ROLE=server bash scripts/install.sh --yes
# #
# Env overrides: # Env overrides:
@@ -18,6 +18,9 @@
# PEARDOCK_CLIENT_DIR=... (default: ~/.local/share/peardock or ~/Applications) # PEARDOCK_CLIENT_DIR=... (default: ~/.local/share/peardock or ~/Applications)
# PEARDOCK_YES=1 — assume defaults / non-interactive where possible # PEARDOCK_YES=1 — assume defaults / non-interactive where possible
# #
# Canonical install host: https://install.peardock.boats (serves this script)
# Site: https://peardock.boats
#
set -euo pipefail set -euo pipefail
# ─── defaults ─────────────────────────────────────────────────────────────── # ─── defaults ───────────────────────────────────────────────────────────────
@@ -125,7 +128,7 @@ Usage: install.sh [options]
--help This help --help This help
One-liner: One-liner:
curl -fsSL ${GITEA_URL}/${OWNER}/${REPO}/raw/branch/main/scripts/install.sh | bash curl -fsSL https://install.peardock.boats | bash
EOF EOF
exit 0 exit 0
;; ;;
+11 -3
View File
@@ -1,18 +1,26 @@
/** /**
* Swarm / Services / Nodes / Tasks / Secrets / Configs handlers. * Swarm / Services / Nodes / Tasks / Secrets / Configs handlers.
* Gated by ENABLE_SWARM=1 (or true). * On by default. Opt out with ENABLE_SWARM=0 / false / off / no.
*/ */
import { docker } from '../services/docker.js' import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js' import * as validation from '../utils/validation.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
/**
* Swarm APIs are on by default.
* Opt out with ENABLE_SWARM=0 / false / off / no.
*/
export function isSwarmEnabled() { export function isSwarmEnabled() {
return process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true' const v = String(process.env.ENABLE_SWARM ?? '1').trim().toLowerCase()
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
return true
} }
function assertSwarm() { function assertSwarm() {
if (!isSwarmEnabled()) { if (!isSwarmEnabled()) {
const err = new Error('Swarm APIs disabled. Set ENABLE_SWARM=1 to enable.') const err = new Error(
'Swarm APIs disabled. Remove ENABLE_SWARM=0 to re-enable (on by default).'
)
err.code = 'FEATURE_DISABLED' err.code = 'FEATURE_DISABLED'
throw err throw err
} }
+320 -35
View File
@@ -1,6 +1,10 @@
/** /**
* Interactive container terminal over protomux-rpc. * Interactive container terminal over protomux-rpc.
* TTY sessions stream raw PTY bytes (no demux). Supports multi-session via sessionId. * TTY sessions stream raw PTY bytes (no demux). Supports multi-session via sessionId.
*
* Shell selection: try an ordered list of shells (bash, sh, ash, ) until one
* actually stays running. Docker's exec create often succeeds even when the
* binary is missing failure shows up only after start (exit 126/127).
*/ */
import { PassThrough } from 'stream' import { PassThrough } from 'stream'
import { docker } from '../services/docker.js' import { docker } from '../services/docker.js'
@@ -9,6 +13,85 @@ import logger from '../utils/logger.js'
const SESSIONS_KEY = 'terminals' const SESSIONS_KEY = 'terminals'
/** How long to wait for a candidate shell to prove it is alive. */
const SHELL_PROBE_MS = 450
const SHELL_PROBE_STEP_MS = 40
/**
* Ordered shell candidates. Prefer interactive shells, then POSIX sh variants,
* busybox, then PATH-relative names. First match that stays Running wins.
*/
export const DEFAULT_SHELL_CANDIDATES = Object.freeze([
['/bin/bash'],
['/usr/bin/bash'],
['bash'],
['/bin/zsh'],
['/usr/bin/zsh'],
['zsh'],
['/bin/sh'],
['/usr/bin/sh'],
['sh'],
['/bin/ash'],
['/usr/bin/ash'],
['ash'],
['/bin/dash'],
['/usr/bin/dash'],
['dash'],
['/bin/ksh'],
['/usr/bin/ksh'],
['ksh'],
['/bin/mksh'],
['/usr/bin/mksh'],
['mksh'],
['/bin/fish'],
['/usr/bin/fish'],
['fish'],
['/bin/busybox', 'sh'],
['/usr/bin/busybox', 'sh'],
['busybox', 'sh'],
['/busybox', 'sh'],
['/bin/busybox', 'ash'],
['/system/bin/sh'], // Android-ish images
])
/**
* One-shot script: from a working sh, pick the best available interactive shell.
* Used when /bin/sh (or equivalent) exists but we still want bash if present.
*/
const SHELL_PROBE_SCRIPT = [
'for s in /bin/bash /usr/bin/bash bash /bin/zsh /usr/bin/zsh zsh',
'/bin/ash /usr/bin/ash ash /bin/dash /usr/bin/dash dash',
'/bin/ksh /usr/bin/ksh ksh /bin/mksh /usr/bin/mksh mksh',
'/bin/fish /usr/bin/fish fish; do',
' if [ -x "$s" ]; then exec "$s"; fi',
' if command -v "$s" >/dev/null 2>&1; then exec "$s"; fi',
'done',
'for b in /bin/busybox /usr/bin/busybox busybox /busybox; do',
' if [ -x "$b" ]; then exec "$b" sh; fi',
' if command -v "$b" >/dev/null 2>&1; then exec "$b" sh; fi',
'done',
'if [ -x /bin/sh ]; then exec /bin/sh; fi',
'if [ -x /usr/bin/sh ]; then exec /usr/bin/sh; fi',
'exit 127',
].join(' ')
/** Bootstrap wrappers that run SHELL_PROBE_SCRIPT. */
const PROBE_WRAPPERS = Object.freeze([
['/bin/sh', '-c', SHELL_PROBE_SCRIPT],
['/usr/bin/sh', '-c', SHELL_PROBE_SCRIPT],
['sh', '-c', SHELL_PROBE_SCRIPT],
['/bin/bash', '-c', SHELL_PROBE_SCRIPT],
['bash', '-c', SHELL_PROBE_SCRIPT],
['/bin/ash', '-c', SHELL_PROBE_SCRIPT],
['ash', '-c', SHELL_PROBE_SCRIPT],
['/bin/busybox', 'sh', '-c', SHELL_PROBE_SCRIPT],
['busybox', 'sh', '-c', SHELL_PROBE_SCRIPT],
])
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getSessions(session) { function getSessions(session) {
let map = session.state.get(SESSIONS_KEY) let map = session.state.get(SESSIONS_KEY)
if (!map) { if (!map) {
@@ -29,6 +112,223 @@ function toBase64(chunk) {
return Buffer.isBuffer(chunk) ? chunk.toString('base64') : Buffer.from(chunk).toString('base64') return Buffer.isBuffer(chunk) ? chunk.toString('base64') : Buffer.from(chunk).toString('base64')
} }
/**
* Build ordered unique shell command lists for this startTerminal call.
* @param {object} args
* @returns {string[][]}
*/
export function buildShellCandidates(args = {}) {
/** @type {string[][]} */
const out = []
const seen = new Set()
const push = (cmd) => {
if (!Array.isArray(cmd) || !cmd.length) return
const key = cmd.join('\0')
if (seen.has(key)) return
seen.add(key)
out.push(cmd)
}
// Explicit user override first (still fall back if it fails)
if (Array.isArray(args.cmd) && args.cmd.length) {
push(args.cmd.map(String))
} else if (typeof args.cmd === 'string' && args.cmd.trim()) {
push(args.cmd.trim().split(/\s+/))
} else if (args.shell) {
push([String(args.shell)])
}
// Prefer adaptive probe wrappers, then each concrete shell
for (const c of PROBE_WRAPPERS) push(c)
for (const c of DEFAULT_SHELL_CANDIDATES) push([...c])
return out
}
/**
* Safely tear down a failed probe stream / exec.
* @param {import('stream').Duplex|null|undefined} stream
*/
function destroyStream(stream) {
if (!stream) return
try {
stream.removeAllListeners?.()
} catch {
// ignore
}
try {
stream.end?.()
} catch {
// ignore
}
try {
stream.destroy?.()
} catch {
// ignore
}
}
/**
* After exec.start(), decide whether the process is a live shell.
* Docker often creates exec configs for missing binaries; they exit 126/127 immediately.
*
* @param {object} exec dockerode Exec
* @param {import('stream').Duplex} stream
* @param {number} [timeoutMs]
* @returns {Promise<{ ok: boolean, exitCode: number|null|undefined, buffered: Buffer[] }>}
*/
async function probeShellAlive(exec, stream, timeoutMs = SHELL_PROBE_MS) {
/** @type {Buffer[]} */
const buffered = []
let ended = false
let streamError = null
const onData = (chunk) => {
buffered.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const onEnd = () => {
ended = true
}
const onError = (err) => {
streamError = err
ended = true
}
stream.on('data', onData)
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('close', onEnd)
const deadline = Date.now() + timeoutMs
/** @type {number|null|undefined} */
let exitCode
let running = false
while (Date.now() < deadline) {
try {
const info = await exec.inspect()
running = Boolean(info.Running)
exitCode = info.ExitCode
if (running) {
// Live process — interactive shell waiting for input
detachProbeListeners(stream, onData, onEnd, onError)
return { ok: true, exitCode, buffered }
}
// Not running: if it already exited, treat as failure (try next shell)
if (info.Pid === 0 || exitCode != null || ended) {
detachProbeListeners(stream, onData, onEnd, onError)
return { ok: false, exitCode: exitCode ?? (streamError ? -1 : 127), buffered }
}
} catch {
// inspect can race; keep polling
}
if (ended && !running) {
detachProbeListeners(stream, onData, onEnd, onError)
return { ok: false, exitCode: exitCode ?? 127, buffered }
}
await sleep(SHELL_PROBE_STEP_MS)
}
// Timeout: accept if still running or we got output (prompt)
try {
const info = await exec.inspect()
running = Boolean(info.Running)
exitCode = info.ExitCode
} catch {
// ignore
}
detachProbeListeners(stream, onData, onEnd, onError)
if (running || buffered.length > 0) {
return { ok: true, exitCode, buffered }
}
return { ok: false, exitCode: exitCode ?? 127, buffered }
}
function detachProbeListeners(stream, onData, onEnd, onError) {
try {
stream.off?.('data', onData) || stream.removeListener?.('data', onData)
stream.off?.('end', onEnd) || stream.removeListener?.('end', onEnd)
stream.off?.('close', onEnd) || stream.removeListener?.('close', onEnd)
stream.off?.('error', onError) || stream.removeListener?.('error', onError)
} catch {
// ignore
}
}
/**
* Try shell candidates until one stays running.
* @param {import('dockerode').Container} container
* @param {string[][]} candidates
* @param {boolean} useTty
* @returns {Promise<{ exec: object, stream: import('stream').Duplex, cmd: string[], buffered: Buffer[] }>}
*/
export async function openShellExec(container, candidates, useTty) {
/** @type {Error|null} */
let lastErr = null
const tried = []
for (const Cmd of candidates) {
const label = Cmd.join(' ')
tried.push(label)
let stream = null
try {
const exec = await container.exec({
Cmd,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: useTty,
Env: ['TERM=xterm-256color', 'COLORTERM=truecolor'],
})
stream = await exec.start({
hijack: true,
stdin: true,
Tty: useTty,
})
const probe = await probeShellAlive(exec, stream)
if (!probe.ok) {
logger.debug('terminal shell candidate rejected', {
cmd: label,
exitCode: probe.exitCode,
})
destroyStream(stream)
lastErr = new Error(
`Shell not available: ${label}` +
(probe.exitCode != null ? ` (exit ${probe.exitCode})` : '')
)
continue
}
// Pause until live push handlers are attached so no bytes are dropped
try {
stream.pause?.()
} catch {
// ignore
}
logger.info('terminal shell selected', { cmd: label })
return { exec, stream, cmd: Cmd, buffered: probe.buffered }
} catch (err) {
lastErr = err instanceof Error ? err : new Error(String(err))
destroyStream(stream)
logger.debug('terminal shell candidate error', {
cmd: label,
error: lastErr.message,
})
}
}
const detail = tried.length ? ` Tried: ${tried.slice(0, 12).join(' · ')}${tried.length > 12 ? ' …' : ''}` : ''
throw (
lastErr ||
new Error(
`No working shell found in container.${detail} Install bash/sh or set startTerminal cmd.`
)
)
}
export function registerTerminalHandlers(session) { export function registerTerminalHandlers(session) {
session.respond('startTerminal', async (args) => { session.respond('startTerminal', async (args) => {
const containerId = args.containerId const containerId = args.containerId
@@ -42,43 +342,14 @@ export function registerTerminalHandlers(session) {
} }
const useTty = args.tty !== false const useTty = args.tty !== false
const shellCandidates = [] const shellCandidates = buildShellCandidates(args)
if (Array.isArray(args.cmd) && args.cmd.length) {
shellCandidates.push(args.cmd.map(String))
} else if (typeof args.cmd === 'string' && args.cmd.trim()) {
shellCandidates.push(args.cmd.trim().split(/\s+/))
} else if (args.shell) {
shellCandidates.push([String(args.shell)])
} else {
shellCandidates.push(['/bin/bash'], ['/bin/sh'], ['/bin/ash'])
}
const container = docker.getContainer(containerId) const container = docker.getContainer(containerId)
let exec = null
let lastErr = null
for (const Cmd of shellCandidates) {
try {
exec = await container.exec({
Cmd,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: useTty,
})
break
} catch (err) {
lastErr = err
}
}
if (!exec) {
throw lastErr || new Error('Failed to create exec session')
}
const stream = await exec.start({ const { exec, stream, cmd, buffered } = await openShellExec(
hijack: true, container,
stdin: true, shellCandidates,
Tty: useTty, useTty
}) )
const entry = { const entry = {
containerId, containerId,
@@ -86,6 +357,7 @@ export function registerTerminalHandlers(session) {
stream, stream,
sessionId, sessionId,
tty: useTty, tty: useTty,
cmd,
} }
sessions.set(sessionId, entry) sessions.set(sessionId, entry)
session.state.set('terminal', entry) session.state.set('terminal', entry)
@@ -106,6 +378,11 @@ export function registerTerminalHandlers(session) {
} }
} }
// Flush any prompt bytes captured during the probe window, then live stream
for (const chunk of buffered) {
pushOut(chunk, false)
}
if (useTty) { if (useTty) {
// Raw PTY — do NOT demux (would corrupt ANSI / binary) // Raw PTY — do NOT demux (would corrupt ANSI / binary)
stream.on('data', (chunk) => pushOut(chunk, false)) stream.on('data', (chunk) => pushOut(chunk, false))
@@ -116,6 +393,11 @@ export function registerTerminalHandlers(session) {
stdout.on('data', (chunk) => pushOut(chunk, false)) stdout.on('data', (chunk) => pushOut(chunk, false))
stderr.on('data', (chunk) => pushOut(chunk, true)) stderr.on('data', (chunk) => pushOut(chunk, true))
} }
try {
stream.resume?.()
} catch {
// ignore
}
stream.on('end', () => { stream.on('end', () => {
sessions.delete(sessionId) sessions.delete(sessionId)
@@ -140,6 +422,7 @@ export function registerTerminalHandlers(session) {
containerId, containerId,
sessionId, sessionId,
tty: useTty, tty: useTty,
shell: cmd.join(' '),
peer: session.id.slice(0, 12), peer: session.id.slice(0, 12),
}) })
return { return {
@@ -148,6 +431,8 @@ export function registerTerminalHandlers(session) {
sessionId, sessionId,
containerId, containerId,
tty: useTty, tty: useTty,
shell: cmd.join(' '),
cmd,
} }
}) })
+4 -1
View File
@@ -87,7 +87,10 @@ export function getMetricsSnapshot(extra = {}) {
}, },
pushes: counters.pushes, pushes: counters.pushes,
features: { features: {
swarm: process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true', swarm: (() => {
const v = String(process.env.ENABLE_SWARM ?? '1').trim().toLowerCase()
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
})(),
plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true', plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true',
holesail: (() => { holesail: (() => {
const v = String(process.env.ENABLE_HOLESAIL ?? '1').trim().toLowerCase() const v = String(process.env.ENABLE_HOLESAIL ?? '1').trim().toLowerCase()
+1 -1
View File
@@ -330,7 +330,7 @@ export const Methods = Object.freeze({
suggestFromImage: 'suggestFromImage', suggestFromImage: 'suggestFromImage',
suggestDefaults: 'suggestDefaults', suggestDefaults: 'suggestDefaults',
// Swarm (gated by ENABLE_SWARM) // Swarm (on by default; ENABLE_SWARM=0 to disable)
swarmInit: 'swarmInit', swarmInit: 'swarmInit',
swarmJoin: 'swarmJoin', swarmJoin: 'swarmJoin',
swarmLeave: 'swarmLeave', swarmLeave: 'swarmLeave',
+46
View File
@@ -0,0 +1,46 @@
/**
* Terminal shell candidate ordering / uniqueness (no Docker required).
*/
import test from 'brittle'
import {
DEFAULT_SHELL_CANDIDATES,
buildShellCandidates,
} from '../server/handlers/terminal.js'
test('DEFAULT_SHELL_CANDIDATES includes bash, sh, ash, busybox', (t) => {
const flat = DEFAULT_SHELL_CANDIDATES.map((c) => c.join(' '))
t.ok(flat.some((s) => s.includes('bash')))
t.ok(flat.some((s) => s === '/bin/sh' || s.endsWith(' sh') || s === 'sh'))
t.ok(flat.some((s) => s.includes('ash')))
t.ok(flat.some((s) => s.includes('busybox')))
})
test('buildShellCandidates puts explicit cmd first', (t) => {
const list = buildShellCandidates({ cmd: ['/custom/shell', '-i'] })
t.alike(list[0], ['/custom/shell', '-i'])
// still includes fallbacks
t.ok(list.some((c) => c[0] === '/bin/bash' || c.join(' ') === '/bin/bash'))
t.ok(list.some((c) => c[0] === '/bin/sh' || c.includes('/bin/sh')))
})
test('buildShellCandidates honors shell string', (t) => {
const list = buildShellCandidates({ shell: '/opt/myshell' })
t.alike(list[0], ['/opt/myshell'])
})
test('buildShellCandidates has unique entries', (t) => {
const list = buildShellCandidates({})
const keys = list.map((c) => c.join('\0'))
t.is(keys.length, new Set(keys).size)
t.ok(list.length >= DEFAULT_SHELL_CANDIDATES.length)
})
test('buildShellCandidates includes probe wrappers before lone shells', (t) => {
const list = buildShellCandidates({})
const idxProbe = list.findIndex((c) => c.includes('-c') && c.some((p) => String(p).includes('exec')))
const idxBash = list.findIndex((c) => c.length === 1 && c[0] === '/bin/bash')
t.ok(idxProbe >= 0, 'probe wrapper present')
t.ok(idxBash >= 0, '/bin/bash present')
// probe wrappers may appear before or after explicit; for default both exist
t.ok(list.length > 10)
})
+22
View File
@@ -27,6 +27,10 @@ const REQUIRED_IDS = [
'stack-git-url', 'stack-git-url',
'tunnel-create-btn', 'tunnel-create-btn',
'swarm-services-body', 'swarm-services-body',
'settings-density',
'settings-accent',
'settings-reduce-motion',
'settings-open-shortcuts',
] ]
const REQUIRED_SNIPPETS = [ const REQUIRED_SNIPPETS = [
@@ -37,6 +41,8 @@ const REQUIRED_SNIPPETS = [
'GitOps', 'GitOps',
'collapse-sidebar-btn', 'collapse-sidebar-btn',
'open-add-connection-btn', 'open-add-connection-btn',
'settings-refresh-stamp',
'value="spacious"',
] ]
test('index.html retains critical view landmarks', (t) => { test('index.html retains critical view landmarks', (t) => {
@@ -56,3 +62,19 @@ test('modern.css includes collapsed sidebar rail rules', (t) => {
t.ok(css.includes('#sidebar.collapsed')) t.ok(css.includes('#sidebar.collapsed'))
t.ok(css.includes('icon') || css.includes('nav-link')) t.ok(css.includes('icon') || css.includes('nav-link'))
}) })
test('modern.css includes Track G UX chrome', (t) => {
const css = fs.readFileSync(path.join(root, 'ui/modern.css'), 'utf8')
t.ok(css.includes('.pd-scroll-top'))
t.ok(css.includes('.pd-refresh-stamp'))
t.ok(css.includes("data-density='spacious'") || css.includes('data-density=spacious') || css.includes("data-density='spacious'"))
t.ok(css.includes("data-accent='violet'") || css.includes('data-accent'))
})
test('track-g-ux.js exports shortcuts and go-map', (t) => {
const src = fs.readFileSync(path.join(root, 'ui/track-g-ux.js'), 'utf8')
t.ok(src.includes('openShortcutsModal'))
t.ok(src.includes('focusListSearch'))
t.ok(src.includes('initTrackGUx'))
t.ok(src.includes("c: 'containers'"))
})
+203
View File
@@ -2668,6 +2668,66 @@ body[data-density='compact'] .nav-link {
body[data-density='compact'] .connection-item { body[data-density='compact'] .connection-item {
padding: 6px 8px !important; padding: 6px 8px !important;
} }
body[data-density='compact'] .view {
padding-top: 12px !important;
}
body[data-density='compact'] .btn-sm {
padding: 0.2rem 0.45rem;
font-size: 0.75rem;
}
body[data-density='spacious'] .table > :not(caption) > * > * {
padding: 0.85rem 1rem;
font-size: 14px;
}
body[data-density='spacious'] .stat-card,
body[data-density='spacious'] .info-card {
padding: 1.25rem 1.35rem !important;
}
body[data-density='spacious'] .page-header {
margin-bottom: 1.5rem;
}
body[data-density='spacious'] .nav-link {
padding: 12px 14px;
font-size: 14px;
}
body[data-density='spacious'] .view {
padding-top: 1.5rem !important;
}
/* Accent variants (Track G) */
body[data-accent='cyan'] {
--accent-primary: #22d3ee;
--accent-secondary: #67e8f9;
--accent-glow: rgba(34, 211, 238, 0.35);
}
body[data-accent='violet'] {
--accent-primary: #a78bfa;
--accent-secondary: #c4b5fd;
--accent-glow: rgba(167, 139, 250, 0.35);
}
/* Sticky table headers in scrollable resource tables */
.table-responsive,
.pd-table-scroll,
.info-card-body {
overflow: auto;
}
.table-responsive > .table > thead,
.pd-table-scroll > .table > thead,
#containers-table thead,
#images-table thead,
#networks-table thead,
#volumes-table thead,
#stacks-table thead {
position: sticky;
top: 0;
z-index: 2;
}
.table thead th {
background: var(--bg-elevated, #1c2128);
box-shadow: 0 1px 0 var(--border, rgba(255, 255, 255, 0.08));
}
/* List search */ /* List search */
.list-search-input { .list-search-input {
@@ -2675,6 +2735,149 @@ body[data-density='compact'] .connection-item {
min-width: 160px; min-width: 160px;
} }
/* ─── Track G UX chrome ─── */
.pd-page-header--with-stamp {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1.25rem;
}
.pd-refresh-stamp {
font-size: 0.75rem;
color: var(--text-muted, #9aa8bc);
white-space: nowrap;
padding: 0.35rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
background: rgba(15, 17, 23, 0.55);
align-self: center;
margin-left: auto;
}
.pd-refresh-stamp time {
font-variant-numeric: tabular-nums;
color: var(--text-secondary, #c8d1df);
}
.pd-view-enter {
animation: pd-view-enter 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes pd-view-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
body[data-reduce-motion='1'] .pd-view-enter,
body[data-system-reduced-motion='1'] .pd-view-enter {
animation: none !important;
}
/* g-chord pending indicator on titlebar */
body.pd-go-pending #titlebar::after {
content: 'g…';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--accent-primary, #2dd4bf);
background: rgba(45, 212, 191, 0.12);
border: 1px solid rgba(45, 212, 191, 0.28);
padding: 2px 8px;
border-radius: 999px;
pointer-events: none;
z-index: 20;
}
.pd-scroll-top {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
z-index: 1050;
width: 42px;
height: 42px;
border-radius: 50%;
border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.14));
background: var(--bg-elevated, #1c2128);
color: var(--text-secondary, #c8d1df);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s, color 0.15s, border-color 0.15s;
display: grid;
place-items: center;
cursor: pointer;
}
.pd-scroll-top--visible {
opacity: 1;
pointer-events: auto;
transform: none;
}
.pd-scroll-top:hover {
color: var(--accent-primary, #2dd4bf);
border-color: rgba(45, 212, 191, 0.35);
}
/* Shortcuts modal */
.pd-shortcuts-modal {
max-width: min(440px, 94vw);
text-align: left;
}
.pd-shortcuts-body {
max-height: min(50vh, 360px);
overflow: auto;
margin: 0.5rem 0 0.25rem;
padding-right: 0.25rem;
}
.pd-shortcuts-heading {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint, #7d8b9f);
margin: 0.85rem 0 0.4rem;
}
.pd-shortcuts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.pd-shortcuts-table th {
text-align: left;
font-weight: 500;
padding: 0.35rem 0.75rem 0.35rem 0;
vertical-align: top;
white-space: nowrap;
width: 38%;
}
.pd-shortcuts-table td {
color: var(--text-muted, #9aa8bc);
padding: 0.35rem 0;
vertical-align: top;
}
.pd-kbd {
display: inline-block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.78em;
font-weight: 600;
padding: 0.15em 0.45em;
border-radius: 6px;
border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.14));
background: rgba(0, 0, 0, 0.35);
color: var(--text-secondary, #c8d1df);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
}
/* Inline empty states inside tables */ /* Inline empty states inside tables */
.pd-empty--inline { .pd-empty--inline {
padding: 20px 12px; padding: 20px 12px;
+82 -27
View File
@@ -45,7 +45,7 @@ const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed'
/** Defaults for client-side preferences (localStorage peardock.settings.v1). */ /** Defaults for client-side preferences (localStorage peardock.settings.v1). */
export const DEFAULT_SETTINGS = { export const DEFAULT_SETTINGS = {
density: 'comfortable', density: 'comfortable', // comfortable | compact | spacious
confirmDestructive: true, confirmDestructive: true,
refreshSeconds: 10, refreshSeconds: 10,
sidebarCollapsed: false, sidebarCollapsed: false,
@@ -60,6 +60,10 @@ export const DEFAULT_SETTINGS = {
dockerTerminalTheme: 'dark', dockerTerminalTheme: 'dark',
showFirstConnectTip: true, showFirstConnectTip: true,
settingsTab: 'appearance', settingsTab: 'appearance',
// Track G — appearance / motion
reduceMotion: false,
accent: 'teal', // teal | cyan | violet
showRefreshStamp: true,
} }
export function loadSettings() { export function loadSettings() {
@@ -91,6 +95,9 @@ let listRefreshTimer = null
export function applySettings(s = loadSettings()) { export function applySettings(s = loadSettings()) {
document.body.dataset.density = s.density || 'comfortable' document.body.dataset.density = s.density || 'comfortable'
document.body.dataset.badgeMode = s.badgeMode || 'alerts' document.body.dataset.badgeMode = s.badgeMode || 'alerts'
document.body.dataset.accent = s.accent || 'teal'
document.body.dataset.reduceMotion = s.reduceMotion ? '1' : '0'
document.body.dataset.refreshStamp = s.showRefreshStamp === false ? '0' : '1'
startListAutoRefresh(s.refreshSeconds) startListAutoRefresh(s.refreshSeconds)
// Sidebar collapsed preference // Sidebar collapsed preference
@@ -160,6 +167,12 @@ export function startListAutoRefresh(seconds) {
else if (view === 'host') loadHostView({ silent: true }) else if (view === 'host') loadHostView({ silent: true })
else if (view === 'tunnels') loadTunnelsView({ silent: true }) else if (view === 'tunnels') loadTunnelsView({ silent: true })
else if (view === 'events') loadEventsView({ silent: true }) else if (view === 'events') loadEventsView({ silent: true })
else if (view === 'swarm') loadSwarmView({ silent: true })
try {
window.peardockUx?.stampOnPoll?.()
} catch {
// ignore
}
}, sec * 1000) }, sec * 1000)
} }
@@ -813,7 +826,7 @@ export async function closeTunnelById(id) {
* @param {{ openBrowser?: boolean, localPort?: number }} [opts] * @param {{ openBrowser?: boolean, localPort?: number }} [opts]
*/ */
/** /**
* Swarm services / nodes / tasks (ENABLE_SWARM=1). * Swarm services / nodes / tasks (on by default; ENABLE_SWARM=0 to disable).
*/ */
export async function loadSwarmView(opts = {}) { export async function loadSwarmView(opts = {}) {
const silent = opts.silent === true const silent = opts.silent === true
@@ -946,7 +959,7 @@ export async function loadSwarmView(opts = {}) {
if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled/i.test(msg)) { if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled/i.test(msg)) {
banner.className = 'alert alert-secondary small mb-3' banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML = banner.innerHTML =
'Swarm APIs are <strong>off</strong>. Start the server with <code>ENABLE_SWARM=1</code>.' 'Swarm APIs are <strong>off</strong>. Remove <code>ENABLE_SWARM=0</code> (on by default).'
} else { } else {
banner.className = 'alert alert-danger small mb-3' banner.className = 'alert alert-danger small mb-3'
banner.textContent = msg banner.textContent = msg
@@ -1043,8 +1056,17 @@ export function showSettingsTab(tab) {
* Collect form values from settings panels into a partial settings object. * Collect form values from settings panels into a partial settings object.
*/ */
export function readSettingsForm() { export function readSettingsForm() {
const densityRaw = document.getElementById('settings-density')?.value || 'comfortable'
const density = ['comfortable', 'compact', 'spacious'].includes(densityRaw)
? densityRaw
: 'comfortable'
const accentRaw = document.getElementById('settings-accent')?.value || 'teal'
const accent = ['teal', 'cyan', 'violet'].includes(accentRaw) ? accentRaw : 'teal'
return { return {
density: document.getElementById('settings-density')?.value || 'comfortable', density,
accent,
reduceMotion: document.getElementById('settings-reduce-motion')?.value === '1',
showRefreshStamp: document.getElementById('settings-refresh-stamp')?.value !== '0',
sidebarCollapsed: document.getElementById('settings-sidebar')?.value === 'collapsed', sidebarCollapsed: document.getElementById('settings-sidebar')?.value === 'collapsed',
confirmDestructive: document.getElementById('settings-confirm')?.value !== '0', confirmDestructive: document.getElementById('settings-confirm')?.value !== '0',
refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0, refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0,
@@ -1067,6 +1089,9 @@ export function readSettingsForm() {
export function loadSettingsView() { export function loadSettingsView() {
const s = loadSettings() const s = loadSettings()
setSelectValue('settings-density', s.density || 'comfortable') setSelectValue('settings-density', s.density || 'comfortable')
setSelectValue('settings-accent', s.accent || 'teal')
setSelectValue('settings-reduce-motion', s.reduceMotion ? '1' : '0')
setSelectValue('settings-refresh-stamp', s.showRefreshStamp === false ? '0' : '1')
setSelectValue('settings-sidebar', s.sidebarCollapsed ? 'collapsed' : 'expanded') setSelectValue('settings-sidebar', s.sidebarCollapsed ? 'collapsed' : 'expanded')
setSelectValue('settings-confirm', s.confirmDestructive === false ? '0' : '1') setSelectValue('settings-confirm', s.confirmDestructive === false ? '0' : '1')
setSelectValue('settings-refresh', s.refreshSeconds ?? 10) setSelectValue('settings-refresh', s.refreshSeconds ?? 10)
@@ -1125,7 +1150,7 @@ export function loadSettingsView() {
} }
const ver = document.getElementById('settings-app-version') const ver = document.getElementById('settings-app-version')
if (ver) ver.textContent = '2.0.0' if (ver) ver.textContent = '2.0.1'
templateUrlsDraft = getTemplateListUrls() templateUrlsDraft = getTemplateListUrls()
renderTemplateUrlsEditor() renderTemplateUrlsEditor()
@@ -1223,36 +1248,52 @@ export async function saveTemplateListSettings(opts = {}) {
} }
export function openPalette(navigateToView) { export function openPalette(navigateToView) {
const send = typeof window !== 'undefined' ? window.sendCommand : null
const gItems =
typeof window !== 'undefined' && window.__peardockTrackGPalette
? window.__peardockTrackGPalette(navigateToView, send)
: []
const items = [ const items = [
{ label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard' }, { label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard', keywords: 'g d home' },
{ label: 'Containers', icon: 'fa-cube', view: 'containers' }, { label: 'Containers', icon: 'fa-cube', view: 'containers', keywords: 'g c' },
{ label: 'Images', icon: 'fa-layer-group', view: 'images' }, { label: 'Images', icon: 'fa-layer-group', view: 'images', keywords: 'g i' },
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks' }, { label: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' },
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes' }, { label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks' }, { label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm' }, { label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' },
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy' }, { label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
{ label: 'Fleet', icon: 'fa-server', view: 'fleet' }, { label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
{ label: 'Peers', icon: 'fa-network-wired', view: 'peers' }, { label: 'Peers', icon: 'fa-network-wired', view: 'peers', keywords: 'g p' },
{ label: 'Events', icon: 'fa-bolt', view: 'events' }, { label: 'Events', icon: 'fa-bolt', view: 'events', keywords: 'g e' },
{ label: 'Host', icon: 'fa-microchip', view: 'host' }, { label: 'Host', icon: 'fa-microchip', view: 'host', keywords: 'g h system' },
{ label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels' }, { label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' },
{ label: 'Access', icon: 'fa-user-shield', view: 'access' }, { label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a vault registry' },
{ label: 'Settings', icon: 'fa-gear', view: 'settings' }, { label: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' },
{ {
label: 'Create network (smart)', label: 'Create network (smart)',
icon: 'fa-plus', icon: 'fa-plus',
keywords: 'ipam subnet',
action: () => openSmartNetworkModal(), action: () => openSmartNetworkModal(),
}, },
{ {
label: 'Refresh host snapshot', label: 'Refresh host snapshot',
icon: 'fa-sync', icon: 'fa-sync',
keywords: 'warm cache',
action: () => warmSnapshot(), action: () => warmSnapshot(),
}, },
...gItems,
] ]
openCommandPalette(items, (item) => { openCommandPalette(items, (item) => {
if (item.action) item.action() if (item.action) item.action()
else if (item.view && navigateToView) navigateToView(item.view) else if (item.view && navigateToView) {
navigateToView(item.view)
try {
window.peardockUx?.animateViewEnter?.(item.view)
window.peardockUx?.markListRefreshed?.(item.view)
} catch {
// ignore
}
}
}) })
} }
@@ -1318,6 +1359,24 @@ export function initOpsApp({ navigateToView, sendCommand }) {
}) })
.catch((err) => console.warn('[ops] track-f-extras failed to load', err)) .catch((err) => console.warn('[ops] track-f-extras failed to load', err))
// Track G UX (shortcuts, go-chords, refresh stamp, scroll-top)
import('./track-g-ux.js')
.then((mod) => {
mod.initTrackGUx?.({ navigateToView, sendCommand })
if (typeof window !== 'undefined') {
window.__peardockTrackGPalette = mod.trackGPaletteItems
if (window.peardockOps) {
Object.assign(window.peardockOps, {
openShortcutsModal: mod.openShortcutsModal,
focusListSearch: mod.focusListSearch,
markListRefreshed: mod.markListRefreshed,
saveSettings,
})
}
}
})
.catch((err) => console.warn('[ops] track-g-ux failed to load', err))
document.getElementById('offline-banner-dismiss')?.addEventListener('click', () => { document.getElementById('offline-banner-dismiss')?.addEventListener('click', () => {
setOfflineBanner(false) setOfflineBanner(false)
}) })
@@ -1493,12 +1552,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
e.preventDefault() e.preventDefault()
openPalette(navigateToView) openPalette(navigateToView)
} }
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !isTyping(e.target)) { // ? and / and g-chords handled in track-g-ux.js
showAlert(
'info',
'Shortcuts: Ctrl+K palette · Navigate sidebar · Destructive actions confirm in Settings'
)
}
}) })
// Enhance create-network buttons in the app to use smart modal when present // Enhance create-network buttons in the app to use smart modal when present
@@ -1553,6 +1607,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
confirmDialog, confirmDialog,
shouldConfirmDestructive, shouldConfirmDestructive,
loadSettings, loadSettings,
saveSettings,
applySettings, applySettings,
shouldShowFirstConnectTip, shouldShowFirstConnectTip,
showSettingsTab, showSettingsTab,
+474
View File
@@ -0,0 +1,474 @@
/**
* Track G UI/UX polish: shortcuts modal, go-chords, quick filter,
* last-refreshed stamps, scroll-to-top, view enter animations.
*/
import { el, escapeHtml } from './components.js'
const SETTINGS_KEY = 'peardock.settings.v1'
/** @type {Record<string, string>} */
const GO_MAP = {
d: 'dashboard',
c: 'containers',
i: 'images',
n: 'networks',
v: 'volumes',
s: 'stacks',
w: 'swarm',
e: 'events',
h: 'host',
t: 'tunnels',
p: 'peers',
f: 'fleet',
a: 'access',
',': 'settings',
o: 'deploy',
}
/** @type {string|null} */
let goPending = null
/** @type {ReturnType<typeof setTimeout>|null} */
let goTimer = null
function loadSettingsLocal() {
try {
if (typeof window !== 'undefined' && window.peardockOps?.loadSettings) {
return window.peardockOps.loadSettings()
}
return { ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') }
} catch {
return {}
}
}
function saveSettingsLocal(partial) {
if (typeof window !== 'undefined' && window.peardockOps?.saveSettings) {
return window.peardockOps.saveSettings(partial)
}
try {
const next = { ...loadSettingsLocal(), ...partial }
localStorage.setItem(SETTINGS_KEY, JSON.stringify(next))
document.body.dataset.density = next.density || 'comfortable'
document.body.dataset.reduceMotion = next.reduceMotion ? '1' : '0'
document.body.dataset.accent = next.accent || 'teal'
return next
} catch {
return partial
}
}
function isTyping(target) {
if (!target) return false
const tag = target.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
}
function reduceMotionOn() {
const s = loadSettingsLocal()
if (s.reduceMotion) return true
try {
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
} catch {
return false
}
}
/**
* Rich shortcuts modal with key table.
*/
export function openShortcutsModal() {
if (document.getElementById('pd-shortcuts-modal')) return
const previouslyFocused = document.activeElement
const backdrop = el('div', {
className: 'pd-modal-backdrop',
role: 'presentation',
id: 'pd-shortcuts-modal',
})
const modal = el('div', {
className: 'pd-modal pd-modal--info pd-shortcuts-modal',
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': 'pd-shortcuts-title',
})
const sections = [
{
title: 'Global',
rows: [
['Ctrl/⌘ + K', 'Open command palette'],
['?', 'Show this help'],
['/', 'Focus search / filter on the current list'],
['Esc', 'Close dialogs and panels'],
],
},
{
title: 'Go to (press g, then key)',
rows: [
['g d', 'Dashboard'],
['g c', 'Containers'],
['g i', 'Images'],
['g n / g v', 'Networks / Volumes'],
['g s / g w', 'Stacks / Swarm'],
['g o', 'Deploy'],
['g e / g h', 'Events / Host'],
['g t / g p / g f', 'Tunnels / Peers / Fleet'],
['g a / g ,', 'Access / Settings'],
],
},
]
modal.innerHTML = `
<div class="pd-modal-icon pd-modal-icon--info" aria-hidden="true">
<i class="fas fa-keyboard"></i>
</div>
<h3 class="pd-modal-title" id="pd-shortcuts-title">Keyboard shortcuts</h3>
<p class="pd-modal-body">Power-user navigation for the desktop console.</p>
<div class="pd-shortcuts-body">
${sections
.map(
(sec) => `
<h4 class="pd-shortcuts-heading">${escapeHtml(sec.title)}</h4>
<table class="pd-shortcuts-table">
<tbody>
${sec.rows
.map(
([k, v]) =>
`<tr><th scope="row"><kbd class="pd-kbd">${escapeHtml(k)}</kbd></th><td>${escapeHtml(v)}</td></tr>`
)
.join('')}
</tbody>
</table>`
)
.join('')}
</div>
<div class="pd-modal-actions">
<button type="button" class="btn btn-primary pd-ok">Got it</button>
</div>`
backdrop.appendChild(modal)
document.body.appendChild(backdrop)
requestAnimationFrame(() => backdrop.classList.add('pd-modal-backdrop--visible'))
const close = () => {
backdrop.classList.remove('pd-modal-backdrop--visible')
backdrop.classList.add('pd-modal-backdrop--leaving')
setTimeout(() => {
backdrop.remove()
document.removeEventListener('keydown', onKey)
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
try {
previouslyFocused.focus()
} catch {
// ignore
}
}
}, 160)
}
const onKey = (e) => {
if (e.key === 'Escape' || e.key === 'Enter') {
e.preventDefault()
close()
}
}
document.addEventListener('keydown', onKey)
modal.querySelector('.pd-ok')?.addEventListener('click', close)
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close()
})
setTimeout(() => modal.querySelector('.pd-ok')?.focus?.(), 30)
}
/**
* Focus the primary list filter for the active view.
*/
export function focusListSearch() {
const view = typeof window !== 'undefined' ? window.currentView : null
const map = {
containers: 'container-search',
images: 'image-search',
networks: 'network-search',
volumes: 'volume-search',
stacks: 'stack-search',
events: 'events-filter',
deploy: 'deploy-template-search-input',
}
const id = map[view] || null
/** @type {HTMLElement|null} */
let elSearch = id ? document.getElementById(id) : null
if (!elSearch) {
const activeView = document.querySelector('.view:not(.hidden)')
elSearch =
activeView?.querySelector?.(
'input[type="search"], input.list-search-input, .view-toolbar input[type="text"], .view-toolbar input'
) || null
}
if (elSearch && typeof elSearch.focus === 'function') {
elSearch.focus()
if (typeof elSearch.select === 'function') elSearch.select()
return true
}
return false
}
/**
* Stamp Updated on the active view toolbar / header.
* @param {string} [view]
*/
export function markListRefreshed(view) {
const s = loadSettingsLocal()
if (s.showRefreshStamp === false) return
const name = view || (typeof window !== 'undefined' ? window.currentView : null)
if (!name) return
const root =
document.getElementById(`${name}-view`) || document.querySelector('.view:not(.hidden)')
if (!root) return
let stamp = root.querySelector('.pd-refresh-stamp')
if (!stamp) {
const header = root.querySelector('.page-header')
const toolbar = root.querySelector('.view-toolbar')
const host = header || toolbar || root.querySelector('.container-fluid') || root
stamp = document.createElement('div')
stamp.className = 'pd-refresh-stamp'
stamp.setAttribute('aria-live', 'polite')
if (header) {
// Prefer right side of header when flex
header.classList.add('pd-page-header--with-stamp')
header.appendChild(stamp)
} else if (toolbar) {
toolbar.appendChild(stamp)
} else {
host.insertBefore(stamp, host.firstChild)
}
}
const now = new Date()
const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
stamp.innerHTML = `<i class="fas fa-clock me-1" aria-hidden="true"></i>Updated <time datetime="${now.toISOString()}">${escapeHtml(time)}</time>`
stamp.dataset.at = String(Date.now())
}
/**
* Subtle enter animation when switching views.
* @param {string} viewName
*/
export function animateViewEnter(viewName) {
if (reduceMotionOn()) return
const root = document.getElementById(`${viewName}-view`)
if (!root) return
root.classList.remove('pd-view-enter')
void root.offsetWidth
root.classList.add('pd-view-enter')
}
/**
* Floating scroll-to-top control for long lists.
*/
function ensureScrollTopBtn() {
let btn = document.getElementById('pd-scroll-top')
if (btn) return btn
btn = document.createElement('button')
btn.id = 'pd-scroll-top'
btn.type = 'button'
btn.className = 'pd-scroll-top'
btn.title = 'Back to top'
btn.setAttribute('aria-label', 'Back to top')
btn.innerHTML = '<i class="fas fa-arrow-up" aria-hidden="true"></i>'
btn.addEventListener('click', () => {
const content = document.getElementById('content')
const behavior = reduceMotionOn() ? 'auto' : 'smooth'
if (content && content.scrollHeight > content.clientHeight) {
content.scrollTo({ top: 0, behavior })
} else {
window.scrollTo({ top: 0, behavior })
}
})
document.body.appendChild(btn)
return btn
}
function updateScrollTopVisibility() {
const btn = ensureScrollTopBtn()
const content = document.getElementById('content')
const y = content ? content.scrollTop : window.scrollY || document.documentElement.scrollTop
btn.classList.toggle('pd-scroll-top--visible', y > 320)
}
/**
* Wire global Track G keyboard + chrome.
* @param {{ navigateToView?: (v: string) => void }} [ctx]
*/
export function initTrackGUx(ctx = {}) {
const navigateToView =
ctx.navigateToView ||
((v) => {
if (typeof window !== 'undefined' && typeof window.navigateToView === 'function') {
window.navigateToView(v)
}
})
ensureScrollTopBtn()
const content = document.getElementById('content')
const onScroll = () => updateScrollTopVisibility()
content?.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
updateScrollTopVisibility()
const go = (view) => {
navigateToView?.(view)
requestAnimationFrame(() => {
animateViewEnter(view)
markListRefreshed(view)
})
}
document.addEventListener('keydown', (e) => {
if (e.defaultPrevented) return
if (document.querySelector('.pd-palette-backdrop, #pd-shortcuts-modal, .modal.show')) {
return
}
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) {
e.preventDefault()
openShortcutsModal()
return
}
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) {
e.preventDefault()
focusListSearch()
return
}
if (!e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) {
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
if (goPending) {
e.preventDefault()
const dest = GO_MAP[key]
if (goTimer) clearTimeout(goTimer)
goPending = null
goTimer = null
document.body.classList.remove('pd-go-pending')
if (dest) go(dest)
return
}
if (key === 'g') {
e.preventDefault()
goPending = 'g'
document.body.classList.add('pd-go-pending')
if (goTimer) clearTimeout(goTimer)
goTimer = setTimeout(() => {
goPending = null
document.body.classList.remove('pd-go-pending')
}, 900)
}
}
})
if (typeof window !== 'undefined') {
window.peardockUx = {
openShortcutsModal,
focusListSearch,
markListRefreshed,
animateViewEnter,
stampOnPoll: () => {
const view = window.currentView
if (view) markListRefreshed(view)
},
GO_MAP,
}
}
document.getElementById('settings-open-shortcuts')?.addEventListener('click', () => {
openShortcutsModal()
})
try {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const apply = () => {
document.body.dataset.systemReducedMotion = mq.matches ? '1' : '0'
}
mq.addEventListener?.('change', apply)
apply()
} catch {
// ignore
}
return { go }
}
/**
* Extra palette actions for Track G.
* @param {(v: string) => void} navigateToView
* @param {Function} [sendCommand]
*/
export function trackGPaletteItems(navigateToView, sendCommand) {
return [
{
label: 'Keyboard shortcuts',
icon: 'fa-keyboard',
keywords: 'help keys hotkeys',
action: () => openShortcutsModal(),
},
{
label: 'Focus list filter',
icon: 'fa-filter',
keywords: 'search /',
action: () => focusListSearch(),
},
{
label: 'Toggle sidebar',
icon: 'fa-bars',
keywords: 'collapse rail',
action: () => {
const sidebar = document.getElementById('sidebar')
if (!sidebar) return
const next = !sidebar.classList.contains('collapsed')
sidebar.classList.toggle('collapsed', next)
saveSettingsLocal({ sidebarCollapsed: next })
// keep legacy key in sync
try {
localStorage.setItem('peardock.sidebarCollapsed', next ? '1' : '0')
} catch {
// ignore
}
},
},
{
label: 'Cycle table density',
icon: 'fa-table-cells',
keywords: 'comfortable compact spacious',
action: () => {
const s = loadSettingsLocal()
const order = ['comfortable', 'compact', 'spacious']
const i = Math.max(0, order.indexOf(s.density || 'comfortable'))
const density = order[(i + 1) % order.length]
saveSettingsLocal({ density })
document.body.dataset.density = density
},
},
{
label: 'Refresh current view',
icon: 'fa-sync',
keywords: 'reload poll',
action: () => {
const view = window.currentView
const send = sendCommand || window.sendCommand
if (!send || !view) return
const quiet = { silent: true }
if (view === 'containers' || view === 'dashboard') send('listContainers', {}, quiet)
else if (view === 'images') send('listImages', {}, quiet)
else if (view === 'networks') send('listNetworks', {}, quiet)
else if (view === 'volumes') send('listVolumes', {}, quiet)
else if (view === 'stacks') send('listStacks', {}, quiet)
markListRefreshed(view)
},
},
]
}
export default {
initTrackGUx,
openShortcutsModal,
focusListSearch,
markListRefreshed,
trackGPaletteItems,
}