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:
|
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
@@ -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 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 |
|
| 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 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 ✅
|
## 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 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
|
// 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
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
'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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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