Expand Settings into categorized subtabs with full client prefs
CI / test (push) Successful in 10m6s
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:
@@ -1051,7 +1051,11 @@ function applyRoleUI() {
|
||||
|
||||
function showFirstConnectChecklist() {
|
||||
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 {
|
||||
// ignore
|
||||
}
|
||||
@@ -2554,8 +2558,10 @@ function populateContainerDetails(config, container) {
|
||||
if (typeof window.peardockOps?.loadTunnelsView === 'function') {
|
||||
// warm tunnels view cache
|
||||
}
|
||||
// Offer local connect via designed modal
|
||||
if (url && window.peardockOps?.connectLocalTunnel) {
|
||||
// Offer local connect via designed modal (Settings → Tunnels)
|
||||
const offerLocal =
|
||||
window.peardockOps?.loadSettings?.()?.offerLocalTunnelAfterCreate !== false;
|
||||
if (url && offerLocal && window.peardockOps?.connectLocalTunnel) {
|
||||
const open = window.peardockOps.askUserConfirm
|
||||
? await window.peardockOps.askUserConfirm(
|
||||
'Open local tunnel?',
|
||||
@@ -2564,7 +2570,7 @@ function populateContainerDetails(config, container) {
|
||||
)
|
||||
: false;
|
||||
if (open) {
|
||||
await window.peardockOps.connectLocalTunnel(url, { openBrowser: true });
|
||||
await window.peardockOps.connectLocalTunnel(url);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+213
-11
@@ -1711,40 +1711,177 @@ services:
|
||||
<!-- Settings -->
|
||||
<div id="settings-view" class="view hidden">
|
||||
<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>
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="settings-save-btn">
|
||||
<i class="fas fa-floppy-disk me-1"></i>Save all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills settings-subtabs flex-wrap gap-1 mb-3" id="settings-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<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>Client</h3>
|
||||
<h3>Layout & density</h3>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Table density</label>
|
||||
<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">Confirm destructive actions</label>
|
||||
<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 & 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">Auto-refresh lists (seconds)</label>
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary btn-sm mt-3" id="settings-save-btn">Save preferences</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="settings-panel hidden" id="settings-panel-notifications" data-settings-panel="notifications">
|
||||
<div class="settings-section">
|
||||
<h3>Bell & 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 & 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 & 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>). Add as many as you want — peardock merges them and removes duplicates (same image / stack repo / title). Portainer only allows one list.
|
||||
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">
|
||||
@@ -1763,10 +1900,26 @@ services:
|
||||
</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">
|
||||
@@ -1776,10 +1929,59 @@ services:
|
||||
<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>About</h3>
|
||||
<p class="small text-muted mb-1">peardock — decentralized Docker control plane over HyperDHT.</p>
|
||||
<p class="small text-muted mb-0">Keyboard: <kbd>Ctrl</kbd>+<kbd>K</kbd> command palette · <kbd>?</kbd> shortcuts</p>
|
||||
<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>
|
||||
|
||||
@@ -370,6 +370,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
dockerTerminalSession?.xterm?.clear()
|
||||
})
|
||||
const themeSelect = document.getElementById('docker-terminal-theme-select')
|
||||
try {
|
||||
const pref = window.__peardockSettings?.dockerTerminalTheme
|
||||
if (pref && dockerTerminalThemes[pref]) {
|
||||
dockerTerminalTheme = pref
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (themeSelect) {
|
||||
themeSelect.value = dockerTerminalTheme
|
||||
themeSelect.addEventListener('change', (e) => {
|
||||
@@ -383,4 +391,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 }
|
||||
|
||||
@@ -17,9 +17,30 @@ const MAX_NOTIFICATIONS = 100 // Limit stored notifications
|
||||
* @param {{ badge?: boolean }} [options]
|
||||
* @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 = {}) {
|
||||
if (options.badge === true) return true
|
||||
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()
|
||||
return t === 'danger' || t === 'warning' || t === 'error'
|
||||
}
|
||||
|
||||
@@ -309,6 +309,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (session?.xterm) session.xterm.clear()
|
||||
})
|
||||
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) {
|
||||
themeSelect.value = modalTerminalTheme
|
||||
themeSelect.addEventListener('change', (e) => applyModalTerminalTheme(e.target.value))
|
||||
@@ -316,6 +325,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 {
|
||||
startTerminal,
|
||||
appendTerminalOutput,
|
||||
|
||||
+16
-2
@@ -327,8 +327,22 @@ export function showAlert(type, message, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Top-center toast: OFF unless explicitly requested
|
||||
const wantToast = options.forceToast === true || options.toast === true;
|
||||
// Top-center toast: OFF unless requested or Settings → forceToast
|
||||
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
|
||||
const wantTray = options.tray !== false;
|
||||
|
||||
|
||||
+236
-23
@@ -40,12 +40,41 @@ import {
|
||||
} from '../client/templateLists.js'
|
||||
|
||||
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() {
|
||||
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 {
|
||||
return {}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +90,48 @@ let listRefreshTimer = null
|
||||
|
||||
export function applySettings(s = loadSettings()) {
|
||||
document.body.dataset.density = s.density || 'comfortable'
|
||||
document.body.dataset.badgeMode = s.badgeMode || 'alerts'
|
||||
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
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let jobRemoveTimer = null
|
||||
const JOB_SUCCESS_DISMISS_MS = 3000
|
||||
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.
|
||||
* Errors stay until the next job or manual clear.
|
||||
@@ -183,10 +258,14 @@ function scheduleJobDrawerDismiss(drawer) {
|
||||
if (jobDismissTimer) clearTimeout(jobDismissTimer)
|
||||
if (jobRemoveTimer) clearTimeout(jobRemoveTimer)
|
||||
|
||||
const ms = jobSuccessDismissMs()
|
||||
// 0 = keep success tray until the next job
|
||||
if (!ms || ms <= 0) return
|
||||
|
||||
jobDismissTimer = setTimeout(() => {
|
||||
jobDismissTimer = null
|
||||
dismissJobDrawer(drawer)
|
||||
}, JOB_SUCCESS_DISMISS_MS)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -625,11 +704,15 @@ export async function loadTunnelsView(opts = {}) {
|
||||
let tunnelCreateBusy = false
|
||||
|
||||
export async function createTunnelFromForm() {
|
||||
const s = loadSettings()
|
||||
const name = document.getElementById('tunnel-name')?.value?.trim()
|
||||
const host = document.getElementById('tunnel-host')?.value?.trim() || '127.0.0.1'
|
||||
const port = Number(document.getElementById('tunnel-port')?.value)
|
||||
const protocol = document.getElementById('tunnel-protocol')?.value || 'tcp'
|
||||
const secure = document.getElementById('tunnel-secure')?.checked !== false
|
||||
const protocol =
|
||||
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')
|
||||
if (!port || port < 1 || port > 65535) {
|
||||
showAlert('warning', 'Enter a valid port (1–65535)')
|
||||
@@ -806,8 +889,12 @@ export async function connectLocalTunnel(url, opts = {}) {
|
||||
try {
|
||||
showActivity(`Connecting Holesail client…`)
|
||||
const { connectLocalHolesail } = await import('../client/holesailLocal.js')
|
||||
const openBrowser =
|
||||
opts.openBrowser !== undefined
|
||||
? opts.openBrowser !== false
|
||||
: loadSettings().openTunnelBrowser !== false
|
||||
const entry = await connectLocalHolesail(url, {
|
||||
openBrowser: opts.openBrowser !== false,
|
||||
openBrowser,
|
||||
localPort: opts.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 */
|
||||
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() {
|
||||
const s = loadSettings()
|
||||
const dens = document.getElementById('settings-density')
|
||||
const conf = document.getElementById('settings-confirm')
|
||||
const ref = document.getElementById('settings-refresh')
|
||||
if (dens) dens.value = s.density || 'comfortable'
|
||||
if (conf) conf.value = s.confirmDestructive === false ? '0' : '1'
|
||||
if (ref) ref.value = s.refreshSeconds ?? 10
|
||||
setSelectValue('settings-density', s.density || 'comfortable')
|
||||
setSelectValue('settings-sidebar', s.sidebarCollapsed ? 'collapsed' : 'expanded')
|
||||
setSelectValue('settings-confirm', s.confirmDestructive === false ? '0' : '1')
|
||||
setSelectValue('settings-refresh', s.refreshSeconds ?? 10)
|
||||
setSelectValue('settings-job-dismiss', s.jobSuccessDismissMs ?? 3000)
|
||||
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 c = manager.active
|
||||
if (peer) {
|
||||
@@ -863,9 +1015,43 @@ export function loadSettingsView() {
|
||||
? `<div>ID: <code>${escape(c.id)}</code></div>
|
||||
<div>Role: ${escape(c.role || '—')}</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'
|
||||
}
|
||||
|
||||
// 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()
|
||||
renderTemplateUrlsEditor()
|
||||
}
|
||||
@@ -1105,18 +1291,20 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
})
|
||||
document.getElementById('events-filter')?.addEventListener('input', () => loadEventsView())
|
||||
|
||||
document.getElementById('settings-save-btn')?.addEventListener('click', () => {
|
||||
// Include multi template list URLs
|
||||
setTemplateListUrls(templateUrlsDraft)
|
||||
const next = saveSettings({
|
||||
density: document.getElementById('settings-density')?.value,
|
||||
confirmDestructive: document.getElementById('settings-confirm')?.value !== '0',
|
||||
refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0,
|
||||
templateListUrls: getTemplateListUrls(),
|
||||
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', () => {
|
||||
setTemplateListUrls(templateUrlsDraft)
|
||||
const partial = readSettingsForm()
|
||||
partial.templateListUrls = getTemplateListUrls()
|
||||
const next = saveSettings(partial)
|
||||
startListAutoRefresh(next.refreshSeconds)
|
||||
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
|
||||
showAlert('success', 'Preferences saved')
|
||||
showAlert('success', 'Preferences saved', { badge: false })
|
||||
})
|
||||
|
||||
document.getElementById('settings-template-url-add')?.addEventListener('click', () => {
|
||||
@@ -1137,6 +1325,28 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
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
|
||||
applySettings(loadSettings())
|
||||
templateUrlsDraft = getTemplateListUrls()
|
||||
@@ -1246,6 +1456,9 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
shouldConfirmDestructive,
|
||||
loadSettings,
|
||||
applySettings,
|
||||
shouldShowFirstConnectTip,
|
||||
showSettingsTab,
|
||||
DEFAULT_SETTINGS,
|
||||
startListAutoRefresh,
|
||||
getTemplateListUrls,
|
||||
setTemplateListUrls,
|
||||
|
||||
+27
@@ -387,6 +387,33 @@
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
color: #7dd3fc;
|
||||
|
||||
Reference in New Issue
Block a user