Ship Track G UX, multi-shell terminals, Swarm default-on, install.peardock.boats
Release rolling / release (push) Successful in 8m22s
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:
@@ -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:
|
||||
|
||||
```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:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
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-*`).
|
||||
|
||||
+61
-21
@@ -12,6 +12,7 @@
|
||||
| **D — Holesail tunnels** | ✅ Complete |
|
||||
| **E — Docker ops / Portainer-parity (core)** | ✅ Complete |
|
||||
| **F — Optional Docker ops enhancements** | ✅ Complete |
|
||||
| **G — UI/UX design system & power-user chrome** | 🔄 In progress |
|
||||
|
||||
```bash
|
||||
npm test
|
||||
@@ -34,15 +35,16 @@ npm run dev
|
||||
| GitOps | `server/utils/gitops.js` |
|
||||
| Recreate / system prune | `recreateContainer`, `systemPrune` |
|
||||
| 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 |
|
||||
| Schedules | `server/services/schedules.js` |
|
||||
| Fleet environments | peer env tags + filter |
|
||||
|
||||
---
|
||||
|
||||
# Tracks A–E — ✅ Complete
|
||||
# Tracks A–F — ✅ 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 |
|
||||
|------|----------|
|
||||
| **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` |
|
||||
| **Service scale UI** | Scale button on Swarm services table → `scaleService` |
|
||||
| **Create secret / config** | Forms on Swarm Secrets/Configs tabs |
|
||||
| **Disconnect network** | Unlink on container networking cards → `disconnectNetwork` |
|
||||
| **Registry browser** | Access view Hub search + pull (vault for private creds) |
|
||||
| **Scheduled maintenance** | Server schedules (prune/ping) + Host panel UI (`peardock-schedules.json`) |
|
||||
| **Container resource editor** | Resources… menu → live `updateContainer` (CPU/memory/restart) |
|
||||
| **Log download / export** | Already wired (`logs-download-btn` + `getContainerLogs`) |
|
||||
| **Scheduled maintenance** | Server schedules (prune/ping) + Host panel UI |
|
||||
| **Container resource editor** | Resources… menu → live `updateContainer` |
|
||||
| **Log download / export** | `logs-download-btn` + `getContainerLogs` |
|
||||
| **Multi-host environments** | Fleet env tags (prod/staging/dev/lab) + filter |
|
||||
|
||||
### Track F checklist
|
||||
---
|
||||
|
||||
- [x] `browseVolume` + UI
|
||||
- [x] Stack env file on deploy
|
||||
- [x] Service scale control
|
||||
- [x] Create secret / config forms
|
||||
- [x] Disconnect network from container details
|
||||
- [x] Registry Hub search in Access
|
||||
- [x] Server schedules + Host UI
|
||||
- [x] Resource live editor
|
||||
- [x] Log export (verified existing)
|
||||
- [x] Fleet environment tags
|
||||
# Track G — UI/UX design system & power-user chrome — 🔄
|
||||
|
||||
Elevate the desktop console: denser/spacier layouts, accessible motion, keyboard-first navigation, and subtle chrome that makes long operating sessions less fatiguing.
|
||||
|
||||
### Delivered (this pass)
|
||||
|
||||
| Item | Delivery |
|
||||
|------|----------|
|
||||
| **Keyboard shortcuts modal** | `?` opens a designed cheatsheet (`openShortcutsModal`); Settings → About link |
|
||||
| **Quick filter focus** | `/` focuses the active view’s 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 ✅
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
4. ✅ Volume browse, resource edit, disconnect network, schedules, fleet envs
|
||||
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 A–F delivered.
|
||||
**Next:** finish remaining Track G checklist (column prefs, saved filters, bulk keyboard, a11y), then open Track H as needed.
|
||||
|
||||
@@ -911,6 +911,14 @@ function navigateToView(viewName, opts = {}) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Track G: view enter animation + “Updated …” stamp
|
||||
try {
|
||||
window.peardockUx?.animateViewEnter?.(viewName);
|
||||
window.peardockUx?.markListRefreshed?.(viewName);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash / back-forward navigation */
|
||||
@@ -4557,7 +4565,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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(() => {
|
||||
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows };
|
||||
if (manager.active?.connected) {
|
||||
@@ -4568,6 +4577,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
rows: dims.rows,
|
||||
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) => {
|
||||
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m`);
|
||||
});
|
||||
|
||||
+1
-1
@@ -425,7 +425,7 @@ export const api = {
|
||||
return connOrActive(connection).request(Methods.closeTunnel, { id })
|
||||
},
|
||||
|
||||
// —— Swarm (requires ENABLE_SWARM) ——
|
||||
// —— Swarm (on by default; ENABLE_SWARM=0 to disable) ——
|
||||
swarmInspect(connection) {
|
||||
return connOrActive(connection).request(Methods.swarmInspect, {})
|
||||
},
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ const CODE_MAP = {
|
||||
},
|
||||
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',
|
||||
},
|
||||
INVALID_ARGS: {
|
||||
|
||||
@@ -30,7 +30,9 @@ EnvironmentFile=-/opt/peardock/.env
|
||||
# Example production knobs (prefer .env):
|
||||
# Environment=PEARDOCK_DEFAULT_ROLE=operator
|
||||
# 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
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ Vault file: `peardock-vault.json` (mode 600). Override path with `PEARDOCK_VAULT
|
||||
|
||||
| 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_HOLESAIL=0` | Disable Holesail P2P port tunnels (on by default) — see [HOLESAIL.md](./HOLESAIL.md) |
|
||||
| `PEARDOCK_MAX_TUNNELS` | Max concurrent Holesail tunnels (default 20) |
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 |
|
||||
| Registry secrets | AES-256-GCM vault (`registry-vault.js`) |
|
||||
| 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`) |
|
||||
|
||||
---
|
||||
@@ -78,7 +79,7 @@
|
||||
- [ ] Set `PEARDOCK_ADMIN_KEYS=<your client public key hex>`
|
||||
- [ ] Enable `PEARDOCK_PEER_ALLOWLIST=1` after registering operators
|
||||
- [ ] 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`
|
||||
- [ ] Run server as non-root in `docker` group (see `deploy/peardock.service`)
|
||||
- [ ] Review `peardock-audit.log` periodically
|
||||
|
||||
+37
-6
@@ -1445,13 +1445,13 @@
|
||||
</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 class="container-fluid">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<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>
|
||||
<button class="btn btn-outline-primary" type="button" id="swarm-refresh-btn"><i class="fas fa-sync me-1"></i>Refresh</button>
|
||||
</div>
|
||||
@@ -1920,8 +1920,9 @@ services:
|
||||
<select id="settings-density" class="form-select bg-dark text-white">
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="compact">Compact</option>
|
||||
<option value="spacious">Spacious</option>
|
||||
</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 class="col-md-4">
|
||||
<label class="form-label" for="settings-sidebar">Sidebar</label>
|
||||
@@ -1931,6 +1932,31 @@ services:
|
||||
</select>
|
||||
<div class="form-text">Applied on next launch and when you save.</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>
|
||||
@@ -2128,13 +2154,18 @@ services:
|
||||
<div class="settings-section">
|
||||
<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">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>
|
||||
<h4 class="h6 text-white">Keyboard</h4>
|
||||
<ul class="small text-muted mb-3">
|
||||
<li><kbd>Ctrl</kbd>+<kbd>K</kbd> — command palette</li>
|
||||
<ul class="small text-muted mb-2">
|
||||
<li><kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>K</kbd> — command palette</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>
|
||||
<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>
|
||||
<p class="small text-muted mb-0">
|
||||
Documented in <code>docs/OPERATOR.md</code>:
|
||||
|
||||
+8
-1
@@ -171,7 +171,8 @@ function startTerminal(containerId, 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(() => {
|
||||
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows }
|
||||
manager
|
||||
@@ -181,6 +182,12 @@ function startTerminal(containerId, containerName) {
|
||||
rows: dims.rows,
|
||||
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) => {
|
||||
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m\r\n`)
|
||||
})
|
||||
|
||||
+7
-4
@@ -2,11 +2,11 @@
|
||||
# peardock interactive installer
|
||||
#
|
||||
# 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:
|
||||
# curl -fsSL ... | bash -s -- --server
|
||||
# curl -fsSL ... | bash -s -- --client
|
||||
# curl -fsSL https://install.peardock.boats | bash -s -- --server
|
||||
# curl -fsSL https://install.peardock.boats | bash -s -- --client
|
||||
# PEARDOCK_ROLE=server bash scripts/install.sh --yes
|
||||
#
|
||||
# Env overrides:
|
||||
@@ -18,6 +18,9 @@
|
||||
# PEARDOCK_CLIENT_DIR=... (default: ~/.local/share/peardock or ~/Applications)
|
||||
# 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
|
||||
|
||||
# ─── defaults ───────────────────────────────────────────────────────────────
|
||||
@@ -125,7 +128,7 @@ Usage: install.sh [options]
|
||||
--help This help
|
||||
|
||||
One-liner:
|
||||
curl -fsSL ${GITEA_URL}/${OWNER}/${REPO}/raw/branch/main/scripts/install.sh | bash
|
||||
curl -fsSL https://install.peardock.boats | bash
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
/**
|
||||
* 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 * as validation from '../utils/validation.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() {
|
||||
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() {
|
||||
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'
|
||||
throw err
|
||||
}
|
||||
|
||||
+320
-35
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Interactive container terminal over protomux-rpc.
|
||||
* 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 { docker } from '../services/docker.js'
|
||||
@@ -9,6 +13,85 @@ import logger from '../utils/logger.js'
|
||||
|
||||
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) {
|
||||
let map = session.state.get(SESSIONS_KEY)
|
||||
if (!map) {
|
||||
@@ -29,6 +112,223 @@ function toBase64(chunk) {
|
||||
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) {
|
||||
session.respond('startTerminal', async (args) => {
|
||||
const containerId = args.containerId
|
||||
@@ -42,43 +342,14 @@ export function registerTerminalHandlers(session) {
|
||||
}
|
||||
|
||||
const useTty = args.tty !== false
|
||||
const shellCandidates = []
|
||||
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 shellCandidates = buildShellCandidates(args)
|
||||
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({
|
||||
hijack: true,
|
||||
stdin: true,
|
||||
Tty: useTty,
|
||||
})
|
||||
const { exec, stream, cmd, buffered } = await openShellExec(
|
||||
container,
|
||||
shellCandidates,
|
||||
useTty
|
||||
)
|
||||
|
||||
const entry = {
|
||||
containerId,
|
||||
@@ -86,6 +357,7 @@ export function registerTerminalHandlers(session) {
|
||||
stream,
|
||||
sessionId,
|
||||
tty: useTty,
|
||||
cmd,
|
||||
}
|
||||
sessions.set(sessionId, 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) {
|
||||
// Raw PTY — do NOT demux (would corrupt ANSI / binary)
|
||||
stream.on('data', (chunk) => pushOut(chunk, false))
|
||||
@@ -116,6 +393,11 @@ export function registerTerminalHandlers(session) {
|
||||
stdout.on('data', (chunk) => pushOut(chunk, false))
|
||||
stderr.on('data', (chunk) => pushOut(chunk, true))
|
||||
}
|
||||
try {
|
||||
stream.resume?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
stream.on('end', () => {
|
||||
sessions.delete(sessionId)
|
||||
@@ -140,6 +422,7 @@ export function registerTerminalHandlers(session) {
|
||||
containerId,
|
||||
sessionId,
|
||||
tty: useTty,
|
||||
shell: cmd.join(' '),
|
||||
peer: session.id.slice(0, 12),
|
||||
})
|
||||
return {
|
||||
@@ -148,6 +431,8 @@ export function registerTerminalHandlers(session) {
|
||||
sessionId,
|
||||
containerId,
|
||||
tty: useTty,
|
||||
shell: cmd.join(' '),
|
||||
cmd,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ export function getMetricsSnapshot(extra = {}) {
|
||||
},
|
||||
pushes: counters.pushes,
|
||||
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',
|
||||
holesail: (() => {
|
||||
const v = String(process.env.ENABLE_HOLESAIL ?? '1').trim().toLowerCase()
|
||||
|
||||
+1
-1
@@ -330,7 +330,7 @@ export const Methods = Object.freeze({
|
||||
suggestFromImage: 'suggestFromImage',
|
||||
suggestDefaults: 'suggestDefaults',
|
||||
|
||||
// Swarm (gated by ENABLE_SWARM)
|
||||
// Swarm (on by default; ENABLE_SWARM=0 to disable)
|
||||
swarmInit: 'swarmInit',
|
||||
swarmJoin: 'swarmJoin',
|
||||
swarmLeave: 'swarmLeave',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -27,6 +27,10 @@ const REQUIRED_IDS = [
|
||||
'stack-git-url',
|
||||
'tunnel-create-btn',
|
||||
'swarm-services-body',
|
||||
'settings-density',
|
||||
'settings-accent',
|
||||
'settings-reduce-motion',
|
||||
'settings-open-shortcuts',
|
||||
]
|
||||
|
||||
const REQUIRED_SNIPPETS = [
|
||||
@@ -37,6 +41,8 @@ const REQUIRED_SNIPPETS = [
|
||||
'GitOps',
|
||||
'collapse-sidebar-btn',
|
||||
'open-add-connection-btn',
|
||||
'settings-refresh-stamp',
|
||||
'value="spacious"',
|
||||
]
|
||||
|
||||
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('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
@@ -2668,6 +2668,66 @@ body[data-density='compact'] .nav-link {
|
||||
body[data-density='compact'] .connection-item {
|
||||
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-input {
|
||||
@@ -2675,6 +2735,149 @@ body[data-density='compact'] .connection-item {
|
||||
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 */
|
||||
.pd-empty--inline {
|
||||
padding: 20px 12px;
|
||||
|
||||
+82
-27
@@ -45,7 +45,7 @@ const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed'
|
||||
|
||||
/** Defaults for client-side preferences (localStorage peardock.settings.v1). */
|
||||
export const DEFAULT_SETTINGS = {
|
||||
density: 'comfortable',
|
||||
density: 'comfortable', // comfortable | compact | spacious
|
||||
confirmDestructive: true,
|
||||
refreshSeconds: 10,
|
||||
sidebarCollapsed: false,
|
||||
@@ -60,6 +60,10 @@ export const DEFAULT_SETTINGS = {
|
||||
dockerTerminalTheme: 'dark',
|
||||
showFirstConnectTip: true,
|
||||
settingsTab: 'appearance',
|
||||
// Track G — appearance / motion
|
||||
reduceMotion: false,
|
||||
accent: 'teal', // teal | cyan | violet
|
||||
showRefreshStamp: true,
|
||||
}
|
||||
|
||||
export function loadSettings() {
|
||||
@@ -91,6 +95,9 @@ let listRefreshTimer = null
|
||||
export function applySettings(s = loadSettings()) {
|
||||
document.body.dataset.density = s.density || 'comfortable'
|
||||
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)
|
||||
|
||||
// Sidebar collapsed preference
|
||||
@@ -160,6 +167,12 @@ export function startListAutoRefresh(seconds) {
|
||||
else if (view === 'host') loadHostView({ silent: true })
|
||||
else if (view === 'tunnels') loadTunnelsView({ silent: true })
|
||||
else if (view === 'events') loadEventsView({ silent: true })
|
||||
else if (view === 'swarm') loadSwarmView({ silent: true })
|
||||
try {
|
||||
window.peardockUx?.stampOnPoll?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, sec * 1000)
|
||||
}
|
||||
|
||||
@@ -813,7 +826,7 @@ export async function closeTunnelById(id) {
|
||||
* @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 = {}) {
|
||||
const silent = opts.silent === true
|
||||
@@ -946,7 +959,7 @@ export async function loadSwarmView(opts = {}) {
|
||||
if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled/i.test(msg)) {
|
||||
banner.className = 'alert alert-secondary small mb-3'
|
||||
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 {
|
||||
banner.className = 'alert alert-danger small mb-3'
|
||||
banner.textContent = msg
|
||||
@@ -1043,8 +1056,17 @@ export function showSettingsTab(tab) {
|
||||
* Collect form values from settings panels into a partial settings object.
|
||||
*/
|
||||
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 {
|
||||
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',
|
||||
confirmDestructive: document.getElementById('settings-confirm')?.value !== '0',
|
||||
refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0,
|
||||
@@ -1067,6 +1089,9 @@ export function readSettingsForm() {
|
||||
export function loadSettingsView() {
|
||||
const s = loadSettings()
|
||||
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-confirm', s.confirmDestructive === false ? '0' : '1')
|
||||
setSelectValue('settings-refresh', s.refreshSeconds ?? 10)
|
||||
@@ -1125,7 +1150,7 @@ export function loadSettingsView() {
|
||||
}
|
||||
|
||||
const ver = document.getElementById('settings-app-version')
|
||||
if (ver) ver.textContent = '2.0.0'
|
||||
if (ver) ver.textContent = '2.0.1'
|
||||
|
||||
templateUrlsDraft = getTemplateListUrls()
|
||||
renderTemplateUrlsEditor()
|
||||
@@ -1223,36 +1248,52 @@ export async function saveTemplateListSettings(opts = {}) {
|
||||
}
|
||||
|
||||
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 = [
|
||||
{ label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard' },
|
||||
{ label: 'Containers', icon: 'fa-cube', view: 'containers' },
|
||||
{ label: 'Images', icon: 'fa-layer-group', view: 'images' },
|
||||
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks' },
|
||||
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes' },
|
||||
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks' },
|
||||
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm' },
|
||||
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy' },
|
||||
{ label: 'Fleet', icon: 'fa-server', view: 'fleet' },
|
||||
{ label: 'Peers', icon: 'fa-network-wired', view: 'peers' },
|
||||
{ label: 'Events', icon: 'fa-bolt', view: 'events' },
|
||||
{ label: 'Host', icon: 'fa-microchip', view: 'host' },
|
||||
{ label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels' },
|
||||
{ label: 'Access', icon: 'fa-user-shield', view: 'access' },
|
||||
{ label: 'Settings', icon: 'fa-gear', view: 'settings' },
|
||||
{ label: 'Dashboard', icon: 'fa-gauge-high', view: 'dashboard', keywords: 'g d home' },
|
||||
{ label: 'Containers', icon: 'fa-cube', view: 'containers', keywords: 'g c' },
|
||||
{ label: 'Images', icon: 'fa-layer-group', view: 'images', keywords: 'g i' },
|
||||
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' },
|
||||
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
|
||||
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
|
||||
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' },
|
||||
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
|
||||
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
|
||||
{ label: 'Peers', icon: 'fa-network-wired', view: 'peers', keywords: 'g p' },
|
||||
{ label: 'Events', icon: 'fa-bolt', view: 'events', keywords: 'g e' },
|
||||
{ label: 'Host', icon: 'fa-microchip', view: 'host', keywords: 'g h system' },
|
||||
{ label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' },
|
||||
{ label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a vault registry' },
|
||||
{ label: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' },
|
||||
{
|
||||
label: 'Create network (smart)',
|
||||
icon: 'fa-plus',
|
||||
keywords: 'ipam subnet',
|
||||
action: () => openSmartNetworkModal(),
|
||||
},
|
||||
{
|
||||
label: 'Refresh host snapshot',
|
||||
icon: 'fa-sync',
|
||||
keywords: 'warm cache',
|
||||
action: () => warmSnapshot(),
|
||||
},
|
||||
...gItems,
|
||||
]
|
||||
openCommandPalette(items, (item) => {
|
||||
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))
|
||||
|
||||
// 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', () => {
|
||||
setOfflineBanner(false)
|
||||
})
|
||||
@@ -1493,12 +1552,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
e.preventDefault()
|
||||
openPalette(navigateToView)
|
||||
}
|
||||
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !isTyping(e.target)) {
|
||||
showAlert(
|
||||
'info',
|
||||
'Shortcuts: Ctrl+K palette · Navigate sidebar · Destructive actions confirm in Settings'
|
||||
)
|
||||
}
|
||||
// ? and / and g-chords handled in track-g-ux.js
|
||||
})
|
||||
|
||||
// Enhance create-network buttons in the app to use smart modal when present
|
||||
@@ -1553,6 +1607,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
confirmDialog,
|
||||
shouldConfirmDestructive,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
applySettings,
|
||||
shouldShowFirstConnectTip,
|
||||
showSettingsTab,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user