Expand Settings into categorized subtabs with full client prefs
CI / test (push) Successful in 10m6s

Add Appearance, Behavior, Notifications, Tunnels, Templates,
Connections, Terminal, and About panels. Wire density, sidebar,
confirmations, refresh, badge mode, toasts, tunnel defaults,
template lists, peer reset, terminal themes, and read-only server
feature flags into peardock.settings.v1.
This commit is contained in:
2026-07-10 23:45:11 -04:00
parent 182fe1e675
commit 869ba6a932
8 changed files with 618 additions and 92 deletions
+10 -4
View File
@@ -1051,7 +1051,11 @@ function applyRoleUI() {
function showFirstConnectChecklist() { function showFirstConnectChecklist() {
try { try {
if (localStorage.getItem('peardock.firstConnect.dismissed') === '1') return; if (typeof window.peardockOps?.shouldShowFirstConnectTip === 'function') {
if (!window.peardockOps.shouldShowFirstConnectTip()) return;
} else if (localStorage.getItem('peardock.firstConnect.dismissed') === '1') {
return;
}
} catch { } catch {
// ignore // ignore
} }
@@ -2554,8 +2558,10 @@ function populateContainerDetails(config, container) {
if (typeof window.peardockOps?.loadTunnelsView === 'function') { if (typeof window.peardockOps?.loadTunnelsView === 'function') {
// warm tunnels view cache // warm tunnels view cache
} }
// Offer local connect via designed modal // Offer local connect via designed modal (Settings → Tunnels)
if (url && window.peardockOps?.connectLocalTunnel) { const offerLocal =
window.peardockOps?.loadSettings?.()?.offerLocalTunnelAfterCreate !== false;
if (url && offerLocal && window.peardockOps?.connectLocalTunnel) {
const open = window.peardockOps.askUserConfirm const open = window.peardockOps.askUserConfirm
? await window.peardockOps.askUserConfirm( ? await window.peardockOps.askUserConfirm(
'Open local tunnel?', 'Open local tunnel?',
@@ -2564,7 +2570,7 @@ function populateContainerDetails(config, container) {
) )
: false; : false;
if (open) { if (open) {
await window.peardockOps.connectLocalTunnel(url, { openBrowser: true }); await window.peardockOps.connectLocalTunnel(url);
} }
} }
} catch (err) { } catch (err) {
+266 -64
View File
@@ -1711,75 +1711,277 @@ services:
<!-- Settings --> <!-- Settings -->
<div id="settings-view" class="view hidden"> <div id="settings-view" class="view hidden">
<div class="container-fluid"> <div class="container-fluid">
<div class="page-header"> <div class="page-header d-flex flex-wrap justify-content-between align-items-start gap-2">
<div> <div>
<h2><i class="fas fa-gear"></i>Settings</h2> <h2><i class="fas fa-gear"></i>Settings</h2>
<p class="page-subtitle">Client preferences and peer context</p> <p class="page-subtitle">Client preferences, catalogs, connections, and about</p>
</div> </div>
</div> <button type="button" class="btn btn-primary btn-sm" id="settings-save-btn">
<div class="settings-section"> <i class="fas fa-floppy-disk me-1"></i>Save all
<h3>Client</h3>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Table density</label>
<select id="settings-density" class="form-select bg-dark text-white">
<option value="comfortable">Comfortable</option>
<option value="compact">Compact</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Confirm destructive actions</label>
<select id="settings-confirm" class="form-select bg-dark text-white">
<option value="1">Always (recommended)</option>
<option value="0">Skip (not recommended)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Auto-refresh lists (seconds)</label>
<input type="number" id="settings-refresh" class="form-control bg-dark text-white" min="0" max="120" value="10">
</div>
</div>
<button type="button" class="btn btn-primary btn-sm mt-3" id="settings-save-btn">Save preferences</button>
</div>
<div class="settings-section">
<h3>Deploy template lists</h3>
<p class="small text-muted mb-2">
Portainer-format JSON catalogs (<code>templates.json</code>). Add as many as you want — peardock merges them and removes duplicates (same image / stack repo / title). Portainer only allows one list.
</p>
<ul id="settings-template-urls" class="list-group list-group-flush mb-2 settings-template-urls"></ul>
<div class="input-group input-group-sm mb-2">
<input type="url" id="settings-template-url-input" class="form-control bg-dark text-white font-monospace" placeholder="https://example.com/templates.json" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-outline-primary" id="settings-template-url-add" title="Add list URL">
<i class="fas fa-plus me-1"></i>Add
</button>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="settings-template-url-reset" title="Restore default catalog">
Restore default
</button>
<button type="button" class="btn btn-outline-info btn-sm" id="settings-template-reload" title="Fetch all lists now">
<i class="fas fa-sync me-1"></i>Reload templates
</button>
</div>
<div id="settings-template-lists-status" class="small text-muted mt-2"></div>
</div>
<div class="settings-section">
<h3>Active peer</h3>
<div id="settings-peer-info" class="small text-muted">Not connected</div>
</div>
<div class="settings-section">
<h3>Saved peers</h3>
<p class="small text-muted mb-2">
Clear all configured peers from this client. You will need to re-add public keys to connect again.
</p>
<button type="button" id="settings-reset-peers-btn" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash-can me-1"></i>Reset all peers
</button> </button>
</div> </div>
<div class="settings-section">
<h3>About</h3> <ul class="nav nav-pills settings-subtabs flex-wrap gap-1 mb-3" id="settings-tabs" role="tablist">
<p class="small text-muted mb-1">peardock — decentralized Docker control plane over HyperDHT.</p> <li class="nav-item" role="presentation">
<p class="small text-muted mb-0">Keyboard: <kbd>Ctrl</kbd>+<kbd>K</kbd> command palette · <kbd>?</kbd> shortcuts</p> <button type="button" class="nav-link active" data-settings-tab="appearance" role="tab">Appearance</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="behavior" role="tab">Behavior</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="notifications" role="tab">Notifications</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="tunnels" role="tab">Tunnels</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="templates" role="tab">Templates</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="connections" role="tab">Connections</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="terminal" role="tab">Terminal</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="about" role="tab">About</button>
</li>
</ul>
<!-- Appearance -->
<div class="settings-panel" id="settings-panel-appearance" data-settings-panel="appearance">
<div class="settings-section">
<h3>Layout &amp; density</h3>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="settings-density">Table density</label>
<select id="settings-density" class="form-select bg-dark text-white">
<option value="comfortable">Comfortable</option>
<option value="compact">Compact</option>
</select>
<div class="form-text">Tighter rows and cards when compact.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-sidebar">Sidebar</label>
<select id="settings-sidebar" class="form-select bg-dark text-white">
<option value="expanded">Expanded by default</option>
<option value="collapsed">Collapsed rail by default</option>
</select>
<div class="form-text">Applied on next launch and when you save.</div>
</div>
</div>
</div>
</div>
<!-- Behavior -->
<div class="settings-panel hidden" id="settings-panel-behavior" data-settings-panel="behavior">
<div class="settings-section">
<h3>Safety &amp; refresh</h3>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="settings-confirm">Confirm destructive actions</label>
<select id="settings-confirm" class="form-select bg-dark text-white">
<option value="1">Always (recommended)</option>
<option value="0">Skip (not recommended)</option>
</select>
<div class="form-text">Stop / remove / prune and similar actions.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-refresh">Auto-refresh lists (seconds)</label>
<input type="number" id="settings-refresh" class="form-control bg-dark text-white" min="0" max="120" value="10">
<div class="form-text">0 disables. Polls containers, images, host, tunnels, etc.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-job-dismiss">Job tray auto-dismiss (ms)</label>
<input type="number" id="settings-job-dismiss" class="form-control bg-dark text-white" min="0" max="30000" step="500" value="3000">
<div class="form-text">How long successful job cards stay visible. 0 = keep until next job.</div>
</div>
</div>
</div>
</div>
<!-- Notifications -->
<div class="settings-panel hidden" id="settings-panel-notifications" data-settings-panel="notifications">
<div class="settings-section">
<h3>Bell &amp; history</h3>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="settings-badge-mode">Bell badge</label>
<select id="settings-badge-mode" class="form-select bg-dark text-white">
<option value="alerts">Alerts only (danger &amp; warning)</option>
<option value="all">All unread notifications</option>
<option value="none">Never show a badge</option>
</select>
<div class="form-text">All items still appear in the notification center.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="settings-force-toast">Top-center toasts</label>
<select id="settings-force-toast" class="form-select bg-dark text-white">
<option value="0">Off (default — job tray / bell only)</option>
<option value="1">On for success &amp; errors</option>
</select>
<div class="form-text">Usually leave off to avoid double feedback.</div>
</div>
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="settings-notif-mark-read">
Mark all notifications read
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="settings-notif-clear">
Clear notification history
</button>
</div>
</div>
</div>
<!-- Tunnels -->
<div class="settings-panel hidden" id="settings-panel-tunnels" data-settings-panel="tunnels">
<div class="settings-section">
<h3>Holesail client defaults</h3>
<p class="small text-muted mb-3">
Client-side defaults for local connect. Server tunnels are controlled by the peer
(<code>ENABLE_HOLESAIL</code>, allowlist, max tunnels).
</p>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="settings-tunnel-open-browser">Open browser on local connect</label>
<select id="settings-tunnel-open-browser" class="form-select bg-dark text-white">
<option value="1">Yes</option>
<option value="0">No</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-tunnel-offer-local">Offer local connect after create</label>
<select id="settings-tunnel-offer-local" class="form-select bg-dark text-white">
<option value="1">Yes (modal)</option>
<option value="0">No</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-tunnel-secure">Default secure tunnel</label>
<select id="settings-tunnel-secure" class="form-select bg-dark text-white">
<option value="1">Secure (hs://s000…)</option>
<option value="0">Open (hs://0000…)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-tunnel-protocol">Default protocol</label>
<select id="settings-tunnel-protocol" class="form-select bg-dark text-white">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
</div>
</div>
</div>
<!-- Templates -->
<div class="settings-panel hidden" id="settings-panel-templates" data-settings-panel="templates">
<div class="settings-section">
<h3>Deploy template lists</h3>
<p class="small text-muted mb-2">
Portainer-format JSON catalogs (<code>templates.json</code>). Lists are merged and de-duplicated
(same image / stack repo / title).
</p>
<ul id="settings-template-urls" class="list-group list-group-flush mb-2 settings-template-urls"></ul>
<div class="input-group input-group-sm mb-2">
<input type="url" id="settings-template-url-input" class="form-control bg-dark text-white font-monospace" placeholder="https://example.com/templates.json" spellcheck="false" autocomplete="off">
<button type="button" class="btn btn-outline-primary" id="settings-template-url-add" title="Add list URL">
<i class="fas fa-plus me-1"></i>Add
</button>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="settings-template-url-reset" title="Restore default catalog">
Restore default
</button>
<button type="button" class="btn btn-outline-info btn-sm" id="settings-template-reload" title="Fetch all lists now">
<i class="fas fa-sync me-1"></i>Reload templates
</button>
</div>
<div id="settings-template-lists-status" class="small text-muted mt-2"></div>
</div>
</div>
<!-- Connections -->
<div class="settings-panel hidden" id="settings-panel-connections" data-settings-panel="connections">
<div class="settings-section">
<h3>Active peer</h3>
<div id="settings-peer-info" class="small text-muted">Not connected</div>
</div>
<div class="settings-section">
<h3>Server features</h3>
<p class="small text-muted mb-2">Read-only snapshot from the connected peer (env on the server).</p>
<div id="settings-server-features" class="small text-muted">Connect a peer to inspect feature flags.</div>
</div>
<div class="settings-section">
<h3>Onboarding</h3>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="settings-first-connect" checked>
<label class="form-check-label" for="settings-first-connect">Show first-connect tip after adding a peer</label>
</div>
</div>
<div class="settings-section">
<h3>Saved peers</h3>
<p class="small text-muted mb-2">
Clear all configured peers from this client. You will need to re-add public keys to connect again.
</p>
<button type="button" id="settings-reset-peers-btn" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash-can me-1"></i>Reset all peers
</button>
</div>
</div>
<!-- Terminal -->
<div class="settings-panel hidden" id="settings-panel-terminal" data-settings-panel="terminal">
<div class="settings-section">
<h3>Terminal themes</h3>
<p class="small text-muted mb-3">Defaults for container exec / docker terminals in this client.</p>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="settings-term-theme">Container terminal theme</label>
<select id="settings-term-theme" class="form-select bg-dark text-white">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="monokai">Monokai</option>
<option value="solarized-dark">Solarized dark</option>
<option value="solarized-light">Solarized light</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="settings-docker-term-theme">Docker CLI terminal theme</label>
<select id="settings-docker-term-theme" class="form-select bg-dark text-white">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="monokai">Monokai</option>
<option value="solarized-dark">Solarized dark</option>
<option value="solarized-light">Solarized light</option>
</select>
</div>
</div>
</div>
</div>
<!-- About -->
<div class="settings-panel hidden" id="settings-panel-about" data-settings-panel="about">
<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-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>
<li><kbd>?</kbd> — shortcuts help</li>
</ul>
<h4 class="h6 text-white">Server env (operator)</h4>
<p class="small text-muted mb-0">
Documented in <code>docs/OPERATOR.md</code>:
<code>ENABLE_HOLESAIL</code>, <code>ENABLE_SWARM</code>, <code>ENABLE_PLUGINS</code>,
<code>PEARDOCK_DEFAULT_ROLE</code>, <code>PEARDOCK_ADMIN_KEYS</code>,
<code>PEARDOCK_PEER_ALLOWLIST</code>, <code>PEARDOCK_BROWSE_ROOTS</code>,
<code>PEARDOCK_MAX_TUNNELS</code>, <code>PEARDOCK_TUNNEL_HOSTS</code>, <code>PEARDOCK_AUDIT</code>.
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
+24
View File
@@ -370,6 +370,14 @@ document.addEventListener('DOMContentLoaded', () => {
dockerTerminalSession?.xterm?.clear() dockerTerminalSession?.xterm?.clear()
}) })
const themeSelect = document.getElementById('docker-terminal-theme-select') const themeSelect = document.getElementById('docker-terminal-theme-select')
try {
const pref = window.__peardockSettings?.dockerTerminalTheme
if (pref && dockerTerminalThemes[pref]) {
dockerTerminalTheme = pref
}
} catch {
// ignore
}
if (themeSelect) { if (themeSelect) {
themeSelect.value = dockerTerminalTheme themeSelect.value = dockerTerminalTheme
themeSelect.addEventListener('change', (e) => { themeSelect.addEventListener('change', (e) => {
@@ -383,4 +391,20 @@ document.addEventListener('DOMContentLoaded', () => {
updateDockerTerminalFontSizeDisplay() updateDockerTerminalFontSizeDisplay()
}) })
if (typeof window !== 'undefined') {
const prev = window.__peardockApplyTerminalThemes
window.__peardockApplyTerminalThemes = (termTheme, dockerTheme) => {
if (typeof prev === 'function') prev(termTheme, dockerTheme)
if (dockerTheme && dockerTerminalThemes[dockerTheme]) {
dockerTerminalTheme = dockerTheme
if (dockerTerminalSession?.xterm) {
dockerTerminalSession.xterm.options.theme =
dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark
}
const themeSelect = document.getElementById('docker-terminal-theme-select')
if (themeSelect) themeSelect.value = dockerTheme
}
}
}
export { startDockerTerminal, cleanUpDockerTerminal, dockerTerminalSession } export { startDockerTerminal, cleanUpDockerTerminal, dockerTerminalSession }
+21
View File
@@ -17,9 +17,30 @@ const MAX_NOTIFICATIONS = 100 // Limit stored notifications
* @param {{ badge?: boolean }} [options] * @param {{ badge?: boolean }} [options]
* @returns {boolean} * @returns {boolean}
*/ */
/**
* Read badge mode from client settings (alerts | all | none).
*/
function getBadgeMode() {
try {
if (typeof document !== 'undefined' && document.body?.dataset?.badgeMode) {
return document.body.dataset.badgeMode
}
if (typeof localStorage !== 'undefined') {
const raw = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}')
if (raw.badgeMode) return raw.badgeMode
}
} catch {
// ignore
}
return 'alerts'
}
export function deservesBadge(type, options = {}) { export function deservesBadge(type, options = {}) {
if (options.badge === true) return true if (options.badge === true) return true
if (options.badge === false) return false if (options.badge === false) return false
const mode = getBadgeMode()
if (mode === 'none') return false
if (mode === 'all') return true
const t = String(type || 'info').toLowerCase() const t = String(type || 'info').toLowerCase()
return t === 'danger' || t === 'warning' || t === 'error' return t === 'danger' || t === 'warning' || t === 'error'
} }
+19
View File
@@ -309,6 +309,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (session?.xterm) session.xterm.clear() if (session?.xterm) session.xterm.clear()
}) })
const themeSelect = document.getElementById('modal-terminal-theme-select') const themeSelect = document.getElementById('modal-terminal-theme-select')
// Prefer saved settings default
try {
const pref = window.__peardockSettings?.terminalTheme
if (pref && terminalThemes[pref]) {
modalTerminalTheme = pref
}
} catch {
// ignore
}
if (themeSelect) { if (themeSelect) {
themeSelect.value = modalTerminalTheme themeSelect.value = modalTerminalTheme
themeSelect.addEventListener('change', (e) => applyModalTerminalTheme(e.target.value)) themeSelect.addEventListener('change', (e) => applyModalTerminalTheme(e.target.value))
@@ -316,6 +325,16 @@ document.addEventListener('DOMContentLoaded', () => {
updateModalTerminalFontSizeDisplay() updateModalTerminalFontSizeDisplay()
}) })
if (typeof window !== 'undefined') {
window.__peardockApplyTerminalThemes = (termTheme, _dockerTheme) => {
if (termTheme && terminalThemes[termTheme]) {
applyModalTerminalTheme(termTheme)
const themeSelect = document.getElementById('modal-terminal-theme-select')
if (themeSelect) themeSelect.value = termTheme
}
}
}
export { export {
startTerminal, startTerminal,
appendTerminalOutput, appendTerminalOutput,
+16 -2
View File
@@ -327,8 +327,22 @@ export function showAlert(type, message, options = {}) {
return; return;
} }
// Top-center toast: OFF unless explicitly requested // Top-center toast: OFF unless requested or Settings → forceToast
const wantToast = options.forceToast === true || options.toast === true; let settingsForceToast = false
try {
if (typeof window !== 'undefined' && window.__peardockSettings?.forceToast) {
settingsForceToast = true
} else if (typeof localStorage !== 'undefined') {
const raw = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}')
settingsForceToast = Boolean(raw.forceToast)
}
} catch {
// ignore
}
const wantToast =
options.forceToast === true ||
options.toast === true ||
(settingsForceToast && options.toast !== false && options.forceToast !== false)
// Bell history: keep for non-job feedback unless caller disables // Bell history: keep for non-job feedback unless caller disables
const wantTray = options.tray !== false; const wantTray = options.tray !== false;
+235 -22
View File
@@ -40,12 +40,41 @@ import {
} from '../client/templateLists.js' } from '../client/templateLists.js'
const SETTINGS_KEY = 'peardock.settings.v1' const SETTINGS_KEY = 'peardock.settings.v1'
const SIDEBAR_KEY = 'peardock.sidebar.collapsed'
const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed'
/** Defaults for client-side preferences (localStorage peardock.settings.v1). */
export const DEFAULT_SETTINGS = {
density: 'comfortable',
confirmDestructive: true,
refreshSeconds: 10,
sidebarCollapsed: false,
jobSuccessDismissMs: 3000,
badgeMode: 'alerts', // alerts | all | none
forceToast: false,
openTunnelBrowser: true,
offerLocalTunnelAfterCreate: true,
defaultTunnelSecure: true,
defaultTunnelProtocol: 'tcp',
terminalTheme: 'dark',
dockerTerminalTheme: 'dark',
showFirstConnectTip: true,
settingsTab: 'appearance',
}
export function loadSettings() { export function loadSettings() {
try { try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') const raw = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')
// Migrate sidebar from legacy key if settings has no value yet
if (raw.sidebarCollapsed === undefined && typeof localStorage !== 'undefined') {
raw.sidebarCollapsed = localStorage.getItem(SIDEBAR_KEY) === '1'
}
if (raw.showFirstConnectTip === undefined && typeof localStorage !== 'undefined') {
raw.showFirstConnectTip = localStorage.getItem(FIRST_CONNECT_KEY) !== '1'
}
return { ...DEFAULT_SETTINGS, ...raw }
} catch { } catch {
return {} return { ...DEFAULT_SETTINGS }
} }
} }
@@ -61,7 +90,48 @@ 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'
startListAutoRefresh(s.refreshSeconds) startListAutoRefresh(s.refreshSeconds)
// Sidebar collapsed preference
const sidebar = document.getElementById('sidebar')
if (sidebar) {
const collapsed = Boolean(s.sidebarCollapsed)
sidebar.classList.toggle('collapsed', collapsed)
try {
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0')
} catch {
// ignore
}
}
// First-connect tip flag
try {
if (s.showFirstConnectTip === false) {
localStorage.setItem(FIRST_CONNECT_KEY, '1')
} else {
localStorage.removeItem(FIRST_CONNECT_KEY)
}
} catch {
// ignore
}
// Terminal theme defaults (if terminals expose hooks)
try {
if (typeof window !== 'undefined') {
window.__peardockSettings = s
window.__peardockApplyTerminalThemes?.(s.terminalTheme, s.dockerTerminalTheme)
}
} catch {
// ignore
}
}
/**
* Whether the first-connect tip should show (settings + legacy key).
*/
export function shouldShowFirstConnectTip() {
return loadSettings().showFirstConnectTip !== false
} }
/** /**
@@ -120,9 +190,14 @@ export function updateActivePeerChip(conn) {
let jobDismissTimer = null let jobDismissTimer = null
/** @type {ReturnType<typeof setTimeout>|null} */ /** @type {ReturnType<typeof setTimeout>|null} */
let jobRemoveTimer = null let jobRemoveTimer = null
const JOB_SUCCESS_DISMISS_MS = 3000
const JOB_EXIT_ANIM_MS = 380 const JOB_EXIT_ANIM_MS = 380
function jobSuccessDismissMs() {
const n = Number(loadSettings().jobSuccessDismissMs)
if (!Number.isFinite(n) || n < 0) return 3000
return n
}
/** /**
* Show live job panel. On success, auto-hide after 3s with exit animation. * Show live job panel. On success, auto-hide after 3s with exit animation.
* Errors stay until the next job or manual clear. * Errors stay until the next job or manual clear.
@@ -183,10 +258,14 @@ function scheduleJobDrawerDismiss(drawer) {
if (jobDismissTimer) clearTimeout(jobDismissTimer) if (jobDismissTimer) clearTimeout(jobDismissTimer)
if (jobRemoveTimer) clearTimeout(jobRemoveTimer) if (jobRemoveTimer) clearTimeout(jobRemoveTimer)
const ms = jobSuccessDismissMs()
// 0 = keep success tray until the next job
if (!ms || ms <= 0) return
jobDismissTimer = setTimeout(() => { jobDismissTimer = setTimeout(() => {
jobDismissTimer = null jobDismissTimer = null
dismissJobDrawer(drawer) dismissJobDrawer(drawer)
}, JOB_SUCCESS_DISMISS_MS) }, ms)
} }
/** /**
@@ -625,11 +704,15 @@ export async function loadTunnelsView(opts = {}) {
let tunnelCreateBusy = false let tunnelCreateBusy = false
export async function createTunnelFromForm() { export async function createTunnelFromForm() {
const s = loadSettings()
const name = document.getElementById('tunnel-name')?.value?.trim() const name = document.getElementById('tunnel-name')?.value?.trim()
const host = document.getElementById('tunnel-host')?.value?.trim() || '127.0.0.1' const host = document.getElementById('tunnel-host')?.value?.trim() || '127.0.0.1'
const port = Number(document.getElementById('tunnel-port')?.value) const port = Number(document.getElementById('tunnel-port')?.value)
const protocol = document.getElementById('tunnel-protocol')?.value || 'tcp' const protocol =
const secure = document.getElementById('tunnel-secure')?.checked !== false document.getElementById('tunnel-protocol')?.value || s.defaultTunnelProtocol || 'tcp'
const secureEl = document.getElementById('tunnel-secure')
const secure =
secureEl != null ? secureEl.checked !== false : s.defaultTunnelSecure !== false
const btn = document.getElementById('tunnel-create-btn') const btn = document.getElementById('tunnel-create-btn')
if (!port || port < 1 || port > 65535) { if (!port || port < 1 || port > 65535) {
showAlert('warning', 'Enter a valid port (165535)') showAlert('warning', 'Enter a valid port (165535)')
@@ -806,8 +889,12 @@ export async function connectLocalTunnel(url, opts = {}) {
try { try {
showActivity(`Connecting Holesail client…`) showActivity(`Connecting Holesail client…`)
const { connectLocalHolesail } = await import('../client/holesailLocal.js') const { connectLocalHolesail } = await import('../client/holesailLocal.js')
const openBrowser =
opts.openBrowser !== undefined
? opts.openBrowser !== false
: loadSettings().openTunnelBrowser !== false
const entry = await connectLocalHolesail(url, { const entry = await connectLocalHolesail(url, {
openBrowser: opts.openBrowser !== false, openBrowser,
localPort: opts.localPort, localPort: opts.localPort,
}) })
completeActivity(true, `Local proxy on ${entry.host}:${entry.localPort}`) completeActivity(true, `Local proxy on ${entry.host}:${entry.localPort}`)
@@ -848,14 +935,79 @@ export async function connectLocalTunnel(url, opts = {}) {
/** @type {string[]} in-memory editor state for template list URLs */ /** @type {string[]} in-memory editor state for template list URLs */
let templateUrlsDraft = [] let templateUrlsDraft = []
function setSelectValue(id, value) {
const el = document.getElementById(id)
if (el && value !== undefined && value !== null) el.value = String(value)
}
function setCheckbox(id, checked) {
const el = document.getElementById(id)
if (el) el.checked = Boolean(checked)
}
/**
* Switch settings subtab.
* @param {string} tab
*/
export function showSettingsTab(tab) {
const name = tab || 'appearance'
document.querySelectorAll('#settings-tabs .nav-link').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-settings-tab') === name)
})
document.querySelectorAll('[data-settings-panel]').forEach((panel) => {
panel.classList.toggle('hidden', panel.getAttribute('data-settings-panel') !== name)
})
try {
saveSettings({ settingsTab: name })
} catch {
// ignore
}
}
/**
* Collect form values from settings panels into a partial settings object.
*/
export function readSettingsForm() {
return {
density: document.getElementById('settings-density')?.value || 'comfortable',
sidebarCollapsed: document.getElementById('settings-sidebar')?.value === 'collapsed',
confirmDestructive: document.getElementById('settings-confirm')?.value !== '0',
refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0,
jobSuccessDismissMs: Number(document.getElementById('settings-job-dismiss')?.value) || 0,
badgeMode: document.getElementById('settings-badge-mode')?.value || 'alerts',
forceToast: document.getElementById('settings-force-toast')?.value === '1',
openTunnelBrowser: document.getElementById('settings-tunnel-open-browser')?.value !== '0',
offerLocalTunnelAfterCreate:
document.getElementById('settings-tunnel-offer-local')?.value !== '0',
defaultTunnelSecure: document.getElementById('settings-tunnel-secure')?.value !== '0',
defaultTunnelProtocol:
document.getElementById('settings-tunnel-protocol')?.value === 'udp' ? 'udp' : 'tcp',
terminalTheme: document.getElementById('settings-term-theme')?.value || 'dark',
dockerTerminalTheme: document.getElementById('settings-docker-term-theme')?.value || 'dark',
showFirstConnectTip: document.getElementById('settings-first-connect')?.checked !== false,
templateListUrls: getTemplateListUrls(),
}
}
export function loadSettingsView() { export function loadSettingsView() {
const s = loadSettings() const s = loadSettings()
const dens = document.getElementById('settings-density') setSelectValue('settings-density', s.density || 'comfortable')
const conf = document.getElementById('settings-confirm') setSelectValue('settings-sidebar', s.sidebarCollapsed ? 'collapsed' : 'expanded')
const ref = document.getElementById('settings-refresh') setSelectValue('settings-confirm', s.confirmDestructive === false ? '0' : '1')
if (dens) dens.value = s.density || 'comfortable' setSelectValue('settings-refresh', s.refreshSeconds ?? 10)
if (conf) conf.value = s.confirmDestructive === false ? '0' : '1' setSelectValue('settings-job-dismiss', s.jobSuccessDismissMs ?? 3000)
if (ref) ref.value = s.refreshSeconds ?? 10 setSelectValue('settings-badge-mode', s.badgeMode || 'alerts')
setSelectValue('settings-force-toast', s.forceToast ? '1' : '0')
setSelectValue('settings-tunnel-open-browser', s.openTunnelBrowser === false ? '0' : '1')
setSelectValue('settings-tunnel-offer-local', s.offerLocalTunnelAfterCreate === false ? '0' : '1')
setSelectValue('settings-tunnel-secure', s.defaultTunnelSecure === false ? '0' : '1')
setSelectValue('settings-tunnel-protocol', s.defaultTunnelProtocol || 'tcp')
setSelectValue('settings-term-theme', s.terminalTheme || 'dark')
setSelectValue('settings-docker-term-theme', s.dockerTerminalTheme || 'dark')
setCheckbox('settings-first-connect', s.showFirstConnectTip !== false)
showSettingsTab(s.settingsTab || 'appearance')
const peer = document.getElementById('settings-peer-info') const peer = document.getElementById('settings-peer-info')
const c = manager.active const c = manager.active
if (peer) { if (peer) {
@@ -863,9 +1015,43 @@ export function loadSettingsView() {
? `<div>ID: <code>${escape(c.id)}</code></div> ? `<div>ID: <code>${escape(c.id)}</code></div>
<div>Role: ${escape(c.role || '—')}</div> <div>Role: ${escape(c.role || '—')}</div>
<div>Protocol: v${escape(String(c.protocolVersion ?? '—'))}</div> <div>Protocol: v${escape(String(c.protocolVersion ?? '—'))}</div>
<div>Docker: ${c.dockerHealth?.ok ? 'ok' : '—'}</div>` <div>Docker: ${c.dockerHealth?.ok ? 'ok' : '—'}</div>
<div>Latency: ${c.latency != null ? escape(String(c.latency)) + ' ms' : '—'}</div>`
: 'Not connected' : 'Not connected'
} }
// Server feature flags from metrics if available
const feat = document.getElementById('settings-server-features')
if (feat) {
if (!c?.connected) {
feat.textContent = 'Connect a peer to inspect feature flags.'
} else {
feat.innerHTML = '<span class="text-muted">Loading…</span>'
manager
.request(Methods.getMetrics, {})
.then((res) => {
const f = res?.features || res?.metrics?.features || {}
const rows = [
['Holesail tunnels', f.holesail],
['Swarm', f.swarm],
['Plugins', f.plugins],
]
feat.innerHTML = rows
.map(
([label, val]) =>
`<div>${escape(label)}: <strong>${val === true ? 'on' : val === false ? 'off' : '—'}</strong></div>`
)
.join('') || '<div class="text-muted">No feature flags reported</div>'
})
.catch(() => {
feat.textContent = 'Could not load metrics (peer may not expose getMetrics).'
})
}
}
const ver = document.getElementById('settings-app-version')
if (ver) ver.textContent = '2.0.0'
templateUrlsDraft = getTemplateListUrls() templateUrlsDraft = getTemplateListUrls()
renderTemplateUrlsEditor() renderTemplateUrlsEditor()
} }
@@ -1105,18 +1291,20 @@ export function initOpsApp({ navigateToView, sendCommand }) {
}) })
document.getElementById('events-filter')?.addEventListener('input', () => loadEventsView()) document.getElementById('events-filter')?.addEventListener('input', () => loadEventsView())
document.getElementById('settings-tabs')?.addEventListener('click', (e) => {
const btn = e.target?.closest?.('[data-settings-tab]')
if (!btn) return
showSettingsTab(btn.getAttribute('data-settings-tab'))
})
document.getElementById('settings-save-btn')?.addEventListener('click', () => { document.getElementById('settings-save-btn')?.addEventListener('click', () => {
// Include multi template list URLs
setTemplateListUrls(templateUrlsDraft) setTemplateListUrls(templateUrlsDraft)
const next = saveSettings({ const partial = readSettingsForm()
density: document.getElementById('settings-density')?.value, partial.templateListUrls = getTemplateListUrls()
confirmDestructive: document.getElementById('settings-confirm')?.value !== '0', const next = saveSettings(partial)
refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0,
templateListUrls: getTemplateListUrls(),
})
startListAutoRefresh(next.refreshSeconds) startListAutoRefresh(next.refreshSeconds)
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.() if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
showAlert('success', 'Preferences saved') showAlert('success', 'Preferences saved', { badge: false })
}) })
document.getElementById('settings-template-url-add')?.addEventListener('click', () => { document.getElementById('settings-template-url-add')?.addEventListener('click', () => {
@@ -1137,6 +1325,28 @@ export function initOpsApp({ navigateToView, sendCommand }) {
saveTemplateListSettings({ reload: true }) saveTemplateListSettings({ reload: true })
}) })
document.getElementById('settings-notif-mark-read')?.addEventListener('click', () => {
try {
notificationManager.markAllAsRead()
showAlert('success', 'All notifications marked read', { badge: false })
} catch (err) {
showAlert('danger', err.message || 'Failed')
}
})
document.getElementById('settings-notif-clear')?.addEventListener('click', async () => {
const ok = await confirmDestructive(
'Clear notification history?',
'This removes all items from the notification center on this client.'
)
if (!ok) return
try {
notificationManager.clearAll()
showAlert('success', 'Notification history cleared', { badge: false })
} catch (err) {
showAlert('danger', err.message || 'Failed')
}
})
// Apply density + refresh on boot // Apply density + refresh on boot
applySettings(loadSettings()) applySettings(loadSettings())
templateUrlsDraft = getTemplateListUrls() templateUrlsDraft = getTemplateListUrls()
@@ -1246,6 +1456,9 @@ export function initOpsApp({ navigateToView, sendCommand }) {
shouldConfirmDestructive, shouldConfirmDestructive,
loadSettings, loadSettings,
applySettings, applySettings,
shouldShowFirstConnectTip,
showSettingsTab,
DEFAULT_SETTINGS,
startListAutoRefresh, startListAutoRefresh,
getTemplateListUrls, getTemplateListUrls,
setTemplateListUrls, setTemplateListUrls,
+27
View File
@@ -387,6 +387,33 @@
color: var(--text-secondary, #c8d1df); color: var(--text-secondary, #c8d1df);
} }
.settings-subtabs .nav-link {
color: var(--text-secondary, #c8d1df);
background: transparent;
border: 1px solid #334155;
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
}
.settings-subtabs .nav-link:hover {
color: #f4f7fb;
border-color: #64748b;
background: rgba(148, 163, 184, 0.08);
}
.settings-subtabs .nav-link.active {
color: #0b1220;
background: linear-gradient(135deg, #7dd3fc, #38bdf8);
border-color: transparent;
}
.settings-panel.hidden {
display: none !important;
}
.settings-section .form-text {
color: #8b9bb0;
font-size: 0.78rem;
}
.form-hint-suggested { .form-hint-suggested {
font-size: 0.75rem; font-size: 0.75rem;
color: #7dd3fc; color: #7dd3fc;